use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use crate::webhook::{verify_signature, SignatureFormat};
use super::org::{BackendKind, StorageMode};
use super::preflight::Preflight;
use super::provision::{ProvisionRequest, TenantProvisioner};
use super::provision_store::{self as store, RunState};
#[derive(Debug, Clone)]
pub enum UrlPolicy {
Template(String),
CallerSupplied,
SchemaMode,
}
impl Default for UrlPolicy {
fn default() -> Self {
Self::Template(String::new())
}
}
#[derive(Clone)]
pub struct WebhookSecret(Arc<Vec<u8>>);
impl WebhookSecret {
pub const MIN_LEN: usize = 32;
#[must_use]
pub fn new(bytes: impl Into<Vec<u8>>) -> Option<Self> {
let bytes = bytes.into();
(bytes.len() >= Self::MIN_LEN).then(|| Self(Arc::new(bytes)))
}
}
impl AsRef<[u8]> for WebhookSecret {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
#[derive(Clone)]
pub struct WebhookConfig {
pub secret: WebhookSecret,
pub format: SignatureFormat,
pub signature_header: String,
pub url_policy: UrlPolicy,
pub timestamp_tolerance: Duration,
pub storage_mode: StorageMode,
pub backend: BackendKind,
pub stale_run_after: Duration,
}
impl WebhookConfig {
#[must_use]
pub fn new(secret: impl Into<Vec<u8>>, database_url_template: impl Into<String>) -> Self {
let secret = WebhookSecret::new(secret).unwrap_or_else(|| {
panic!(
"WebhookConfig::new: the secret must be at least {} bytes",
WebhookSecret::MIN_LEN
)
});
Self {
secret,
format: SignatureFormat::HexSha256WithPrefix,
signature_header: "x-signature-256".to_owned(),
url_policy: UrlPolicy::Template(database_url_template.into()),
timestamp_tolerance: Duration::from_secs(300),
storage_mode: StorageMode::Database,
backend: BackendKind::default(),
stale_run_after: store::STALE_RUN_AFTER,
}
}
}
#[derive(Clone)]
pub struct WebhookState {
pub config: WebhookConfig,
pub provisioner: Arc<dyn TenantProvisioner>,
}
#[derive(Debug, serde::Deserialize)]
pub struct ProvisionPayload {
pub event_id: String,
pub timestamp: i64,
pub slug: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub host_pattern: Option<String>,
#[serde(default)]
pub database_url: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct Accepted {
pub run_id: i64,
pub slug: String,
pub state: String,
pub duplicate: bool,
}
#[derive(Debug)]
enum Refusal {
BadSignature,
Stale,
Malformed(String),
Policy(String),
Internal(String),
}
impl IntoResponse for Refusal {
fn into_response(self) -> Response {
let (code, message) = match self {
Self::BadSignature => (StatusCode::UNAUTHORIZED, "invalid signature".to_owned()),
Self::Stale => (
StatusCode::UNAUTHORIZED,
"payload timestamp is outside the accepted window".to_owned(),
),
Self::Malformed(m) => (StatusCode::BAD_REQUEST, m),
Self::Policy(m) => (StatusCode::FORBIDDEN, m),
Self::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
};
crate::api_errors::ApiError::logged(code, "provision_webhook", message).into_response()
}
}
pub async fn provision_webhook(
State(state): State<WebhookState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
match handle(&state, &headers, &body).await {
Ok(accepted) => {
let code = if accepted.duplicate {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
(code, Json(accepted)).into_response()
}
Err(refusal) => refusal.into_response(),
}
}
async fn handle(
state: &WebhookState,
headers: &HeaderMap,
body: &Bytes,
) -> Result<Accepted, Refusal> {
let Some(signature) = headers
.get(state.config.signature_header.as_str())
.and_then(|v| v.to_str().ok())
else {
return Err(Refusal::BadSignature);
};
if !verify_signature(
state.config.format,
state.config.secret.as_ref(),
body,
signature,
) {
return Err(Refusal::BadSignature);
}
let payload: ProvisionPayload = serde_json::from_slice(body)
.map_err(|e| Refusal::Malformed(format!("payload is not the expected JSON: {e}")))?;
let age = chrono::Utc::now().timestamp() - payload.timestamp;
let tolerance = i64::try_from(state.config.timestamp_tolerance.as_secs()).unwrap_or(i64::MAX);
if age.abs() > tolerance {
return Err(Refusal::Stale);
}
let registry = state.provisioner.registry();
if let Some(existing) = store::run_by_idempotency_key(®istry, &payload.event_id)
.await
.map_err(|e| Refusal::Internal(e.to_string()))?
{
let retry = store::release_for_retry(®istry, &existing, state.config.stale_run_after)
.await
.map_err(|e| Refusal::Internal(e.to_string()))?;
if !retry {
return Ok(Accepted {
run_id: existing.id.get().copied().unwrap_or_default(),
slug: existing.slug,
state: existing.state,
duplicate: true,
});
}
}
let request = build_request(&state.config, &payload)?;
let run = match store::open_run(
®istry,
&request.slug,
request.mode.as_str(),
request.backend.as_str(),
request.database_url.as_deref(),
Some("webhook"),
Some(&payload.event_id),
)
.await
{
Ok(run) => run,
Err(e) => {
let existing = store::run_by_idempotency_key(®istry, &payload.event_id)
.await
.ok()
.flatten();
return match existing {
Some(existing) => Ok(Accepted {
run_id: existing.id.get().copied().unwrap_or_default(),
slug: existing.slug,
state: existing.state,
duplicate: true,
}),
None => Err(Refusal::Internal(format!(
"could not open a provisioning run: {e}"
))),
};
}
};
let run_id = run.id.get().copied().unwrap_or_default();
let provisioner = Arc::clone(&state.provisioner);
let slug = request.slug.clone();
tokio::spawn(async move {
if let Err(e) = provisioner.provision_in_run(run_id, &request).await {
tracing::warn!(
target: "rustango::tenancy::provision_webhook",
slug = %request.slug,
run_id,
error = %e,
"webhook-initiated provisioning failed"
);
}
});
Ok(Accepted {
run_id,
slug,
state: RunState::Running.as_str().to_owned(),
duplicate: false,
})
}
fn build_request(
config: &WebhookConfig,
payload: &ProvisionPayload,
) -> Result<ProvisionRequest, Refusal> {
if payload.slug.trim().is_empty() {
return Err(Refusal::Malformed("`slug` must not be empty".to_owned()));
}
if !payload
.slug
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(Refusal::Malformed(
"`slug` may contain only lowercase letters, digits and hyphens".to_owned(),
));
}
let database_url = match (&config.url_policy, &payload.database_url) {
(UrlPolicy::Template(_) | UrlPolicy::SchemaMode, Some(_)) => {
return Err(Refusal::Policy(
"this endpoint derives tenant database URLs itself; \
remove `database_url` from the payload"
.to_owned(),
));
}
(UrlPolicy::Template(template), None) => {
if template.is_empty() {
return Err(Refusal::Policy(
"no tenant database URL template is configured on this endpoint".to_owned(),
));
}
Some(template.replace("{slug}", &payload.slug))
}
(UrlPolicy::SchemaMode, None) => None,
(UrlPolicy::CallerSupplied, url) => {
let Some(url) = url.as_ref().filter(|u| !u.trim().is_empty()) else {
return Err(Refusal::Malformed(
"`database_url` is required by this endpoint's policy".to_owned(),
));
};
Some(url.clone())
}
};
Ok(ProvisionRequest {
slug: payload.slug.clone(),
mode: config.storage_mode,
backend: config.backend,
display_name: payload.display_name.clone(),
database_url,
schema_name: None,
host_pattern: payload.host_pattern.clone(),
port: None,
path_prefix: None,
run_migrations: true,
preflight: Preflight::default(),
})
}
pub fn router(path: &str, state: WebhookState) -> axum::Router {
axum::Router::new()
.route(path, axum::routing::post(provision_webhook))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(slug: &str, url: Option<&str>) -> ProvisionPayload {
ProvisionPayload {
event_id: "evt_1".to_owned(),
timestamp: chrono::Utc::now().timestamp(),
slug: slug.to_owned(),
display_name: None,
host_pattern: None,
database_url: url.map(ToOwned::to_owned),
}
}
fn config(policy: UrlPolicy) -> WebhookConfig {
WebhookConfig {
url_policy: policy,
..WebhookConfig::new([7u8; WebhookSecret::MIN_LEN].to_vec(), "")
}
}
#[test]
#[should_panic(expected = "at least 32 bytes")]
fn an_empty_secret_is_refused() {
let _ = WebhookConfig::new(Vec::new(), "");
}
#[test]
fn a_short_secret_is_refused() {
assert!(WebhookSecret::new(vec![1; WebhookSecret::MIN_LEN - 1]).is_none());
assert!(WebhookSecret::new(vec![1; WebhookSecret::MIN_LEN]).is_some());
}
#[test]
fn the_template_policy_substitutes_the_slug_and_nothing_else() {
let c = config(UrlPolicy::Template(
"postgres://app:pw@db.internal:5432/tenant_{slug}".to_owned(),
));
let r = build_request(&c, &payload("acme", None)).expect("valid");
assert_eq!(
r.database_url.as_deref(),
Some("postgres://app:pw@db.internal:5432/tenant_acme")
);
}
#[test]
fn a_caller_supplied_url_is_refused_under_the_template_policy() {
let c = config(UrlPolicy::Template("postgres://h/{slug}".to_owned()));
let err = build_request(&c, &payload("acme", Some("postgres://attacker/x")))
.expect_err("must be refused");
assert!(
matches!(err, Refusal::Policy(_)),
"should be a policy refusal, got {err:?}"
);
}
#[test]
fn the_caller_supplied_policy_accepts_one_when_deliberately_enabled() {
let c = config(UrlPolicy::CallerSupplied);
let r = build_request(&c, &payload("acme", Some("postgres://mine/x"))).expect("allowed");
assert_eq!(r.database_url.as_deref(), Some("postgres://mine/x"));
}
#[test]
fn the_caller_supplied_policy_still_needs_a_url() {
let c = config(UrlPolicy::CallerSupplied);
assert!(matches!(
build_request(&c, &payload("acme", None)),
Err(Refusal::Malformed(_))
));
}
#[test]
fn an_empty_template_is_a_configuration_refusal() {
let c = config(UrlPolicy::Template(String::new()));
assert!(matches!(
build_request(&c, &payload("acme", None)),
Err(Refusal::Policy(_))
));
}
#[test]
fn a_slug_is_restricted_to_what_is_safe_everywhere_it_is_used() {
let c = config(UrlPolicy::Template("postgres://h/{slug}".to_owned()));
for bad in [
"Acme", "ac me", "acme;DROP", "acme/../etc", "acme'", "", ] {
assert!(
build_request(&c, &payload(bad, None)).is_err(),
"slug `{bad}` should have been refused"
);
}
assert!(build_request(&c, &payload("acme-2", None)).is_ok());
}
#[test]
fn schema_mode_takes_no_url_at_all() {
let c = config(UrlPolicy::SchemaMode);
let r = build_request(&c, &payload("acme", None)).expect("valid");
assert_eq!(r.database_url, None);
}
}