risingwave_frontend/webhook/
utils.rs1use std::collections::HashMap;
16use std::sync::LazyLock;
17
18use anyhow::anyhow;
19use axum::Json;
20use axum::http::{HeaderMap, StatusCode};
21use axum::response::IntoResponse;
22use risingwave_common::log::LogSuppressor;
23use risingwave_common::row::OwnedRow;
24use risingwave_common::secret::LocalSecretManager;
25use risingwave_common::types::JsonbVal;
26use risingwave_pb::expr::ExprNode;
27use serde_json::json;
28use thiserror_ext::AsReport;
29
30use crate::expr::ExprImpl;
31
32static WEBHOOK_ERROR_LOG_SUPPRESSOR: LazyLock<LogSuppressor> =
33 LazyLock::new(LogSuppressor::default);
34
35#[derive(Debug)]
36pub struct WebhookError {
37 err: anyhow::Error,
38 code: StatusCode,
39}
40
41pub(crate) type Result<T> = std::result::Result<T, WebhookError>;
42
43pub(crate) fn err(err: impl Into<anyhow::Error>, code: StatusCode) -> WebhookError {
44 WebhookError {
45 err: err.into(),
46 code,
47 }
48}
49
50impl WebhookError {
51 #[cfg(test)]
52 pub(crate) fn code(&self) -> StatusCode {
53 self.code
54 }
55}
56
57impl From<anyhow::Error> for WebhookError {
58 fn from(value: anyhow::Error) -> Self {
59 WebhookError {
60 err: value,
61 code: StatusCode::INTERNAL_SERVER_ERROR,
62 }
63 }
64}
65
66impl std::fmt::Display for WebhookError {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 write!(f, "{}", self.err.as_report())
69 }
70}
71
72impl std::error::Error for WebhookError {}
73
74impl IntoResponse for WebhookError {
75 fn into_response(self) -> axum::response::Response {
76 if let Ok(suppressed_count) = WEBHOOK_ERROR_LOG_SUPPRESSOR.check() {
77 tracing::error!(
78 error = %self.err.as_report(),
79 status = self.code.as_u16(),
80 suppressed_count,
81 "webhook request failed",
82 );
83 }
84
85 let mut resp = Json(json!({
86 "error": format!("{}", self.err.as_report()),
87 }))
88 .into_response();
89 *resp.status_mut() = self.code;
90 resp
91 }
92}
93
94pub(crate) fn header_map_to_json(headers: &HeaderMap) -> JsonbVal {
95 let mut header_map = HashMap::new();
96
97 for (key, value) in headers {
98 let key = key.as_str().to_owned();
99 let value = value.to_str().unwrap_or("").to_owned();
100 header_map.insert(key, value);
101 }
102
103 let json_value = json!(header_map);
104 JsonbVal::from(json_value)
105}
106
107pub(crate) async fn authenticate_webhook_payload(
108 headers_jsonb: JsonbVal,
109 payload: &[u8],
110 webhook_source_info: &risingwave_pb::catalog::WebhookSourceInfo,
111) -> Result<()> {
112 let is_valid = if let Some(signature_expr) = webhook_source_info.signature_expr.clone() {
113 let secret = if let Some(secret_ref) = webhook_source_info.secret_ref {
114 LocalSecretManager::global()
115 .fill_secret(secret_ref)
116 .map_err(|e| err(e, StatusCode::NOT_FOUND))?
117 } else {
118 String::new()
119 };
120 verify_signature(headers_jsonb, secret.as_str(), payload, signature_expr).await?
121 } else {
122 true
123 };
124
125 if !is_valid {
126 return Err(err(
127 anyhow!("Signature verification failed"),
128 StatusCode::UNAUTHORIZED,
129 ));
130 }
131
132 Ok(())
133}
134
135pub(crate) async fn verify_signature(
136 headers_jsonb: JsonbVal,
137 secret: &str,
138 payload: &[u8],
139 signature_expr: ExprNode,
140) -> Result<bool> {
141 let row = OwnedRow::new(vec![
142 Some(headers_jsonb.into()),
143 Some(secret.into()),
144 Some(payload.into()),
145 ]);
146
147 let signature_expr_impl = ExprImpl::from_expr_proto(&signature_expr)
148 .map_err(|e| err(e, StatusCode::INTERNAL_SERVER_ERROR))?;
149
150 let result = signature_expr_impl
151 .eval_row(&row)
152 .await
153 .map_err(|e| err(e, StatusCode::INTERNAL_SERVER_ERROR))?
154 .ok_or_else(|| {
155 err(
156 anyhow!("`SECURE_COMPARE()` failed"),
157 StatusCode::BAD_REQUEST,
158 )
159 })?;
160 Ok(*result.as_bool())
161}
162
163#[cfg(test)]
164mod tests {
165 use axum::body::to_bytes;
166 use axum::http::header::HeaderName;
167
168 use super::*;
169
170 #[tokio::test]
171 async fn test_webhook_error_response() {
172 let response = err(
173 anyhow!("failed to decode webhook payload"),
174 StatusCode::UNPROCESSABLE_ENTITY,
175 )
176 .into_response();
177
178 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
179 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
180 assert_eq!(
181 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
182 json!({"error": "failed to decode webhook payload"}),
183 );
184 }
185
186 #[test]
187 fn test_header_map_to_json_preserves_header_names() {
188 let mut headers = HeaderMap::new();
189 headers.insert(
190 HeaderName::from_static("x-custom-token"),
191 "abc".parse().unwrap(),
192 );
193
194 let headers_json = header_map_to_json(&headers);
195 let json_value: serde_json::Value =
196 serde_json::from_str(&headers_json.to_string()).unwrap();
197
198 assert_eq!(json_value["x-custom-token"], "abc");
199 }
200}