Merge origin/license-app-only-implicit-promotion into guest-jwt-app-entry

Parent adds guest_activity handling on workspace rename (UPDATE ... SET
workspace_id) and delete (DELETE FROM guest_activity), and reads the deployed
mode under the app-row lock on a rename. The rename/delete queries touch only
workspace_id, leaving the jwt_entry column this PR adds intact. Keep
guest_session_scopes relocated to windmill_api_auth::scopes rather than the
parent's re-added local copy, and sync its refusal message with the parent's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
This commit is contained in:
Ruben Fiszel
2026-09-04 14:41:33 +02:00
co-authored by Claude Opus 4.8
5 changed files with 103 additions and 23 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "0c5b02b6b70fb8fd2ab3e6c57897038750a44a67360d342b6ef705ef2e4d3007"
"hash": "e2eee8de61337b7d093f38e3e393e3620111119abf8dda4bde5b835ff934e4f1"
}
+62
View File
@@ -591,6 +591,68 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
resp.text().await?
);
// Set on update: an app that already sits on such a path cannot be switched.
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": "u/test-user/x:y",
"summary": "App",
"value": {},
"policy": { "execution_mode": "publisher", "triggerables_v2": {} }
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
let resp = authed(
client().post(format!("{ws}/apps/update/u/test-user/x:y")),
ADMIN_TOKEN,
)
.json(&json!({ "policy": { "execution_mode": "guest", "triggerables_v2": {} } }))
.send()
.await?;
assert_eq!(resp.status(), 400, "switched to Guests on a `:` path");
Ok(())
}
/// Renaming a workspace copies its settings; the guest switch must travel with them,
/// or the rename silently shuts every guest app of the workspace.
#[sqlx::test(fixtures("base"))]
async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
enable_guests(port, "test-workspace").await?;
sqlx::query(
"INSERT INTO guest_activity (email, workspace_id, day)
VALUES ('guest@example.com', 'test-workspace', CURRENT_DATE)",
)
.execute(&db)
.await?;
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
)),
ADMIN_TOKEN,
)
.json(&json!({ "new_id": "test-workspace-2", "new_name": "Test workspace 2" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let enabled: bool = sqlx::query_scalar(
"SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
)
.fetch_one(&db)
.await?;
assert!(enabled, "the guest switch travels with the workspace");
let moved: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace-2')
AND NOT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace')",
)
.fetch_one(&db)
.await?;
assert!(moved, "the guests seen in the workspace follow its new id");
Ok(())
}
+2 -1
View File
@@ -805,7 +805,8 @@ pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Ve
// `/`; app paths may otherwise carry spaces and `@`, so guard only those reserved chars.
if !windmill_common::auth::is_scope_literal_path(app_path) {
return Err(windmill_common::error::Error::BadRequest(format!(
"app path {app_path} cannot be scoped: `:`, `,` and `*` are reserved in scopes"
"app path {app_path} cannot be scoped: `:`, `,` and `*` are reserved in scopes, \
and a leading `/` never matches a route"
)));
}
Ok(vec![
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -187,6 +187,13 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
info!("Updating guest_activity table");
sqlx::query("UPDATE guest_activity SET workspace_id = $1 WHERE workspace_id = $2")
.bind(&rw.new_id)
.bind(&old_id)
.execute(&mut *tx)
.await?;
info!("Updating workspace_invite table");
sqlx::query!(
"UPDATE workspace_invite SET workspace_id = $1 WHERE workspace_id = $2",
@@ -1112,6 +1119,11 @@ pub(crate) async fn delete_workspace(
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM guest_activity WHERE workspace_id = $1")
.bind(&w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM token WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
+24 -19
View File
@@ -336,7 +336,8 @@ fn deployment_rule_for_mode(mode: ExecutionMode) -> Option<ProtectionRuleKind> {
fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> {
if matches!(mode, ExecutionMode::Guest) && !windmill_common::auth::is_scope_literal_path(path) {
return Err(Error::BadRequest(format!(
"app {path} cannot be set to Guests: `:`, `,` and `*` in a path cannot be scoped"
"app {path} cannot be set to Guests: a path with `:`, `,` or `*`, or a leading `/`, \
cannot be scoped"
)));
}
Ok(())
@@ -3359,24 +3360,6 @@ async fn update_app_internal<'a>(
// the token's write scope, not just the source path.
if let Some(npath) = ns.path.as_deref() {
check_scopes(&authed, || format!("apps:write:{}", npath))?;
// The destination is what a guest session would be scoped to; a rename that
// carries no policy keeps the deployed mode.
let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) {
Some(mode) => mode,
None => sqlx::query_scalar::<_, Option<String>>(
"SELECT policy->>'execution_mode' FROM app WHERE workspace_id = $1 AND path = $2",
)
.bind(w_id)
.bind(path)
.fetch_optional(&db)
.await?
.flatten()
.and_then(|m| {
serde_json::from_value::<ExecutionMode>(serde_json::Value::String(m)).ok()
})
.unwrap_or_default(),
};
refuse_unscopable_guest_app(npath, mode)?;
}
if raw_app {
@@ -3449,6 +3432,28 @@ async fn update_app_internal<'a>(
if npath != path {
require_owner_of_path(&authed, path)?;
// The destination is what a guest session would be scoped to. A rename
// that carries no policy keeps the deployed mode, read under the row
// lock so a policy update landing alongside cannot slip a guest app
// onto a path it cannot be scoped to.
let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) {
Some(mode) => mode,
None => sqlx::query_scalar::<_, Option<String>>(
"SELECT policy->>'execution_mode' FROM app
WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?
.flatten()
.and_then(|m| {
serde_json::from_value::<ExecutionMode>(serde_json::Value::String(m)).ok()
})
.unwrap_or_default(),
};
refuse_unscopable_guest_app(npath, mode)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
npath,