fix: unify billable seat counting and prevent fork subscriptions (#10818)

* fix: unify billable seat counting and prevent fork subscriptions

* fix: authorize candidate before reading its plan, scope seat breakdown

* chore: pin ee ref for the stripe checkout fork guard

* fix: grant the billable_member view and widen the paid-plan check

* refactor: keep the seat rule in rust instead of a view and function

* docs: correct the attach guard summary after widening the plan check

* revert: keep cloud out of the ci test feature set

* chore: update ee-repo-ref to 9ff97cd818e85940fec282c92161e98c1b8583e2

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

Previous ee-repo-ref: 0ec0b42565a41f271a45bf24a93467d110c36df3

New ee-repo-ref: 9ff97cd818e85940fec282c92161e98c1b8583e2

Automated by sync-ee-ref workflow.

---------

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-08-28 17:13:02 +02:00
committed by GitHub
co-authored by windmill-internal-app[bot] Ruben Fiszel
parent 3ce9bbc716
commit 7dd88c470c
9 changed files with 310 additions and 129 deletions
@@ -117,6 +117,7 @@ pub fn workspaced_service() -> Router {
get(get_secondary_storage_names),
)
.route("/is_premium", get(is_premium))
.route("/billable_seats", get(get_billable_seats))
.route("/edit_error_handler", post(edit_error_handler))
.route("/edit_success_handler", post(edit_success_handler))
.route(
@@ -686,6 +687,48 @@ async fn is_premium(
Ok(Json(premium))
}
#[derive(Serialize)]
struct BillableSeatsResponse {
/// Both omitted when the seats counted are another workspace's: a fork member need not be a
/// member of the billing root, so the root's headcount is not theirs to read. The total is,
/// since it is the divisor of the quota their own executions draw on.
#[serde(skip_serializing_if = "Option::is_none")]
developers: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
operators: Option<i64>,
seats: i64,
}
async fn get_billable_seats(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<BillableSeatsResponse> {
// Readable by any workspace member, like `is_premium`: this is what the sidebar usage meter
// divides by, and that meter is shown to non-admin developers too.
//
// On cloud a fork draws its plan, quota and bill from the root, so the seats its usage is
// measured against are the root's. Resolved here rather than by the caller: a fork member need
// not be a member of that root, and so cannot count its seats from the member list. Off cloud
// a fork is not billed through a root at all, so the workspace answers for itself.
#[cfg(feature = "cloud")]
let billing_w_id = if *CLOUD_HOSTED {
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await?
} else {
w_id.clone()
};
#[cfg(not(feature = "cloud"))]
let billing_w_id = w_id.clone();
let counted = windmill_common::workspaces::billable_seats(&db, &billing_w_id).await?;
let own = billing_w_id == w_id;
Ok(Json(BillableSeatsResponse {
developers: own.then_some(counted.developers),
operators: own.then_some(counted.operators),
seats: counted.seats,
}))
}
async fn exists_workspace(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -7374,6 +7417,76 @@ async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()
enforce_cloud_fork_count(db, &root, 1).await
}
/// Cloud: refuse to attach a workspace that already has a paid plan of its own.
///
/// Once attached it draws the root's plan and meters its usage there, so a subscription of its own
/// bills a second time for one plan. Only an attach can reach this state: a fork is created as a
/// fresh workspace and never had a plan to keep.
///
/// Asked only of a candidate joining this family, never of one already under the same root: that
/// one is already in the double-billed state, where the settings page surfaces the leftover
/// subscription and the portal that cancels it, and refusing there would block re-designating a
/// renamed dev workspace over a billing problem the attach did not cause.
#[cfg(feature = "cloud")]
async fn reject_attach_of_subscribed_workspace(db: &DB, dev_w_id: &str) -> Result<()> {
let plan = sqlx::query_scalar!(
"SELECT plan FROM workspace_settings WHERE workspace_id = $1",
dev_w_id
)
.fetch_optional(db)
.await?
.flatten();
// Any plan, not just `'team'`: the column is written by the subscription webhook, and a plan
// value it does not write yet would otherwise walk straight past this. An enterprise
// arrangement is deliberately not covered — it sets `premium` without a plan and has no
// self-serve portal, so refusing there would be a dead end rather than something to act on.
if plan.is_some() {
return Err(Error::BadRequest(format!(
"Workspace {dev_w_id} is on a paid plan of its own. A dev or fork workspace runs on its parent's plan and is never invoiced separately, so cancel that subscription from its own billing settings before attaching it."
)));
}
Ok(())
}
#[cfg(all(test, feature = "cloud"))]
mod attach_billing_guard_tests {
use super::reject_attach_of_subscribed_workspace;
use sqlx::{Pool, Postgres};
async fn workspace_on_plan(db: &Pool<Postgres>, id: &str, plan: Option<&str>) {
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')")
.bind(id)
.execute(db)
.await
.expect("insert workspace");
sqlx::query("INSERT INTO workspace_settings (workspace_id, plan) VALUES ($1, $2)")
.bind(id)
.bind(plan)
.execute(db)
.await
.expect("insert workspace_settings");
}
#[sqlx::test(migrations = "../migrations")]
async fn refuses_a_candidate_that_still_pays_for_itself(db: Pool<Postgres>) {
workspace_on_plan(&db, "subscribed", Some("team")).await;
workspace_on_plan(&db, "cancelled", None).await;
let err = reject_attach_of_subscribed_workspace(&db, "subscribed")
.await
.expect_err("a workspace on a paid plan of its own must not be attachable");
assert!(err.to_string().contains("paid plan of its own"), "{err}");
// Cancelling clears `plan` but keeps `customer_id`, so the plan column is what decides.
reject_attach_of_subscribed_workspace(&db, "cancelled")
.await
.expect("a workspace with no plan is attachable");
reject_attach_of_subscribed_workspace(&db, "no-settings-row")
.await
.expect("a workspace with no settings row is attachable");
}
}
/// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper
/// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new
/// node — 0 for a plain fork, or the candidate's own subtree height for an attach.
@@ -7916,6 +8029,17 @@ async fn attach_dev_workspace(
)));
}
// Deliberately below the admin-of-candidate check, unlike the cap enforcement above: the
// refusal names the candidate's plan, so running it earlier would tell any admin of any
// premium workspace whether an arbitrary workspace id is on a team plan.
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
let root = windmill_common::workspaces::get_billing_workspace_id(&db, &prod_w_id).await?;
if windmill_common::workspaces::get_billing_workspace_id(&db, &dev_w_id).await? != root {
reject_attach_of_subscribed_workspace(&db, &dev_w_id).await?;
}
}
let mut tx = db.begin().await?;
// Everything above ran outside a transaction, so prod's eligibility and the chain's labels could
// have changed under us: re-decide both here, under the pairing lock.