Merge remote-tracking branch 'origin/main' into migrate-session-to-fork

Main dropped the session header's "Acting on" chip for the page breadcrumb, so the
move-to-fork picker now opens from a "Move to a fork…" item in the session's menu.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-14 01:06:16 -07:00
co-authored by Claude Opus 5.5
947 changed files with 46540 additions and 15739 deletions
@@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts {
pub(crate) former_owner: Option<FormerOwnerDefaults>,
/// For a revoke: each grant it takes back.
pub(crate) revoked_grants: Vec<RevokedGrant>,
/// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant
/// would give it nothing it can use.
pub(crate) lacks_schema_usage: bool,
}
/// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the
@@ -1526,7 +1529,24 @@ async fn build_plan(
)));
}
}
let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants };
let lacks_schema_usage = match (&change, target) {
(AclChange::Grant { scope, .. }, AclTarget::Schema { schema })
if !matches!(scope, GrantScope::Target) =>
{
!has_schema_usage(client, schema, &pg_role).await?
}
(AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => {
!has_schema_usage(client, schema, &pg_role).await?
}
_ => false,
};
let facts = CatalogFacts {
other_pg_roles,
existing_objects,
former_owner,
revoked_grants,
lacks_schema_usage,
};
let mut plan =
crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?;
if matches!(change, AclChange::SetOwner { .. }) {
@@ -1540,6 +1560,27 @@ async fn build_plan(
Ok(plan)
}
/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from.
async fn has_schema_usage(
client: &tokio_postgres::Client,
schema: &str,
pg_role: &str,
) -> Result<bool> {
Ok(client
.query_one(
"SELECT has_schema_privilege($1::name, $2::text, 'USAGE')",
&[&pg_role, &schema],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the role's privileges on schema {schema}: {}",
pg_error_message(&e)
))
})?
.get(0))
}
/// Postgres only hands an object to a role that could have created it: a table to one with
/// `CREATE` on its schema, a schema to one with `CREATE` on the database.
async fn missing_owner_privilege(
@@ -880,7 +880,11 @@ struct DatatableMigrationsStatusResult {
/// Whether the SQL-migrations feature is enabled for a data table. Honors the
/// explicit `migrations_enabled` flag; when unset (data tables predating the
/// feature) it is considered enabled only if migrations already exist.
async fn datatable_migrations_enabled(db: &DB, w_id: &str, datatable_name: &str) -> Result<bool> {
pub(crate) async fn datatable_migrations_enabled(
db: &DB,
w_id: &str,
datatable_name: &str,
) -> Result<bool> {
let flag: Option<bool> = sqlx::query_scalar!(
"SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean \
FROM workspace_settings ws WHERE ws.workspace_id = $1",
@@ -25,11 +25,11 @@ use axum::{
};
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use magic_crypt::MagicCrypt256;
use regex::Regex;
use serde::{Deserialize, Serialize};
use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened};
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::variables::WorkspaceCrypt;
use windmill_common::{
auth::hash_token,
error::{error_source_chain, Error, Result},
@@ -676,8 +676,8 @@ async fn proxy(
pub(crate) async fn reencrypt_tokens(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &MagicCrypt256,
new: &MagicCrypt256,
old: &WorkspaceCrypt,
new: &WorkspaceCrypt,
) -> Result<()> {
let rows = sqlx::query!(
"SELECT email, token FROM remote_deploy_token
@@ -2325,6 +2325,9 @@ struct DataTableTables {
/// What the role the listing connected as may create.
can_create_schema: bool,
creatable_schemas: Vec<String>,
/// Whether its schema is kept as versioned migrations, which a change made with plain SQL
/// would bypass.
migrations_enabled: bool,
}
#[derive(Deserialize)]
@@ -2570,8 +2573,17 @@ async fn list_one_datatable_tables(
default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(),
can_create_schema: false,
creatable_schemas: vec![],
migrations_enabled: false,
};
let result: Result<()> = async {
// Read from the workspace settings alone, so it is known even when the database is not
// reachable.
entry.migrations_enabled = crate::datatable_migrations::datatable_migrations_enabled(
db,
w_id,
&entry.datatable_name,
)
.await?;
let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?;
entry.instance = governing.role_cluster().is_some();
let usable =
@@ -6317,8 +6329,8 @@ async fn set_encryption_key(
async fn reencrypt_git_sync_secrets(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &magic_crypt::MagicCrypt256,
new: &magic_crypt::MagicCrypt256,
old: &windmill_common::variables::WorkspaceCrypt,
new: &windmill_common::variables::WorkspaceCrypt,
) -> Result<()> {
let Some((mut credentials, mut git_sync)) =
sqlx::query_as::<_, (serde_json::Value, Option<serde_json::Value>)>(
@@ -11569,10 +11581,13 @@ struct ChangeOperatorSettings {
/// withdraw or grant the right on every push.
///
/// `builder_flows` lets every operator of this workspace compose flows out of already-deployed
/// runnables, and makes each of them consume a full author seat instead of half of one.
/// runnables, `builder_apps` full-code apps. Either makes each operator consume a full author
/// seat instead of half of one.
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_flows: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_apps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_triggers: Option<bool>,
@@ -11587,9 +11602,10 @@ async fn update_operator_settings(
require_admin(authed.is_admin, &authed.username)?;
// Every operator of the workspace turns into a full seat, which an offline license may not
// cover. It is a no-op delta when the right is already on.
// cover. Either right costs the same seat, so this prices "any", and it is a no-op delta when
// one of them is already on.
#[cfg(feature = "enterprise")]
if settings.builder_flows == Some(true) {
if settings.builder_flows == Some(true) || settings.builder_apps == Some(true) {
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await?
{
@@ -1299,6 +1299,13 @@ pub(crate) async fn delete_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM runnable_job_stats WHERE workspace_id = $1",
&w_id
)
.execute(&mut *tx)
.await?;
// If this workspace is itself a dev workspace, deleting it dissolves the pairing, so also drop
// the parent prod's reserved dev_workspace_lock (mirrors detach_dev_workspace) — otherwise prod
// stays locked against direct deploy/forking with no dev workspace left to make changes in.