//! `max_token_expiration_days`: the instance-wide ceiling on how far ahead a token a caller //! picks the lifetime of may expire, and the service-account exemption. use serde_json::json; use sqlx::types::chrono::{DateTime, Utc}; use sqlx::{Pool, Postgres}; use windmill_test_utils::*; const DAY: u64 = 24 * 60 * 60; fn client() -> reqwest::Client { reqwest::Client::new() } fn from_now(secs: u64) -> DateTime { Utc::now() + std::time::Duration::from_secs(secs) } async fn set_max(db: &Pool, value: serde_json::Value) { sqlx::query( "INSERT INTO global_settings (name, value) VALUES ('max_token_expiration_days', $1) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", ) .bind(value) .execute(db) .await .unwrap(); } /// Mints as `test2@windmill.dev`, a plain member of `test-workspace`. async fn create_token(port: u16, body: serde_json::Value) -> reqwest::Response { client() .post(format!("http://localhost:{port}/api/users/tokens/create")) .header("Authorization", "Bearer SECRET_TOKEN_2") .json(&body) .send() .await .unwrap() } async fn stored_expiration(db: &Pool, label: &str) -> Option> { sqlx::query_scalar::<_, Option>>("SELECT expiration FROM token WHERE label = $1") .bind(label) .fetch_one(db) .await .unwrap() } #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_max_token_expiration_days_shortens_user_tokens( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let resp = create_token(port, json!({ "label": "unset" })).await; assert_eq!(resp.status(), 201); assert_eq!( stored_expiration(&db, "unset").await, None, "with no setting a token may still have no expiration" ); // Refused at write time: the token routes can only read a value they cannot parse as no // ceiling at all. let resp = client() .post(format!( "http://localhost:{port}/api/settings/global/max_token_expiration_days" )) .header("Authorization", "Bearer SECRET_TOKEN") .json(&json!({ "value": 7.5 })) .send() .await?; assert_eq!(resp.status(), 400); set_max(&db, json!(7)).await; // The token form reads the ceiling as whoever is creating the token, usually not a // superadmin, so it can offer only expirations the server would keep. let resp = client() .get(format!( "http://localhost:{port}/api/settings/global/max_token_expiration_days" )) .header("Authorization", "Bearer SECRET_TOKEN_2") .send() .await?; assert_eq!(resp.status(), 200); assert_eq!(resp.text().await?, "7"); let resp = create_token(port, json!({ "label": "none asked" })).await; assert_eq!(resp.status(), 201); let expiration = stored_expiration(&db, "none asked") .await .expect("a token asking for no expiration gets the ceiling"); assert!( expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY), "expected the 7 day ceiling, got {expiration}" ); let resp = create_token( port, json!({ "label": "past the ceiling", "expiration": from_now(30 * DAY) }), ) .await; assert_eq!(resp.status(), 201); let expiration = stored_expiration(&db, "past the ceiling").await.unwrap(); assert!( expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY), "expected an expiration past the ceiling to be shortened to it, got {expiration}" ); let resp = create_token( port, json!({ "label": "within", "expiration": from_now(3 * DAY) }), ) .await; assert_eq!(resp.status(), 201); let expiration = stored_expiration(&db, "within").await.unwrap(); assert!( expiration <= from_now(3 * DAY), "an expiration within the ceiling must be kept, got {expiration}" ); // The settings UI stores an integer, but the YAML instance config and config sync can write // the same whole number as a string or as `5.0`. Reading either as "unset" would silently // drop the ceiling, while the token form (`parseMaxTokenExpirationDays`) still showed it. for (stored, label) in [ (json!("5"), "string setting"), (json!(5.0), "float setting"), ] { set_max(&db, stored).await; let resp = create_token(port, json!({ "label": label })).await; assert_eq!(resp.status(), 201); let expiration = stored_expiration(&db, label).await; assert!( expiration.is_some_and(|e| e > from_now(4 * DAY) && e <= from_now(5 * DAY)), "{label}: expected the 5 day ceiling, got {expiration:?}" ); } // A superadmin impersonating a user picks the lifetime too, so the ceiling applies there; // left out, it would be the one way to mint a token that never expires. let resp = client() .post(format!( "http://localhost:{port}/api/users/tokens/impersonate" )) .header("Authorization", "Bearer SECRET_TOKEN") .json(&json!({ "label": "impersonated", "impersonate_email": "test3@windmill.dev" })) .send() .await?; assert_eq!(resp.status(), 201); assert!( stored_expiration(&db, "impersonated").await.is_some(), "an impersonation token asking for no expiration gets the ceiling" ); Ok(()) } #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_service_accounts_are_exempt(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); set_max(&db, json!(7)).await; // The same email is a service account in one workspace and an ordinary user in another. sqlx::query( "UPDATE usr SET is_service_account = true WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace'", ) .execute(&db) .await?; sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')") .execute(&db) .await?; sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other')") .execute(&db) .await?; sqlx::query( "INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES ('other', 'test2@windmill.dev', 'test-user-2', false, 'User')", ) .execute(&db) .await?; // The token form decides the exemption from this list, so it must carry each membership's flag. let memberships: serde_json::Value = client() .get(format!("http://localhost:{port}/api/workspaces/users")) .header("Authorization", "Bearer SECRET_TOKEN_2") .send() .await? .json() .await?; for (workspace, is_service_account) in [("test-workspace", true), ("other", false)] { let membership = memberships["workspaces"] .as_array() .and_then(|ws| ws.iter().find(|w| w["id"] == workspace)) .unwrap_or_else(|| panic!("{workspace} missing from {memberships}")); assert_eq!(membership["is_service_account"], json!(is_service_account)); } for (label, workspace_id, exempt) in [ ("own workspace", Some("test-workspace"), true), ("other workspace", Some("other"), false), // A workspace-less token has no workspace to match, so a service account anywhere counts. ("global", None, true), ] { let resp = create_token( port, json!({ "label": label, "workspace_id": workspace_id }), ) .await; assert_eq!(resp.status(), 201); assert_eq!( stored_expiration(&db, label).await.is_none(), exempt, "{label}: expected exempt = {exempt}" ); } // Impersonation checks the impersonated account, not the superadmin minting the token. let resp = client() .post(format!( "http://localhost:{port}/api/users/tokens/impersonate" )) .header("Authorization", "Bearer SECRET_TOKEN") .json(&json!({ "label": "impersonated service account", "impersonate_email": "test2@windmill.dev" })) .send() .await?; assert_eq!(resp.status(), 201); assert_eq!( stored_expiration(&db, "impersonated service account").await, None ); Ok(()) }