mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 16:03:14 +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
@@ -89,6 +89,9 @@ export interface SyncOptions {
|
||||
includeUsers?: boolean;
|
||||
includeGroups?: boolean;
|
||||
includeSettings?: boolean;
|
||||
/** With includeSettings: settings.yaml also states who may connect to each data table as which
|
||||
* role. Off by default, since tenants and roles are specific to a workspace and an instance. */
|
||||
includeDatatablePermissions?: boolean;
|
||||
includeKey?: boolean;
|
||||
skipReencryptOnKeyChange?: boolean;
|
||||
skipBranchValidation?: boolean;
|
||||
@@ -388,6 +391,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly<
|
||||
| "includeUsers"
|
||||
| "includeGroups"
|
||||
| "includeSettings"
|
||||
| "includeDatatablePermissions"
|
||||
| "includeKey"
|
||||
| "nonDottedPaths"
|
||||
| "syncBehavior"
|
||||
@@ -410,6 +414,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly<
|
||||
includeUsers: false,
|
||||
includeGroups: false,
|
||||
includeSettings: false,
|
||||
includeDatatablePermissions: false,
|
||||
includeKey: false,
|
||||
skipWorkspaceDependencies: false,
|
||||
skipDatatableMigrations: false,
|
||||
|
||||
@@ -58,8 +58,35 @@ export interface SimplifiedSettings {
|
||||
slack_command_script?: string;
|
||||
slack_oauth_client_id?: string;
|
||||
slack_oauth_client_secret?: string;
|
||||
ducklake?: any;
|
||||
dbt_warehouses?: any;
|
||||
deploy_ui?: any;
|
||||
teams_command_script?: string | null;
|
||||
public_app_execution_limit_per_minute?: number | null;
|
||||
guest_access_enabled?: boolean;
|
||||
guest_jwt_public_key?: string | null;
|
||||
guest_jwt_jwks_url?: string | null;
|
||||
error_handler_fallback_to_instance_alerts?: boolean;
|
||||
add_admins_and_developers_to_forks?: boolean;
|
||||
datatable_permissions?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Settings the server manages only when settings.yaml states them: a file written before
|
||||
* they existed must neither clear them nor show as differing from the workspace. */
|
||||
export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [
|
||||
"datatable_permissions",
|
||||
"ducklake",
|
||||
"dbt_warehouses",
|
||||
"deploy_ui",
|
||||
"teams_command_script",
|
||||
"public_app_execution_limit_per_minute",
|
||||
"guest_access_enabled",
|
||||
"guest_jwt_public_key",
|
||||
"guest_jwt_jwks_url",
|
||||
"error_handler_fallback_to_instance_alerts",
|
||||
"add_admins_and_developers_to_forks",
|
||||
] as const;
|
||||
|
||||
// Legacy settings interface for reading old settings.yaml files
|
||||
interface LegacySimplifiedSettings {
|
||||
auto_invite_enabled?: boolean;
|
||||
@@ -108,6 +135,9 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
|
||||
if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script;
|
||||
if (settings.slack_oauth_client_id !== undefined) result.slack_oauth_client_id = settings.slack_oauth_client_id;
|
||||
if (settings.slack_oauth_client_secret !== undefined) result.slack_oauth_client_secret = settings.slack_oauth_client_secret;
|
||||
for (const key of SETTINGS_MANAGED_ONLY_WHEN_PRESENT) {
|
||||
if (settings[key] !== undefined) (result as any)[key] = settings[key];
|
||||
}
|
||||
|
||||
// Handle auto_invite: check if already grouped or needs migration
|
||||
if (settings.auto_invite && typeof settings.auto_invite === "object") {
|
||||
@@ -173,10 +203,37 @@ export async function pushWorkspaceSettings(
|
||||
workspace: string,
|
||||
_path: string,
|
||||
settings: SimplifiedSettings | undefined,
|
||||
localSettings: SimplifiedSettings | any
|
||||
localSettings: SimplifiedSettings | any,
|
||||
opts: { includeDatatablePermissions?: boolean } = {},
|
||||
) {
|
||||
if (localSettings?.protection_rules !== undefined) {
|
||||
log.warn(
|
||||
"Ignoring protection_rules in settings.yaml: protection-rules.yaml syncs them (wmill protection-rules push)."
|
||||
);
|
||||
}
|
||||
// Migrate local settings from legacy format if needed
|
||||
localSettings = migrateToGroupedFormat(localSettings);
|
||||
if (!opts.includeDatatablePermissions) {
|
||||
delete localSettings.datatable_permissions;
|
||||
}
|
||||
|
||||
if (await applySettingsDocument(workspace, localSettings)) {
|
||||
return;
|
||||
}
|
||||
// The per-setting endpoints of a server without the settings document know nothing of
|
||||
// these keys, nor of a secret it would have to decrypt or keep.
|
||||
for (const key of SETTINGS_MANAGED_ONLY_WHEN_PRESENT) {
|
||||
delete (localSettings as any)[key];
|
||||
}
|
||||
const secret = localSettings.slack_oauth_client_secret;
|
||||
const slackSecretUnreadable =
|
||||
typeof secret === "string" &&
|
||||
(secret.startsWith("$encrypted:") || secret === "__SENSITIVE_AND_UNCHANGED__");
|
||||
if (slackSecretUnreadable) {
|
||||
log.warn(
|
||||
"Leaving the Slack OAuth client and secret unchanged: this server cannot read an encrypted or redacted secret. Upgrade it, or write the secret in plain text."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const remoteSettings = await wmill.getSettings({
|
||||
@@ -456,8 +513,9 @@ export async function pushWorkspaceSettings(
|
||||
// every other setting): both present → upsert; anything else → delete.
|
||||
// Pull always emits both fields as null when remote is NULL.
|
||||
if (
|
||||
localSettings.slack_oauth_client_id != settings.slack_oauth_client_id ||
|
||||
localSettings.slack_oauth_client_secret != settings.slack_oauth_client_secret
|
||||
!slackSecretUnreadable &&
|
||||
(localSettings.slack_oauth_client_id != settings.slack_oauth_client_id ||
|
||||
localSettings.slack_oauth_client_secret != settings.slack_oauth_client_secret)
|
||||
) {
|
||||
log.debug(`Updating slack oauth config...`);
|
||||
if (
|
||||
@@ -477,6 +535,38 @@ export async function pushWorkspaceSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies settings.yaml server-side, which owns the rules for what an omitted or null setting
|
||||
* means. Returns false on a server that predates the settings document. */
|
||||
async function applySettingsDocument(
|
||||
workspace: string,
|
||||
document: SimplifiedSettings,
|
||||
): Promise<boolean> {
|
||||
let result;
|
||||
try {
|
||||
result = await wmill.applyWorkspaceSettingsDocument({
|
||||
workspace,
|
||||
requestBody: { document: document as unknown as Record<string, unknown>, replace: true },
|
||||
});
|
||||
} catch (err: any) {
|
||||
// A missing route answers 404 with no body; a handler's own 404 carries a message.
|
||||
if (err?.status === 404 && !err?.body) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Failed to apply workspace settings: ${err?.body ?? err}`);
|
||||
}
|
||||
if (result.changes.length > 0) {
|
||||
log.debug(
|
||||
`Updated workspace settings: ${result.changes.map((c) => c.key).join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
log.debug(`Workspace settings are up to date`);
|
||||
}
|
||||
for (const warning of result.warnings ?? []) {
|
||||
log.warn(warning);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface PushWorkspaceKeyOptions {
|
||||
// True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a
|
||||
// TTY). In that case the re-encryption decision is taken from `skipReencrypt`
|
||||
|
||||
Reference in New Issue
Block a user