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:
Ruben Fiszel
2026-10-14 12:35:08 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 17fefcf777
commit 9a5ec223bc
25 changed files with 2449 additions and 136 deletions
+1
View File
@@ -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
+8 -3
View File
@@ -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,
+21 -116
View File
@@ -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(),