Files
windmill/backend/tests/app_guest_allowance.rs
fce635d3c4 feat: guest app execution mode, a role that takes no seat (#10929)
* feat: guest app execution mode, a fourth role that takes no seat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: make the guest grant a server-minted label, not a declarable scope

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the guest session companion branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest tokens are not rescopable and guest embed tokens keep the sentinel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest-derived tokens share one constraint set; gate sign-in on guest discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the label alone governs a guest; refuse guests with accounts; unserialize discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest discovery fails closed; SAML aborts if the guest cookie write fails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* refactor: enforce the guest switch once at the auth door; sign-in for a guest of another app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest app-mode decided once at the on-behalf resolver; clear a stale guest session before offering another app's sign-in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest may use anonymous apps; await the stale-session logout; trim comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest's path confinement waits for the app's mode, so anonymous apps stay open to it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest target survives http (Lax cookie), rides SAML RelayState; tell account holders on arrival

* fix: a guest uses an anonymous app as itself; S3 uploads confined by app mode

* fix: a guest upload needs an app policy; a missing app does not skip the confinement

* fix: guests are gated on the Enterprise plan server-side; pin ee-repo-ref

* fix: the guest plan gate fails closed on non-enterprise builds; settings report the effective switch

* fix: guest controls read the plan, not the key; gate the guest tests on the features they need

* docs: tighten the guest session invariant comments

* feat: 100 free guests per 30 days, then a quarter seat each on Enterprise and a hard cap elsewhere; superadmin guest list; refusals reach the page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the cap is exact, an account ends a guest session at the door, popups close, and guest mode survives the CLI round trip

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* feat: a superadmin switch over guests for the whole instance; the pre-existing-user flag keeps its meaning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: drop the dead guest-access helper, name the instance setting once, guests tab states, CE save order

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest app path is refused at the mint if it could widen the scope; the instance toggle waits for its reload

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guests stop at the launched-by-me job grant; canonical app paths at the mint and discovery; the toggle ends on the stored value

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: only the scope grammar's own characters bar an app path from guests, refused at deploy as well as at the mint

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the deploy-time guest path guard checks the destination of a rename and refuses a leading slash

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a workspace rename keeps the guest switch; the rename guard reads the deployed mode under the row lock

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest_activity follows a workspace rename and goes with a workspace delete

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the state-bound guest target

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref; the guest cookie is never cleared by a callback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* docs: the workspace-scoped guest_activity delete moves an instance-wide count; assert the mint records the guest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* test: the seeded allowance is a day old, so only the mint can write today's guest_activity row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: update ee-repo-ref to 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

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

Previous ee-repo-ref: 32841072aa396bff91d30bd91854fa348cb3c439

New ee-repo-ref: 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-04 22:47:28 +02:00

160 lines
5.4 KiB
Rust

//! The guest allowance: free up to `FREE_GUESTS_PER_WINDOW` distinct emails over the
//! trailing window. Past it, a hard-capped instance (Community, Pro) refuses a stranger
//! and lets a returning guest back in; a metered one (Enterprise) admits everyone and
//! counts seats. Its own binary: the plan is read from a process-wide key id that this
//! test flips, which no test sharing the process could tolerate.
//!
//! Users from the `base` fixture:
//! test-user (admin, token SECRET_TOKEN)
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::{FREE_GUESTS_PER_WINDOW, GUEST_WINDOW_DAYS};
use windmill_test_utils::*;
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
const APP_PATH: &str = "u/test-user/guest_app";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Community and Pro are capped, Enterprise is metered. Only a build with both
/// `private` (the key id) and `enterprise` (the plan read) can meter; every other build
/// is capped whatever this says.
fn set_plan(pro: bool) {
#[cfg(feature = "private")]
windmill_common::ee::LICENSE_KEY_ID.store(std::sync::Arc::new(
if pro { "test_pro" } else { "" }.to_string(),
));
let _ = pro;
}
async fn mint(db: &Pool<Postgres>, email: &str) -> windmill_common::error::Result<String> {
let mut tx = db.begin().await.unwrap();
let minted = windmill_api_users::users::create_guest_session_token(
email,
"test-workspace",
APP_PATH,
&mut tx,
tower_cookies::Cookies::default(),
)
.await;
tx.commit().await.unwrap();
minted
}
#[sqlx::test(fixtures("base"))]
async fn the_allowance_caps_strangers_and_meters_an_enterprise_plan(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
authed(
client().post(format!("{ws}/workspaces/edit_guest_access")),
ADMIN_TOKEN,
)
.json(&json!({ "guest_access_enabled": true }))
.send()
.await?;
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP_PATH,
"summary": "Guest app",
"value": {},
"policy": { "execution_mode": "guest", "triggerables_v2": {} }
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
// The whole allowance, used yesterday: still in the window, and a day the mint
// does not write, so a row dated today can only be the mint's own.
sqlx::query(
"INSERT INTO guest_activity (email, workspace_id, day)
SELECT 'g' || i || '@example.com', 'test-workspace', CURRENT_DATE - 1
FROM generate_series(1, $1) AS i",
)
.bind(FREE_GUESTS_PER_WINDOW)
.execute(&db)
.await?;
set_plan(true);
let refused = mint(&db, "stranger@example.com").await.unwrap_err();
assert!(
matches!(&refused, windmill_common::error::Error::PermissionDenied(m)
if m.contains(&format!("limit of {FREE_GUESTS_PER_WINDOW} guests over {GUEST_WINDOW_DAYS} days"))),
"a stranger past the allowance is refused with the message the visitor reads: {refused:?}"
);
mint(&db, "g1@example.com")
.await
.expect("a guest already in the window is let back in");
let recorded: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guest_activity
WHERE email = 'g1@example.com' AND workspace_id = 'test-workspace'
AND day = CURRENT_DATE)",
)
.fetch_one(&db)
.await?;
assert!(
recorded,
"the mint writes today's guest_activity row, the allowance's unit"
);
let list: serde_json::Value = authed(
client().get(format!(
"http://localhost:{port}/api/users/guests?per_page=5"
)),
ADMIN_TOKEN,
)
.send()
.await?
.json()
.await?;
assert_eq!(list["usage"]["guest_count"], FREE_GUESTS_PER_WINDOW);
assert_eq!(list["usage"]["metered"], false);
assert_eq!(list["usage"]["guest_seats"], 0);
assert_eq!(list["guests"].as_array().map(Vec::len), Some(5));
assert_eq!(list["guests"][0]["workspaces"], json!(["test-workspace"]));
let usage: serde_json::Value = authed(
client().get(format!("{ws}/workspaces/guest_usage")),
ADMIN_TOKEN,
)
.send()
.await?
.json()
.await?;
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW);
#[cfg(all(feature = "private", feature = "enterprise"))]
{
set_plan(false);
mint(&db, "stranger@example.com")
.await
.expect("a metered plan admits past the allowance");
let usage: serde_json::Value = authed(
client().get(format!("{ws}/workspaces/guest_usage")),
ADMIN_TOKEN,
)
.send()
.await?
.json()
.await?;
assert_eq!(usage["guest_count"], FREE_GUESTS_PER_WINDOW + 1);
assert_eq!(usage["metered"], true);
assert_eq!(usage["billable_guests"], 1);
assert_eq!(
usage["guest_seats"], 1,
"one guest past the allowance is a whole seat"
);
}
Ok(())
}