mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +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
@@ -209,6 +209,7 @@ mod workspaces;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod workspaces_ee;
|
||||
mod workspaces_export;
|
||||
mod workspace_settings_document;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod mcp_tools;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,11 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
.route("/tarball", get(crate::workspaces_export::tarball_workspace))
|
||||
.route("/edit_copilot_config", post(edit_copilot_config))
|
||||
.route(
|
||||
"/settings_document",
|
||||
get(crate::workspace_settings_document::get_settings_document)
|
||||
.post(crate::workspace_settings_document::apply_settings_document),
|
||||
)
|
||||
.route("/get_copilot_info", get(get_copilot_info))
|
||||
.route("/critical_alerts", get(get_critical_alerts))
|
||||
.route(
|
||||
@@ -85,7 +90,7 @@ pub fn workspaced_service() -> Router {
|
||||
router
|
||||
}
|
||||
|
||||
async fn edit_copilot_config(
|
||||
pub(crate) async fn edit_copilot_config(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
@@ -190,7 +195,7 @@ async fn edit_copilot_config(
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EditCopilotConfigResponse {
|
||||
pub(crate) struct EditCopilotConfigResponse {
|
||||
effective_ai_config: AIConfig,
|
||||
has_instance_ai_config: bool,
|
||||
uses_instance_ai_config: bool,
|
||||
@@ -321,7 +326,7 @@ pub struct MuteCriticalAlertRequest {
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn mute_critical_alerts(
|
||||
pub(crate) async fn mute_critical_alerts(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
|
||||
@@ -359,6 +359,9 @@ pub(crate) struct ArchiveQueryParams {
|
||||
include_users: Option<bool>,
|
||||
include_groups: Option<bool>,
|
||||
include_settings: Option<bool>,
|
||||
/// Opt-in, with `settings_version=v2`: state the data tables' roles in settings.json. Their
|
||||
/// tenants name this workspace's users and groups, and their roles this instance's catalog.
|
||||
include_datatable_permissions: Option<bool>,
|
||||
include_key: Option<bool>,
|
||||
include_workspace_dependencies: Option<bool>,
|
||||
/// Default `false`: data table migrations ship in the tarball unless the repo
|
||||
@@ -513,49 +516,6 @@ struct SimplifiedGroup {
|
||||
admins: Vec<String>,
|
||||
}
|
||||
|
||||
// V2 format: New grouped format
|
||||
#[derive(Serialize)]
|
||||
struct SimplifiedSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auto_invite: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
webhook: Option<String>,
|
||||
// Always serialize (including as `null`) so that `wmill sync pull` emits
|
||||
// these fields in settings.yaml unconditionally. Makes round-trip
|
||||
// bijective: YAML is the source of truth, absence/null = "clear remote",
|
||||
// mirroring every other workspace setting.
|
||||
error_handler: Option<Value>,
|
||||
success_handler: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ai_config: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
large_file_storage: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
git_sync: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
default_app: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
default_scripts: Option<Value>,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mute_critical_alerts: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
operator_settings: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
datatable: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slack_team_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slack_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slack_command_script: Option<String>,
|
||||
// Always serialize (see note above on error_handler / success_handler).
|
||||
slack_oauth_client_id: Option<String>,
|
||||
slack_oauth_client_secret: Option<String>,
|
||||
}
|
||||
|
||||
// V1 format: Legacy flat format for backward compatibility (matches main branch exactly)
|
||||
#[derive(Serialize)]
|
||||
struct SimplifiedSettingsLegacy {
|
||||
@@ -602,7 +562,6 @@ struct SettingsRow {
|
||||
auto_invite: Option<Value>,
|
||||
webhook: Option<String>,
|
||||
error_handler: Option<Value>,
|
||||
success_handler: Option<Value>,
|
||||
ai_config: Option<serde_json::Value>,
|
||||
large_file_storage: Option<Value>,
|
||||
git_sync: Option<Value>,
|
||||
@@ -616,8 +575,6 @@ struct SettingsRow {
|
||||
slack_team_id: Option<String>,
|
||||
slack_name: Option<String>,
|
||||
slack_command_script: Option<String>,
|
||||
slack_oauth_client_id: Option<String>,
|
||||
slack_oauth_client_secret: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn tarball_workspace(
|
||||
@@ -638,6 +595,7 @@ pub(crate) async fn tarball_workspace(
|
||||
include_users,
|
||||
include_groups,
|
||||
include_settings,
|
||||
include_datatable_permissions,
|
||||
include_key,
|
||||
include_workspace_dependencies,
|
||||
skip_datatable_migrations,
|
||||
@@ -1593,7 +1551,6 @@ pub(crate) async fn tarball_workspace(
|
||||
auto_invite,
|
||||
webhook,
|
||||
error_handler,
|
||||
success_handler,
|
||||
ai_config,
|
||||
large_file_storage,
|
||||
git_sync,
|
||||
@@ -1606,9 +1563,7 @@ pub(crate) async fn tarball_workspace(
|
||||
datatable,
|
||||
slack_team_id,
|
||||
slack_name,
|
||||
slack_command_script,
|
||||
slack_oauth_client_id,
|
||||
slack_oauth_client_secret
|
||||
slack_command_script
|
||||
FROM workspace_settings
|
||||
LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id
|
||||
WHERE workspace_id = $1"#,
|
||||
@@ -1617,73 +1572,23 @@ pub(crate) async fn tarball_workspace(
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility
|
||||
// Server-owned state (the HMAC webhook secret + hook id/error, the
|
||||
// synced-sha / last-pull status, the admin automatic pulls run as, and what
|
||||
// the credential check observed) must never leave the server: keep it out of
|
||||
// export archives and synced repos, and don't let a re-imported workspace
|
||||
// inherit another install's hook/sync state or pull identity.
|
||||
fn redact_git_sync_for_export(git_sync: Option<Value>) -> Option<Value> {
|
||||
let mut git_sync = git_sync?;
|
||||
if let Some(repos) = git_sync
|
||||
.get_mut("repositories")
|
||||
.and_then(|r| r.as_array_mut())
|
||||
{
|
||||
for repo in repos {
|
||||
if let Some(auto_pull) =
|
||||
repo.get_mut("auto_pull").and_then(|a| a.as_object_mut())
|
||||
{
|
||||
for field in [
|
||||
"webhook_secret",
|
||||
"webhook_id",
|
||||
"webhook_error",
|
||||
"last_synced_sha",
|
||||
"last_pull_status",
|
||||
"enabled_by",
|
||||
] {
|
||||
auto_pull.remove(field);
|
||||
}
|
||||
}
|
||||
// What this install observed about its own credential: a token
|
||||
// id and expiry, and a `checked_at` that moves on its own.
|
||||
// None of it describes the workspace, and in a git-synced
|
||||
// `wmill.yaml` it would churn the file for no reason.
|
||||
if let Some(repo) = repo.as_object_mut() {
|
||||
repo.remove("credential");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git_sync)
|
||||
}
|
||||
|
||||
let settings_str = if settings_version.as_deref() == Some("v2") {
|
||||
let settings = SimplifiedSettings {
|
||||
auto_invite: row.auto_invite,
|
||||
webhook: row.webhook,
|
||||
error_handler: row.error_handler,
|
||||
success_handler: row.success_handler,
|
||||
ai_config: row.ai_config,
|
||||
large_file_storage: row.large_file_storage,
|
||||
git_sync: redact_git_sync_for_export(row.git_sync),
|
||||
default_app: row.default_app,
|
||||
default_scripts: row.default_scripts,
|
||||
name: row.name.clone().unwrap_or_default(),
|
||||
mute_critical_alerts: row.mute_critical_alerts,
|
||||
color: row.color.clone(),
|
||||
operator_settings: row.operator_settings.clone(),
|
||||
datatable: windmill_common::workspaces::strip_datatable_permissions(
|
||||
row.datatable.clone(),
|
||||
),
|
||||
slack_team_id: row.slack_team_id.clone(),
|
||||
slack_name: row.slack_name.clone(),
|
||||
slack_command_script: row.slack_command_script.clone(),
|
||||
slack_oauth_client_id: row.slack_oauth_client_id.clone(),
|
||||
slack_oauth_client_secret: row.slack_oauth_client_secret.clone(),
|
||||
let secrets = if export_plain_secrets {
|
||||
crate::workspace_settings_document::SecretMode::Plain
|
||||
} else {
|
||||
crate::workspace_settings_document::SecretMode::Encrypted
|
||||
};
|
||||
serde_json::to_value(settings)
|
||||
.map(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.ok()
|
||||
.flatten()
|
||||
let mut doc = crate::workspace_settings_document::load_settings_document(
|
||||
&mut *tx, &db, &w_id, secrets,
|
||||
)
|
||||
.await?;
|
||||
// A repo syncs its rules through protection-rules.yaml, so settings.yaml leaving
|
||||
// them out keeps one writer for them.
|
||||
doc.remove("protection_rules");
|
||||
if !include_datatable_permissions.unwrap_or(false) {
|
||||
doc.remove("datatable_permissions");
|
||||
}
|
||||
serde_json::to_string_pretty(&doc).ok()
|
||||
} else {
|
||||
// V1 (legacy) format: convert JSONB to flat fields (matches main branch exactly)
|
||||
let (auto_invite_enabled, auto_invite_as, auto_invite_mode) =
|
||||
@@ -1730,7 +1635,7 @@ pub(crate) async fn tarball_workspace(
|
||||
error_handler_muted_on_cancel,
|
||||
ai_config: row.ai_config,
|
||||
large_file_storage: row.large_file_storage,
|
||||
git_sync: redact_git_sync_for_export(row.git_sync),
|
||||
git_sync: crate::workspace_settings_document::redact_git_sync(row.git_sync),
|
||||
default_app: row.default_app,
|
||||
default_scripts: row.default_scripts,
|
||||
name: row.name.unwrap_or_default(),
|
||||
|
||||
Reference in New Issue
Block a user