mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
feat: workspace settings as one document, applied server-side (#11740)
* feat: add the workspace settings document, applied server-side * feat: add wmill workspace get-settings and apply-settings * fix: warn on unknown settings keys and refuse ee-only ones in the dry run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: stub the settings document endpoint in the push settings unit tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse mistyped settings and keep 4xx status on a partial settings apply Reject non-boolean switches and non-string Slack credentials instead of coercing them into destructive writes, preserve the failing endpoint's status when wrapping a partial-apply error, keep the stored Slack secret when an older CLI pushes one encrypted under another workspace's key, print the plan's warnings in apply-settings, and state the admin check load_settings_document leaves to its caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: compare handler arguments verbatim and type-check scalar settings A handler's extra_args is stored as sent and its nulls reach the job as arguments, so an added or removed null argument is now a change. Scalar settings are type-checked in the plan so a dry run never reports a change the endpoint then refuses, and an error handler's omitted mute flags compare as the false the endpoint stores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: require a 32-bit integer rate limit in the settings plan Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: data table permissions and protection rules in the settings document The settings document gains datatable_permissions, each data table's roles named by their Postgres role name and written by catalog id, and protection_rules, keyed by rule name. Both are managed only when stated and stated as a whole. The dev workspace lock stays with the dev workspace setting. wmill sync carries datatable_permissions only with includeDatatablePermissions (or --include-datatable-permissions), since tenants and roles are specific to a workspace and an instance. protection_rules stay out of settings.yaml: protection-rules.yaml syncs them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: validate the default data table role and plan permissions against a replaced datatable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
17fefcf777
commit
9a5ec223bc
@@ -1765,6 +1765,43 @@ async fn set_slack_oauth_config(
|
||||
"Both client ID and client secret are required".to_string(),
|
||||
));
|
||||
}
|
||||
// A settings.yaml pulled by `wmill sync` holds the secret encrypted, and a CLI that
|
||||
// predates the settings document pushes it here as it reads it. One encrypted under another
|
||||
// workspace's key keeps the stored secret, as the settings document does, so that CLI's push
|
||||
// does not abort over it.
|
||||
let mut req = req;
|
||||
if let Some(ciphertext) = req
|
||||
.slack_oauth_client_secret
|
||||
.strip_prefix(windmill_common::variables::SETTINGS_ENCRYPTED_PREFIX)
|
||||
{
|
||||
match windmill_common::variables::build_crypt(&db, &w_id)
|
||||
.await?
|
||||
.decrypt_str(ciphertext)
|
||||
{
|
||||
Ok(plain) => req.slack_oauth_client_secret = plain,
|
||||
Err(_) => {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
tracing::warn!(
|
||||
workspace_id = %w_id,
|
||||
"Slack client secret left unchanged: it is encrypted with another workspace's key"
|
||||
);
|
||||
match stored {
|
||||
Some(stored) => req.slack_oauth_client_secret = stored,
|
||||
None => {
|
||||
return Ok("Slack OAuth config left unchanged: the client secret is \
|
||||
encrypted with another workspace's key"
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -14852,3 +14889,154 @@ async fn set_ws_specific(
|
||||
body.value, body.item_kind, body.path
|
||||
))
|
||||
}
|
||||
|
||||
/// A setting the workspace settings document writes, each through the endpoint that owns it,
|
||||
/// so applying a document validates, audits and has the side effects of the matching UI save.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsEdit {
|
||||
Webhook,
|
||||
AutoInvite,
|
||||
InstanceGroups,
|
||||
ErrorHandler,
|
||||
SuccessHandler,
|
||||
LargeFileStorage,
|
||||
GitSync,
|
||||
DefaultScripts,
|
||||
DefaultApp,
|
||||
Color,
|
||||
OperatorSettings,
|
||||
Datatable,
|
||||
SlackCommand,
|
||||
SetSlackOAuth,
|
||||
DeleteSlackOAuth,
|
||||
Ducklake,
|
||||
DbtWarehouses,
|
||||
DeployUi,
|
||||
GuestAccess,
|
||||
GuestJwtKey,
|
||||
PublicAppRateLimit,
|
||||
AddAdminsAndDevelopersToForks,
|
||||
/// Body: `{ datatable, permissioned, default_role, roles: [{ id, tenants }] }`, role ids.
|
||||
DatatablePermissions,
|
||||
CreateProtectionRule,
|
||||
/// Body: the update request plus `rule_name`, the rule it updates.
|
||||
UpdateProtectionRule,
|
||||
/// Body: `{ name }`.
|
||||
DeleteProtectionRule,
|
||||
}
|
||||
|
||||
fn settings_edit_body<T: serde::de::DeserializeOwned>(
|
||||
edit: SettingsEdit,
|
||||
body: serde_json::Value,
|
||||
) -> Result<Json<T>> {
|
||||
serde_json::from_value(body)
|
||||
.map(Json)
|
||||
.map_err(|e| Error::BadRequest(format!("Invalid value for {edit:?}: {e}")))
|
||||
}
|
||||
|
||||
/// Returns the endpoint's JSON response, or `null` for one that answers with prose.
|
||||
pub async fn apply_settings_edit(
|
||||
authed: ApiAuthed,
|
||||
db: DB,
|
||||
w_id: String,
|
||||
edit: SettingsEdit,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
use SettingsEdit::*;
|
||||
let a = authed.clone();
|
||||
let (d, p) = (Extension(db), Path(w_id));
|
||||
match edit {
|
||||
Webhook => edit_webhook(a, d, p, authed, settings_edit_body(edit, body)?).await?,
|
||||
AutoInvite => edit_auto_invite(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
InstanceGroups => edit_instance_groups(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
ErrorHandler => {
|
||||
edit_error_handler(a, d, p, authed, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
SuccessHandler => edit_success_handler(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
LargeFileStorage => {
|
||||
edit_large_file_storage_config(a, d, p, authed, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
GitSync => edit_git_sync_config(a, d, p, authed, settings_edit_body(edit, body)?).await?,
|
||||
DefaultScripts => {
|
||||
edit_default_scripts(a, d, p, authed, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
DefaultApp => edit_default_app(a, d, p, authed, settings_edit_body(edit, body)?).await?,
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
DefaultApp => edit_default_app(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
Color => change_workspace_color(a, p, d, settings_edit_body(edit, body)?).await?,
|
||||
OperatorSettings => {
|
||||
update_operator_settings(a, p, d, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
Datatable => {
|
||||
let Json(result) =
|
||||
edit_datatable_config(a, d, p, authed, settings_edit_body(edit, body)?).await?;
|
||||
return serde_json::to_value(result).map_err(|e| Error::internal_err(e.to_string()));
|
||||
}
|
||||
SlackCommand => {
|
||||
edit_slack_command(a, d, p, authed, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
SetSlackOAuth => set_slack_oauth_config(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
DeleteSlackOAuth => delete_slack_oauth_config(a, d, p).await?,
|
||||
Ducklake => edit_ducklake_config(a, d, p, authed, settings_edit_body(edit, body)?).await?,
|
||||
DbtWarehouses => {
|
||||
edit_dbt_warehouses(a, d, p, authed, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
DeployUi => edit_deploy_ui_config(a, d, p, authed, settings_edit_body(edit, body)?).await?,
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
DeployUi => edit_deploy_ui_config(a, d, p).await?,
|
||||
GuestAccess => edit_guest_access(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
GuestJwtKey => edit_guest_jwt_key(a, d, p, settings_edit_body(edit, body)?).await?,
|
||||
PublicAppRateLimit => {
|
||||
edit_public_app_rate_limit(d, p, a, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
AddAdminsAndDevelopersToForks => {
|
||||
edit_add_admins_and_developers_to_forks(a, d, p, settings_edit_body(edit, body)?)
|
||||
.await?
|
||||
}
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
DatatablePermissions => {
|
||||
#[derive(Deserialize)]
|
||||
struct Body {
|
||||
datatable: String,
|
||||
#[serde(flatten)]
|
||||
req: crate::datatable_permissions_ee::SetDatatablePermissions,
|
||||
}
|
||||
let Json(Body { datatable, req }) = settings_edit_body(edit, body)?;
|
||||
crate::datatable_permissions_oss::set_datatable_permissions(
|
||||
a,
|
||||
d,
|
||||
Path((p.0, datatable)),
|
||||
Json(req),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
DatatablePermissions => {
|
||||
crate::datatable_permissions_oss::set_datatable_permissions(a).await?
|
||||
}
|
||||
CreateProtectionRule => {
|
||||
create_protection_rule(a, d, p, settings_edit_body(edit, body)?).await?
|
||||
}
|
||||
UpdateProtectionRule => {
|
||||
#[derive(Deserialize)]
|
||||
struct Body {
|
||||
rule_name: String,
|
||||
#[serde(flatten)]
|
||||
req: UpdateProtectionRuleRequest,
|
||||
}
|
||||
let Json(Body { rule_name, req }) = settings_edit_body(edit, body)?;
|
||||
update_protection_rule(a, d, Path((p.0, rule_name)), Json(req)).await?
|
||||
}
|
||||
DeleteProtectionRule => {
|
||||
#[derive(Deserialize)]
|
||||
struct Body {
|
||||
name: String,
|
||||
}
|
||||
let Json(Body { name }) = settings_edit_body(edit, body)?;
|
||||
delete_protection_rule(a, d, Path((p.0, name))).await?
|
||||
}
|
||||
};
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user