diff --git a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json b/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json new file mode 100644 index 0000000000..da0ce60709 --- /dev/null +++ b/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91" +} diff --git a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json b/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json new file mode 100644 index 0000000000..73ffec7e48 --- /dev/null +++ b/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a" +} diff --git a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json b/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json new file mode 100644 index 0000000000..14757923d1 --- /dev/null +++ b/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7\n WHERE $7::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $7 AND deleted = true\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0" +} diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index ba0bf928e7..efab57cf91 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -534,10 +534,13 @@ pub async fn create_token_internal( )); } } - sqlx::query!( + let rows = sqlx::query!( "INSERT INTO token (token, email, label, expiration, super_admin, scopes, workspace_id) - VALUES ($1, $2, $3, $4, $5, $6, $7)", + SELECT $1, $2, $3, $4, $5, $6, $7 + WHERE $7::varchar IS NULL OR NOT EXISTS( + SELECT 1 FROM workspace WHERE id = $7 AND deleted = true + )", token, authed.email, token_config.label, @@ -548,6 +551,11 @@ pub async fn create_token_internal( ) .execute(&mut *tx) .await?; + if rows.rows_affected() == 0 { + return Err(Error::BadRequest( + "Cannot create a token for an archived workspace".to_string(), + )); + } audit_log( &mut *tx, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 9146ce2274..253b543b97 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3558,7 +3558,7 @@ pub(crate) async fn archive_workspace_impl( db: &DB, w_id: &str, username: &str, -) -> Result<(usize, usize)> { +) -> Result<(usize, usize, usize)> { // Step 1: Disable all schedules and clear their queued jobs let mut tx = db.begin().await?; let disabled_schedules = sqlx::query_scalar!( @@ -3580,6 +3580,20 @@ pub(crate) async fn archive_workspace_impl( windmill_queue::schedule::clear_schedule(&mut tx, schedule_path, w_id).await?; } + // Delete non-session tokens scoped to this workspace + let deleted_tokens = sqlx::query_scalar!( + "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + w_id + ) + .fetch_all(&mut *tx) + .await?; + + tracing::info!( + "Deleted {} non-session tokens in workspace {}", + deleted_tokens.len(), + w_id + ); + // Mark workspace as archived sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", w_id) .execute(&mut *tx) @@ -3618,7 +3632,7 @@ pub(crate) async fn archive_workspace_impl( 0 }; - Ok((schedules_count, canceled_count)) + Ok((schedules_count, canceled_count, deleted_tokens.len())) } async fn archive_workspace( @@ -3628,7 +3642,7 @@ async fn archive_workspace( ) -> Result { require_admin(authed.is_admin, &authed.username)?; - let (schedules_count, canceled_count) = + let (schedules_count, canceled_count, deleted_tokens_count) = archive_workspace_impl(&db, &w_id, &authed.username).await?; // Audit log @@ -3636,6 +3650,7 @@ async fn archive_workspace( let mut audit_params = HashMap::new(); audit_params.insert("disabled_schedules", schedules_count.to_string()); audit_params.insert("canceled_jobs", canceled_count.to_string()); + audit_params.insert("deleted_tokens", deleted_tokens_count.to_string()); let audit_params_refs: HashMap<&str, &str> = audit_params.iter().map(|(k, v)| (*k, v.as_str())).collect(); @@ -3652,8 +3667,8 @@ async fn archive_workspace( tx.commit().await?; Ok(format!( - "Archived workspace {}, disabled {} schedules and canceled {} jobs", - &w_id, schedules_count, canceled_count + "Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens", + &w_id, schedules_count, canceled_count, deleted_tokens_count )) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index b6b1cceae9..12c4fec971 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -641,7 +641,7 @@ pub(crate) async fn change_workspace_id( // Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true // Note: schedules were already moved to new workspace, so this will find 0 schedules info!("Archiving old workspace"); - let (_schedules_count, canceled_count) = + let (_schedules_count, canceled_count, _deleted_tokens_count) = archive_workspace_impl(&db, &old_id, &authed.username).await?; info!( diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 7f959779b5..9a81e2efd8 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -387,10 +387,11 @@ async fn handle_authorization_code_grant( let token_family = sqlx::types::Uuid::new_v4(); let scopes = auth_code.scopes; - // Create access token - if let Err(e) = sqlx::query!( + // Create access token (rejects archived workspaces inline) + let rows = sqlx::query!( "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)", + SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", access_token, auth_code.user_email, format!("mcp-oauth-{}", auth_code.client_id), @@ -400,10 +401,13 @@ async fn handle_authorization_code_grant( ) .execute(db) .await - { + .map_err(|e| { tracing::error!("Failed to create access token: {}", e); - return Err(OAuthTokenError::server_error( - "Failed to create access token", + OAuthTokenError::server_error("Failed to create access token") + })?; + if rows.rows_affected() == 0 { + return Err(OAuthTokenError::invalid_grant( + "Cannot create a token for an archived workspace", )); } @@ -514,10 +518,11 @@ async fn handle_refresh_token_grant( let new_refresh_token = rd_string(32); let scopes = token_row.scopes; - // Create new access token - if let Err(e) = sqlx::query!( + // Create new access token (rejects archived workspaces inline) + let rows = sqlx::query!( "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)", + SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", new_access_token, token_row.user_email, format!("mcp-oauth-{}", token_row.client_id), @@ -527,10 +532,13 @@ async fn handle_refresh_token_grant( ) .execute(db) .await - { + .map_err(|e| { tracing::error!("Failed to create new access token: {}", e); - return Err(OAuthTokenError::server_error( - "Failed to create access token", + OAuthTokenError::server_error("Failed to create access token") + })?; + if rows.rows_affected() == 0 { + return Err(OAuthTokenError::invalid_grant( + "Cannot create a token for an archived workspace", )); }