refactor: rotate webhook tokens instead of recovering plaintext from DB

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
HugoCasa
2026-03-13 15:39:35 +01:00
co-authored by Claude Opus 4.6
parent 943650692a
commit b607c5cb41
9 changed files with 167 additions and 73 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE token_prefix = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "0a1a962b4e6172f580904df260e24f96b8c4a5b397ba40792d4f3c05ee7629f4"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id)\n VALUES ($1, $2, NULL, $3, $4, $5, $6, $7)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "509588b81366668241ec5471f8765850d30408d06f6ff77184442df3ede93416"
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, email, scopes, workspace_id, super_admin FROM token WHERE token_prefix = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "super_admin",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true,
true,
true,
false
]
},
"hash": "999a3e795fbf7566b360a45d6f395685b4b7dd6b7e8d610a13fac49368e26846"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token FROM token WHERE token_prefix = $1 AND token IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "abb79d8397504f48b6c99fed21c3dca862926695cf116843540a56f56a5022cc"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE native_trigger\n SET service_config = $1, updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ",
"query": "\n UPDATE native_trigger\n SET service_config = $1,\n webhook_token_prefix = COALESCE($5, webhook_token_prefix),\n updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ",
"describe": {
"columns": [],
"parameters": {
@@ -18,10 +18,11 @@
}
}
},
"Text",
"Text"
]
},
"nullable": []
},
"hash": "2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1"
"hash": "fdd84901d5a32fb5b0a3db7c0438cb7f1c644519db666e9893cc0923ad065de3"
}
@@ -11,7 +11,7 @@ use windmill_common::{
use windmill_queue::PushArgsOwned;
use crate::{
generate_webhook_service_url, get_token_by_prefix,
generate_webhook_service_url, rotate_webhook_token,
sync::{SyncAction, SyncError, TriggerSyncInfo},
update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger,
NativeTriggerData, ServiceName,
@@ -309,14 +309,17 @@ impl Google {
}
/// Renew an expiring Google watch channel.
/// Stops the old channel and creates a new one with the same channel ID.
/// Returns the updated service_config with new expiration.
/// Rotates the webhook token (creating a new one with the same label),
/// stops the old channel and creates a new one with the same channel ID.
/// Returns (new_service_config, new_token_prefix).
pub async fn renew_channel(
&self,
w_id: &str,
trigger: &NativeTrigger,
db: &DB,
) -> Result<serde_json::Value> {
) -> Result<(serde_json::Value, String)> {
use windmill_common::auth::TOKEN_PREFIX_LEN;
let config: GoogleServiceConfig = trigger
.service_config
.as_ref()
@@ -324,9 +327,8 @@ impl Google {
.transpose()?
.ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?;
let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix)
.await?
.ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?;
let webhook_token = rotate_webhook_token(db, &trigger.webhook_token_prefix).await?;
let new_prefix = webhook_token[..TOKEN_PREFIX_LEN].to_string();
let base_url = &*BASE_URL.read().await;
// Reuse the same channel ID so external_id stays permanent
@@ -403,8 +405,9 @@ impl Google {
new_config.google_resource_id = Some(resp.resource_id);
new_config.expiration = Some(resp.expiration);
serde_json::to_value(&new_config)
.map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))
let config_value = serde_json::to_value(&new_config)
.map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))?;
Ok((config_value, new_prefix))
}
}
@@ -461,13 +464,14 @@ async fn renew_expiring_channels(
);
match handler.renew_channel(workspace_id, trigger, db).await {
Ok(new_config) => {
Ok((new_config, new_prefix)) => {
match update_native_trigger_service_config(
db,
workspace_id,
ServiceName::Google,
&trigger.external_id,
&new_config,
Some(&new_prefix),
)
.await
{
+20 -27
View File
@@ -1,6 +1,6 @@
use crate::{
decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger,
get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error,
list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error,
External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName,
};
use axum::{
@@ -234,32 +234,25 @@ async fn update_native_trigger_handler<T: External>(
let runnable_changed =
existing.script_path != data.script_path || existing.is_flow != data.is_flow;
let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
Some(token) if !runnable_changed => token,
existing_token => {
if let Some(_) = existing_token {
delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?;
} else {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
}
let token = new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?;
tx.commit().await?;
tx = user_db.begin(&authed).await?;
token
}
let webhook_token = if runnable_changed {
// Scopes change when the runnable changes — create a fresh token
delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?;
let token = new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?;
tx.commit().await?;
tx = user_db.begin(&authed).await?;
token
} else {
// Same runnable — rotate the token keeping the same label
rotate_webhook_token(&db, &existing.webhook_token_prefix).await?
};
let service_config = handler
+49 -12
View File
@@ -719,20 +719,53 @@ async fn update_oauth_token_resource(
}
}
/// Look up the full plaintext token from the token table using its prefix.
/// Only webhook tokens store the plaintext (needed for URL construction).
/// Returns None for tokens that only have a hash stored.
pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>(
db: E,
token_prefix: &str,
) -> Result<Option<String>> {
let token: Option<Option<String>> = sqlx::query_scalar!(
"SELECT token FROM token WHERE token_prefix = $1 AND token IS NOT NULL",
token_prefix
/// Create a new webhook token that keeps the same label as the old one,
/// delete the old token, and return the new plaintext token.
/// Used when renewing external service webhooks (e.g. Google channel renewal)
/// so we don't need to store plaintext tokens in the DB.
pub async fn rotate_webhook_token(db: &DB, old_token_prefix: &str) -> Result<String> {
use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN};
use windmill_common::utils::rd_string;
let old = sqlx::query!(
"SELECT label, email, scopes, workspace_id, super_admin FROM token WHERE token_prefix = $1",
old_token_prefix
)
.fetch_optional(db)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"Webhook token not found for prefix: {}",
old_token_prefix
))
})?;
let new_token = rd_string(32);
let new_hash = hash_token(&new_token);
let new_prefix = &new_token[..TOKEN_PREFIX_LEN];
sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id)
VALUES ($1, $2, NULL, $3, $4, $5, $6, $7)",
new_hash,
new_prefix,
old.email,
old.label,
old.super_admin,
old.scopes.as_deref(),
old.workspace_id,
)
.execute(db)
.await?;
Ok(token.flatten())
sqlx::query!(
"DELETE FROM token WHERE token_prefix = $1",
old_token_prefix
)
.execute(db)
.await?;
Ok(new_token)
}
/// Delete a token from the token table using its prefix
@@ -1029,11 +1062,14 @@ pub async fn update_native_trigger_service_config<
service_name: ServiceName,
external_id: &str,
service_config: &serde_json::Value,
new_webhook_token_prefix: Option<&str>,
) -> Result<()> {
sqlx::query!(
r#"
UPDATE native_trigger
SET service_config = $1, updated_at = NOW()
SET service_config = $1,
webhook_token_prefix = COALESCE($5, webhook_token_prefix),
updated_at = NOW()
WHERE
workspace_id = $2
AND service_name = $3
@@ -1043,6 +1079,7 @@ pub async fn update_native_trigger_service_config<
workspace_id,
service_name as ServiceName,
external_id,
new_webhook_token_prefix,
)
.execute(db)
.await?;
@@ -390,6 +390,7 @@ pub async fn reconcile_with_external_state(
service_name,
&trigger.external_id,
external_service_config,
None,
)
.await
{