feat(licensing): enforce offline license seat cap (#9845)

* [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 <noreply@anthropic.com>

* [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 <noreply@anthropic.com>

* [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 <noreply@anthropic.com>

* [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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2026-06-30 23:52:12 +02:00
committed by GitHub
parent 6b79bddd42
commit 83f3d7f910
6 changed files with 47 additions and 29 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
1a98119b0b8b8548601983c9e5ab091150f0b180
f814c3f75308c1ef1e4526d8d0eeb360ce16abe4
+9
View File
@@ -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,
+9
View File
@@ -20,6 +20,7 @@ lazy_static::lazy_static! {
pub static ref LICENSE_KEY: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap<Option<OfflineMetadata>> = 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<Option<OfflineCapStatus>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap<Option<chrono::DateTime<chrono::Utc>>> = 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<Option<String>> {
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result<Option<String>> {
// Implementation is not open source