874 lines
29 KiB
Rust
874 lines
29 KiB
Rust
//! Webhook management and receiver API routes
|
|
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::{Path, State},
|
|
http::HeaderMap,
|
|
response::IntoResponse,
|
|
routing::post,
|
|
Json, Router,
|
|
};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use attune_common::{
|
|
mq::{EventCreatedPayload, MessageEnvelope, MessageType},
|
|
repositories::{
|
|
event::{CreateEventInput, EventRepository},
|
|
trigger::{TriggerRepository, WebhookEventLogInput},
|
|
Create, FindById, FindByRef,
|
|
},
|
|
};
|
|
|
|
use attune_common::rbac::{Action, AuthorizationContext, Resource};
|
|
|
|
use crate::{
|
|
auth::middleware::RequireAuth,
|
|
authz::{AuthorizationCheck, AuthorizationService},
|
|
dto::{
|
|
trigger::TriggerResponse,
|
|
webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
|
|
ApiResponse,
|
|
},
|
|
middleware::{ApiError, ApiResult},
|
|
state::AppState,
|
|
webhook_security,
|
|
};
|
|
|
|
// ============================================================================
|
|
// WEBHOOK CONFIG HELPERS
|
|
// ============================================================================
|
|
|
|
/// Helper to extract boolean value from webhook_config JSON using path notation
|
|
fn get_webhook_config_bool(
|
|
trigger: &attune_common::models::trigger::Trigger,
|
|
path: &str,
|
|
default: bool,
|
|
) -> bool {
|
|
let config = match &trigger.webhook_config {
|
|
Some(c) => c,
|
|
None => return default,
|
|
};
|
|
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
let mut current = config;
|
|
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if i == parts.len() - 1 {
|
|
// Last part - extract value
|
|
return current
|
|
.get(part)
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(default);
|
|
} else {
|
|
// Intermediate part - navigate deeper
|
|
current = match current.get(part) {
|
|
Some(v) => v,
|
|
None => return default,
|
|
};
|
|
}
|
|
}
|
|
|
|
default
|
|
}
|
|
|
|
/// Helper to extract string value from webhook_config JSON using path notation
|
|
fn get_webhook_config_str(
|
|
trigger: &attune_common::models::trigger::Trigger,
|
|
path: &str,
|
|
) -> Option<String> {
|
|
let config = trigger.webhook_config.as_ref()?;
|
|
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
let mut current = config;
|
|
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if i == parts.len() - 1 {
|
|
// Last part - extract value
|
|
return current
|
|
.get(part)
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
} else {
|
|
// Intermediate part - navigate deeper
|
|
current = current.get(part)?;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Helper to extract i64 value from webhook_config JSON using path notation
|
|
fn get_webhook_config_i64(
|
|
trigger: &attune_common::models::trigger::Trigger,
|
|
path: &str,
|
|
) -> Option<i64> {
|
|
let config = trigger.webhook_config.as_ref()?;
|
|
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
let mut current = config;
|
|
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if i == parts.len() - 1 {
|
|
// Last part - extract value
|
|
return current.get(part).and_then(|v| v.as_i64());
|
|
} else {
|
|
// Intermediate part - navigate deeper
|
|
current = current.get(part)?;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Helper to extract array of strings from webhook_config JSON using path notation
|
|
fn get_webhook_config_array(
|
|
trigger: &attune_common::models::trigger::Trigger,
|
|
path: &str,
|
|
) -> Option<Vec<String>> {
|
|
let config = trigger.webhook_config.as_ref()?;
|
|
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
let mut current = config;
|
|
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if i == parts.len() - 1 {
|
|
// Last part - extract array
|
|
return current.get(part).and_then(|v| {
|
|
v.as_array().map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
|
.collect()
|
|
})
|
|
});
|
|
} else {
|
|
// Intermediate part - navigate deeper
|
|
current = current.get(part)?;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
// ============================================================================
|
|
// WEBHOOK MANAGEMENT ENDPOINTS
|
|
// ============================================================================
|
|
|
|
/// Enable webhooks for a trigger
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/triggers/{ref}/webhooks/enable",
|
|
tag = "webhooks",
|
|
params(
|
|
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Webhooks enabled", body = TriggerResponse),
|
|
(status = 404, description = "Trigger not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("jwt" = [])
|
|
)
|
|
)]
|
|
pub async fn enable_webhook(
|
|
State(state): State<Arc<AppState>>,
|
|
RequireAuth(user): RequireAuth,
|
|
Path(trigger_ref): Path<String>,
|
|
) -> ApiResult<impl IntoResponse> {
|
|
// First, find the trigger by ref to get its ID
|
|
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
|
|
|
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
|
|
let identity_id = user
|
|
.identity_id()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
|
let authz = AuthorizationService::new(state.db.clone());
|
|
let mut ctx = AuthorizationContext::new(identity_id);
|
|
ctx.target_ref = Some(trigger.r#ref.clone());
|
|
ctx.pack_ref = trigger.pack_ref.clone();
|
|
authz
|
|
.authorize(
|
|
&user,
|
|
AuthorizationCheck {
|
|
resource: Resource::Triggers,
|
|
action: Action::Update,
|
|
context: ctx,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Enable webhooks for this trigger
|
|
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
|
|
|
// Fetch the updated trigger to return
|
|
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
|
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
|
|
|
let response = TriggerResponse::from(updated_trigger);
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
/// Disable webhooks for a trigger
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/triggers/{ref}/webhooks/disable",
|
|
tag = "webhooks",
|
|
params(
|
|
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Webhooks disabled", body = TriggerResponse),
|
|
(status = 404, description = "Trigger not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("jwt" = [])
|
|
)
|
|
)]
|
|
pub async fn disable_webhook(
|
|
State(state): State<Arc<AppState>>,
|
|
RequireAuth(user): RequireAuth,
|
|
Path(trigger_ref): Path<String>,
|
|
) -> ApiResult<impl IntoResponse> {
|
|
// First, find the trigger by ref to get its ID
|
|
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
|
|
|
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
|
|
let identity_id = user
|
|
.identity_id()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
|
let authz = AuthorizationService::new(state.db.clone());
|
|
let mut ctx = AuthorizationContext::new(identity_id);
|
|
ctx.target_ref = Some(trigger.r#ref.clone());
|
|
ctx.pack_ref = trigger.pack_ref.clone();
|
|
authz
|
|
.authorize(
|
|
&user,
|
|
AuthorizationCheck {
|
|
resource: Resource::Triggers,
|
|
action: Action::Update,
|
|
context: ctx,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Disable webhooks for this trigger
|
|
TriggerRepository::disable_webhook(&state.db, trigger.id)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
|
|
|
// Fetch the updated trigger to return
|
|
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
|
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
|
|
|
let response = TriggerResponse::from(updated_trigger);
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
/// Regenerate webhook key for a trigger
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/triggers/{ref}/webhooks/regenerate",
|
|
tag = "webhooks",
|
|
params(
|
|
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Webhook key regenerated", body = TriggerResponse),
|
|
(status = 400, description = "Webhooks not enabled for this trigger"),
|
|
(status = 404, description = "Trigger not found"),
|
|
(status = 500, description = "Internal server error")
|
|
),
|
|
security(
|
|
("jwt" = [])
|
|
)
|
|
)]
|
|
pub async fn regenerate_webhook_key(
|
|
State(state): State<Arc<AppState>>,
|
|
RequireAuth(user): RequireAuth,
|
|
Path(trigger_ref): Path<String>,
|
|
) -> ApiResult<impl IntoResponse> {
|
|
// First, find the trigger by ref to get its ID
|
|
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
|
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
|
|
|
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
|
|
let identity_id = user
|
|
.identity_id()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
|
|
let authz = AuthorizationService::new(state.db.clone());
|
|
let mut ctx = AuthorizationContext::new(identity_id);
|
|
ctx.target_ref = Some(trigger.r#ref.clone());
|
|
ctx.pack_ref = trigger.pack_ref.clone();
|
|
authz
|
|
.authorize(
|
|
&user,
|
|
AuthorizationCheck {
|
|
resource: Resource::Triggers,
|
|
action: Action::Update,
|
|
context: ctx,
|
|
},
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Check if webhooks are enabled
|
|
if !trigger.webhook_enabled {
|
|
return Err(ApiError::BadRequest(
|
|
"Webhooks are not enabled for this trigger. Enable webhooks first.".to_string(),
|
|
));
|
|
}
|
|
|
|
// Regenerate the webhook key
|
|
let _regenerate_result = TriggerRepository::regenerate_webhook_key(&state.db, trigger.id)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
|
|
|
// Fetch the updated trigger to return
|
|
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
|
.await
|
|
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
|
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
|
|
|
let response = TriggerResponse::from(updated_trigger);
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
// ============================================================================
|
|
// WEBHOOK RECEIVER ENDPOINT
|
|
// ============================================================================
|
|
|
|
/// Webhook receiver endpoint - receives webhook events and creates events
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/webhooks/{webhook_key}",
|
|
tag = "webhooks",
|
|
params(
|
|
("webhook_key" = String, Path, description = "Webhook key")
|
|
),
|
|
request_body = WebhookReceiverRequest,
|
|
responses(
|
|
(status = 200, description = "Webhook received and event created", body = WebhookReceiverResponse),
|
|
(status = 404, description = "Invalid webhook key"),
|
|
(status = 429, description = "Rate limit exceeded"),
|
|
(status = 500, description = "Internal server error")
|
|
)
|
|
)]
|
|
pub async fn receive_webhook(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(webhook_key): Path<String>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> ApiResult<impl IntoResponse> {
|
|
let start_time = Instant::now();
|
|
|
|
// Extract metadata from headers
|
|
let source_ip = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
|
|
.map(|s| s.to_string());
|
|
|
|
let user_agent = headers
|
|
.get("user-agent")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
let signature = headers
|
|
.get("x-webhook-signature")
|
|
.or_else(|| headers.get("x-hub-signature-256"))
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string());
|
|
|
|
// Parse JSON payload
|
|
let payload: WebhookReceiverRequest = serde_json::from_slice(&body)
|
|
.map_err(|e| ApiError::BadRequest(format!("Invalid JSON payload: {}", e)))?;
|
|
|
|
let payload_size_bytes = body.len() as i32;
|
|
|
|
// Look up trigger by webhook key
|
|
let trigger = match TriggerRepository::find_by_webhook_key(&state.db, &webhook_key).await {
|
|
Ok(Some(t)) => t,
|
|
Ok(None) => {
|
|
// Log failed attempt
|
|
let _ = log_webhook_failure(
|
|
&state,
|
|
webhook_key.clone(),
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
404,
|
|
"Invalid webhook key".to_string(),
|
|
start_time,
|
|
)
|
|
.await;
|
|
return Err(ApiError::NotFound("Invalid webhook key".to_string()));
|
|
}
|
|
Err(e) => {
|
|
let _ = log_webhook_failure(
|
|
&state,
|
|
webhook_key.clone(),
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
500,
|
|
e.to_string(),
|
|
start_time,
|
|
)
|
|
.await;
|
|
return Err(ApiError::InternalServerError(e.to_string()));
|
|
}
|
|
};
|
|
|
|
// Verify webhooks are enabled
|
|
if !trigger.webhook_enabled {
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
400,
|
|
Some("Webhooks not enabled for this trigger".to_string()),
|
|
start_time,
|
|
None,
|
|
false,
|
|
None,
|
|
)
|
|
.await;
|
|
return Err(ApiError::BadRequest(
|
|
"Webhooks are not enabled for this trigger".to_string(),
|
|
));
|
|
}
|
|
|
|
// Phase 3: Check payload size limit
|
|
if let Some(limit_kb) = get_webhook_config_i64(&trigger, "payload_size_limit_kb") {
|
|
let limit_bytes = limit_kb * 1024;
|
|
if i64::from(payload_size_bytes) > limit_bytes {
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
413,
|
|
Some(format!(
|
|
"Payload too large: {} bytes (limit: {} bytes)",
|
|
payload_size_bytes, limit_bytes
|
|
)),
|
|
start_time,
|
|
None,
|
|
false,
|
|
None,
|
|
)
|
|
.await;
|
|
return Err(ApiError::BadRequest(format!(
|
|
"Payload too large. Maximum size: {} KB",
|
|
limit_kb
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Phase 3: Check IP whitelist
|
|
let ip_whitelist_enabled = get_webhook_config_bool(&trigger, "ip_whitelist/enabled", false);
|
|
let ip_allowed = if ip_whitelist_enabled {
|
|
if let Some(ref ip) = source_ip {
|
|
if let Some(whitelist) = get_webhook_config_array(&trigger, "ip_whitelist/ips") {
|
|
match webhook_security::check_ip_in_whitelist(ip, &whitelist) {
|
|
Ok(allowed) => {
|
|
if !allowed {
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
403,
|
|
Some("IP address not in whitelist".to_string()),
|
|
start_time,
|
|
None,
|
|
false,
|
|
Some(false),
|
|
)
|
|
.await;
|
|
return Err(ApiError::Forbidden("IP address not allowed".to_string()));
|
|
}
|
|
Some(true)
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("IP whitelist check error: {}", e);
|
|
Some(false)
|
|
}
|
|
}
|
|
} else {
|
|
Some(false)
|
|
}
|
|
} else {
|
|
Some(false)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Phase 3: Check rate limit
|
|
let rate_limit_enabled = get_webhook_config_bool(&trigger, "rate_limit/enabled", false);
|
|
if rate_limit_enabled {
|
|
if let (Some(max_requests), Some(window_seconds)) = (
|
|
get_webhook_config_i64(&trigger, "rate_limit/requests"),
|
|
get_webhook_config_i64(&trigger, "rate_limit/window_seconds"),
|
|
) {
|
|
// Note: Rate limit checking would need to be implemented with a time-series approach
|
|
// For now, we skip this check as the repository function was removed
|
|
let allowed = true; // TODO: Implement proper rate limiting
|
|
|
|
if !allowed {
|
|
{
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
429,
|
|
Some("Rate limit exceeded".to_string()),
|
|
start_time,
|
|
None,
|
|
true,
|
|
ip_allowed,
|
|
)
|
|
.await;
|
|
return Err(ApiError::TooManyRequests(format!(
|
|
"Rate limit exceeded. Maximum {} requests per {} seconds",
|
|
max_requests, window_seconds
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 3: Verify HMAC signature
|
|
let hmac_enabled = get_webhook_config_bool(&trigger, "hmac/enabled", false);
|
|
let hmac_verified = if hmac_enabled {
|
|
if let (Some(secret), Some(algorithm)) = (
|
|
get_webhook_config_str(&trigger, "hmac/secret"),
|
|
get_webhook_config_str(&trigger, "hmac/algorithm"),
|
|
) {
|
|
if let Some(sig) = signature {
|
|
match webhook_security::verify_hmac_signature(&body, &sig, &secret, &algorithm) {
|
|
Ok(valid) => {
|
|
if !valid {
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
401,
|
|
Some("Invalid HMAC signature".to_string()),
|
|
start_time,
|
|
Some(false),
|
|
false,
|
|
ip_allowed,
|
|
)
|
|
.await;
|
|
return Err(ApiError::Unauthorized(
|
|
"Invalid webhook signature".to_string(),
|
|
));
|
|
}
|
|
Some(true)
|
|
}
|
|
Err(e) => {
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
401,
|
|
Some(format!("HMAC verification error: {}", e)),
|
|
start_time,
|
|
Some(false),
|
|
false,
|
|
ip_allowed,
|
|
)
|
|
.await;
|
|
return Err(ApiError::Unauthorized(format!(
|
|
"Signature verification failed: {}",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
} else {
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
401,
|
|
Some("HMAC signature required but not provided".to_string()),
|
|
start_time,
|
|
Some(false),
|
|
false,
|
|
ip_allowed,
|
|
)
|
|
.await;
|
|
return Err(ApiError::Unauthorized("Signature required".to_string()));
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Build config with webhook context metadata
|
|
let mut config = serde_json::json!({
|
|
"source": "webhook",
|
|
"webhook_key": webhook_key,
|
|
"received_at": chrono::Utc::now().to_rfc3339(),
|
|
});
|
|
|
|
// Add optional metadata
|
|
if let Some(headers) = payload.headers {
|
|
config["headers"] = headers;
|
|
}
|
|
if let Some(ref ip) = source_ip {
|
|
config["source_ip"] = serde_json::Value::String(ip.clone());
|
|
}
|
|
if let Some(ref ua) = user_agent {
|
|
config["user_agent"] = serde_json::Value::String(ua.clone());
|
|
}
|
|
let hmac_enabled = get_webhook_config_bool(&trigger, "hmac/enabled", false);
|
|
if hmac_enabled {
|
|
config["hmac_verified"] = serde_json::Value::Bool(hmac_verified.unwrap_or(false));
|
|
}
|
|
|
|
// Create event
|
|
let event_input = CreateEventInput {
|
|
trigger: Some(trigger.id),
|
|
trigger_ref: trigger.r#ref.clone(),
|
|
config: Some(config),
|
|
payload: Some(payload.payload),
|
|
source: None,
|
|
source_ref: Some("webhook".to_string()),
|
|
rule: None,
|
|
rule_ref: None,
|
|
};
|
|
|
|
let event = EventRepository::create(&state.db, event_input)
|
|
.await
|
|
.map_err(|e| {
|
|
let _ = futures::executor::block_on(log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
None,
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
500,
|
|
Some(format!("Failed to create event: {}", e)),
|
|
start_time,
|
|
hmac_verified,
|
|
false,
|
|
ip_allowed,
|
|
));
|
|
ApiError::InternalServerError(e.to_string())
|
|
})?;
|
|
|
|
// Publish EventCreated message to message queue if publisher is available
|
|
tracing::info!(
|
|
"Webhook event {} created, attempting to publish EventCreated message",
|
|
event.id
|
|
);
|
|
if let Some(publisher) = state.get_publisher().await {
|
|
let message_payload = EventCreatedPayload {
|
|
event_id: event.id,
|
|
trigger_id: event.trigger,
|
|
trigger_ref: event.trigger_ref.clone(),
|
|
sensor_id: event.source,
|
|
sensor_ref: event.source_ref.clone(),
|
|
payload: event.payload.clone().unwrap_or(serde_json::json!({})),
|
|
config: event.config.clone(),
|
|
};
|
|
|
|
let envelope = MessageEnvelope::new(MessageType::EventCreated, message_payload)
|
|
.with_source("api-webhook-receiver");
|
|
|
|
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
|
tracing::warn!(
|
|
"Failed to publish EventCreated message for event {}: {}",
|
|
event.id,
|
|
e
|
|
);
|
|
// Continue even if message publishing fails - event is already recorded
|
|
} else {
|
|
tracing::info!(
|
|
"Published EventCreated message for event {} (trigger: {})",
|
|
event.id,
|
|
event.trigger_ref
|
|
);
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
"Publisher not available, cannot publish EventCreated message for event {}",
|
|
event.id
|
|
);
|
|
}
|
|
|
|
// Log successful webhook
|
|
let _ = log_webhook_event(
|
|
&state,
|
|
&trigger,
|
|
&webhook_key,
|
|
Some(event.id),
|
|
source_ip.clone(),
|
|
user_agent.clone(),
|
|
payload_size_bytes,
|
|
200,
|
|
None,
|
|
start_time,
|
|
hmac_verified,
|
|
false,
|
|
ip_allowed,
|
|
)
|
|
.await;
|
|
|
|
let response = WebhookReceiverResponse {
|
|
event_id: event.id,
|
|
trigger_ref: trigger.r#ref.clone(),
|
|
received_at: event.created,
|
|
message: "Webhook received successfully".to_string(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse::new(response)))
|
|
}
|
|
|
|
// Helper function to log webhook events
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn log_webhook_event(
|
|
state: &AppState,
|
|
trigger: &attune_common::models::trigger::Trigger,
|
|
webhook_key: &str,
|
|
event_id: Option<i64>,
|
|
source_ip: Option<String>,
|
|
user_agent: Option<String>,
|
|
payload_size_bytes: i32,
|
|
status_code: i32,
|
|
error_message: Option<String>,
|
|
start_time: Instant,
|
|
hmac_verified: Option<bool>,
|
|
rate_limited: bool,
|
|
ip_allowed: Option<bool>,
|
|
) -> Result<(), attune_common::error::Error> {
|
|
let processing_time_ms = start_time.elapsed().as_millis() as i32;
|
|
|
|
let log_input = WebhookEventLogInput {
|
|
trigger_id: trigger.id,
|
|
trigger_ref: trigger.r#ref.clone(),
|
|
webhook_key: webhook_key.to_string(),
|
|
event_id,
|
|
source_ip,
|
|
user_agent,
|
|
payload_size_bytes: Some(payload_size_bytes),
|
|
headers: None, // Could be added if needed
|
|
status_code,
|
|
error_message,
|
|
processing_time_ms: Some(processing_time_ms),
|
|
hmac_verified,
|
|
rate_limited,
|
|
ip_allowed,
|
|
};
|
|
|
|
TriggerRepository::log_webhook_event(&state.db, log_input).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// Helper function to log failures when trigger is not found
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn log_webhook_failure(
|
|
_state: &AppState,
|
|
webhook_key: String,
|
|
source_ip: Option<String>,
|
|
user_agent: Option<String>,
|
|
payload_size_bytes: i32,
|
|
status_code: i32,
|
|
error_message: String,
|
|
start_time: Instant,
|
|
) -> Result<(), attune_common::error::Error> {
|
|
let processing_time_ms = start_time.elapsed().as_millis() as i32;
|
|
|
|
// We can't log to webhook_event_log without a trigger_id, so just log to tracing
|
|
tracing::warn!(
|
|
webhook_key = %webhook_key,
|
|
source_ip = ?source_ip,
|
|
user_agent = ?user_agent,
|
|
payload_size_bytes = payload_size_bytes,
|
|
status_code = status_code,
|
|
error_message = %error_message,
|
|
processing_time_ms = processing_time_ms,
|
|
"Webhook request failed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// ROUTER
|
|
// ============================================================================
|
|
|
|
pub fn routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
// Webhook management routes (protected)
|
|
.route("/triggers/{ref}/webhooks/enable", post(enable_webhook))
|
|
.route("/triggers/{ref}/webhooks/disable", post(disable_webhook))
|
|
.route(
|
|
"/triggers/{ref}/webhooks/regenerate",
|
|
post(regenerate_webhook_key),
|
|
)
|
|
// TODO: Add Phase 3 management endpoints for HMAC, rate limiting, IP whitelist
|
|
// Webhook receiver route (public - no auth required)
|
|
.route("/webhooks/{webhook_key}", post(receive_webhook))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_webhook_routes_structure() {
|
|
let _router = routes();
|
|
}
|
|
}
|