mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
windmill-internal-app[bot]
Ruben Fiszel
parent
3ce9bbc716
commit
7dd88c470c
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user