From 83f3d7f910b331c09f60cc9ff556728afa3dec07 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 30 Jun 2026 23:52:12 +0200 Subject: [PATCH] feat(licensing): enforce offline license seat cap (#9845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] feat(licensing): enforce offline license seat cap Companion to windmill-ee-private. Aligns the offline-license seat count with the billing model and adds real-time enforcement when usage exceeds the cap. OSS side carries the ee_oss stubs, the reactivation cap-check call site, the regenerated SQLx cache, and the EE ref bump. - Exclude instance-disabled users (password.disabled) and service accounts from the seat count. Deactivating a user now frees a seat. - Service accounts no longer consume seats (no check at creation). - Hard-block reactivation when it would exceed the cap. - Invalidate the license (halting jobs) when seat usage exceeds the cap, mirroring CU-cap enforcement; recovers when usage drops back under or a higher-cap key is loaded. Co-Authored-By: Claude Opus 4.8 * [ee] fix(licensing): bump EE ref for reactivation seat-check fixes Points to the EE companion commit that fixes reactivation double-counting and preserves the original seat alert tag. Co-Authored-By: Claude Opus 4.8 * [ee] fix(licensing): reactivation seat delta includes pending invites Bumps the EE ref and drops the now-orphaned usr-only cache entry; the reactivation check reuses the existing usr ∪ workspace_invite query. Co-Authored-By: Claude Opus 4.8 * [ee] test(licensing): bump EE ref for offline seat-cap tests Adds #[sqlx::test] coverage for the offline seat counting and cap-check logic; EE-only (runtime queries, no cache change). Co-Authored-By: Claude Opus 4.8 * chore: update ee-repo-ref to f814c3f75308c1ef1e4526d8d0eeb360ce16abe4 This commit updates the EE repository reference after PR #637 was merged in windmill-ee-private. Previous ee-repo-ref: b2622e3afc2fe1fe3e2ec978ca46cf9decf91b82 New ee-repo-ref: f814c3f75308c1ef1e4526d8d0eeb360ce16abe4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...14d66b1678bfe3028df89dff4e343e2f4ab44.json | 26 +++++++++++++++++++ ...f063c36ac17e9d331cd9980b91b28d713c72.json} | 4 +-- ...7ad51a4cd5cbdc2fa34038530070d6a579455.json | 26 ------------------- backend/ee-repo-ref.txt | 2 +- backend/windmill-api-users/src/users.rs | 9 +++++++ backend/windmill-common/src/ee_oss.rs | 9 +++++++ 6 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json rename backend/.sqlx/{query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json => query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json} (57%) delete mode 100644 backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json diff --git a/backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json b/backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json new file mode 100644 index 0000000000..5f114c0156 --- /dev/null +++ b/backend/.sqlx/query-4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH potential AS (\n SELECT email, operator FROM usr WHERE is_service_account IS false\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user\n WHERE email NOT IN (SELECT email FROM password WHERE disabled IS true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "authors!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "operators!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "4865e22673a7886ff84cfe5ee1114d66b1678bfe3028df89dff4e343e2f4ab44" +} diff --git a/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json b/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json similarity index 57% rename from backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json rename to backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json index fce125c6d5..35b70a239c 100644 --- a/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json +++ b/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t", + "query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1 AND is_service_account IS false\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9" + "hash": "5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72" } diff --git a/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json b/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json deleted file mode 100644 index f38c023cb3..0000000000 --- a/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "authors!", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "operators!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null - ] - }, - "hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455" -} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4f05fcbda6..4c31aedd8a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1a98119b0b8b8548601983c9e5ab091150f0b180 +f814c3f75308c1ef1e4526d8d0eeb360ce16abe4 diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 2963b91a50..a0dca7576a 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1552,6 +1552,15 @@ async fn update_user( } if let Some(d) = eu.disabled { + #[cfg(feature = "enterprise")] + if !d { + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_reactivation(&db, &email_to_update) + .await? + { + return Err(Error::BadRequest(msg)); + } + } sqlx::query_scalar!( "UPDATE password SET disabled = $1 WHERE email = $2", d, diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index e68bc00c6e..26b9c87b7e 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -20,6 +20,7 @@ lazy_static::lazy_static! { pub static ref LICENSE_KEY: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false); + pub static ref LICENSE_OFFLINE_OVER_SEAT_CAP: AtomicBool = AtomicBool::new(false); pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap>> = arc_swap::ArcSwap::from_pointee(None); } @@ -62,6 +63,14 @@ pub async fn check_seat_cap_for_new_user( Ok(None) } +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn check_seat_cap_for_reactivation( + _db: &DB, + _email: &str, +) -> anyhow::Result> { + Ok(None) +} + #[cfg(all(feature = "enterprise", not(feature = "private")))] pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result> { // Implementation is not open source