fix: delete non-session tokens on workspace archive and reject token creation for archived workspaces (#8082)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-25 10:04:05 +01:00
committed by GitHub
parent a7d1e34619
commit 812419b2bf
7 changed files with 112 additions and 20 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -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<String> {
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
))
}

View File

@@ -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!(

View File

@@ -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",
));
}