stop billing service accounts twice after a session refresh (#11408)

* fix: stop billing service accounts twice after a session refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: refuse refresh for expired, swept or impersonation tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 5a2b6b8527250bd12cf856d8a667a9ef3106ec60

This commit updates the EE repository reference after PR #835 was merged in windmill-ee-private.

Previous ee-repo-ref: 8cc94ec6aeb603b6f6ebbe1fa95b32fbec074b74

New ee-repo-ref: 5a2b6b8527250bd12cf856d8a667a9ef3106ec60

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
AlexRV12
2026-09-29 19:23:15 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent d1260d7c5a
commit 05bcc361af
6 changed files with 99 additions and 6 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
null
]
},
"hash": "dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c"
"hash": "4afdc63af51e599b9d6fe6cedcd7980fd761a29df0f2a6820f3c8d7a29c3c20a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT NOT starts_with(COALESCE(label, ''), 'impersonation:') FROM token\n WHERE token_hash = $1 AND (expiration IS NULL OR expiration > now())",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "be004608fc1de826803f266dfce96e09e634977478443b6372bca5e3dd0ce5ff"
}
+1 -1
View File
@@ -1 +1 @@
dba91044a174f8c9275fefea2619dc966b0f9250
5a2b6b8527250bd12cf856d8a667a9ef3106ec60
@@ -993,3 +993,56 @@ async fn test_drafts_follow_their_owner_without_a_fkey(db: Pool<Postgres>) -> an
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_impersonation_session_is_never_refreshed(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api/users", server.addr.port());
let get = |token: &'static str, path: &str| {
client()
.get(format!("{base}/{path}"))
.header("Authorization", format!("Bearer {token}"))
.send()
};
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, email, label, expiration)
VALUES (encode(sha256('IMPERSONATION_TOKEN'::bytea), 'hex'), 'IMPERSONAT',
'test2@windmill.dev', 'impersonation:test@windmill.dev', now() + interval '1 day')",
)
.execute(&db)
.await?;
let refreshed = get("IMPERSONATION_TOKEN", "refresh_token")
.await?
.text()
.await?;
assert_eq!(refreshed, "this session cannot be refreshed");
// Once the row is gone, as when the monitor sweeps an expired one, the auth cache still
// accepts the token for a while.
sqlx::query("DELETE FROM token WHERE label LIKE 'impersonation:%'")
.execute(&db)
.await?;
assert_eq!(get("IMPERSONATION_TOKEN", "whoami").await?.status(), 200);
let refreshed = get("IMPERSONATION_TOKEN", "refresh_token")
.await?
.text()
.await?;
assert_eq!(refreshed, "this session cannot be refreshed");
let sessions: i64 = sqlx::query_scalar(
"SELECT count(*) FROM token WHERE email = 'test2@windmill.dev' AND label = 'session'",
)
.fetch_one(&db)
.await?;
assert_eq!(sessions, 0);
let refreshed = get("SECRET_TOKEN_3", "refresh_token").await?.text().await?;
assert_eq!(
refreshed, "token refreshed",
"an ordinary token still refreshes"
);
Ok(())
}
+19 -1
View File
@@ -2866,8 +2866,26 @@ async fn refresh_token(
.to_string(),
));
}
let t_hash = windmill_common::auth::hash_token(&token);
// Only a live, non-impersonation row may be exchanged for a session: an impersonation must
// end at its 24h expiry, not become a renewable service-account session billed as active.
// Read the row rather than trust `authed`, which the auth cache keeps serving for up to
// 120s after the row expired or was swept. `jwt_` tokens have no row.
if !token.starts_with("jwt_") {
let refreshable = sqlx::query_scalar!(
"SELECT NOT starts_with(COALESCE(label, ''), 'impersonation:') FROM token
WHERE token_hash = $1 AND (expiration IS NULL OR expiration > now())",
&t_hash
)
.fetch_optional(&db)
.await?
.flatten()
.unwrap_or(false);
if !refreshable {
return Ok("this session cannot be refreshed".to_string());
}
}
if let Some(thresh_s) = query.if_expiring_in_less_than_s {
let t_hash = windmill_common::auth::hash_token(&token);
let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s)
.fetch_optional(&db)
.await?
+2 -2
View File
@@ -261,13 +261,13 @@
<ToggleButton
value="developer"
label="Developer"
tooltip="Can author and edit scripts/flows/apps within its path. Counts as 1 seat. Use this for CLI sync tokens."
tooltip="Can author and edit scripts/flows/apps within its path. Counts as 0.5 seat. Use this for CLI sync tokens."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="Full workspace admin. Counts as 1 seat. Grant only when the service account needs to manage workspace settings."
tooltip="Full workspace admin. Counts as 0.5 seat. Grant only when the service account needs to manage workspace settings."
{item}
/>
{/snippet}