mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
d1260d7c5a
commit
05bcc361af
+2
-2
@@ -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"
|
||||
}
|
||||
+22
@@ -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 @@
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user