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
+3
View File
@@ -109,6 +109,9 @@ export const CONFIG_REFERENCE: ConfigOption[] = [
commented: true, templateValue: "true" },
{ name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync",
commented: true, templateValue: "true" },
{ name: "includeDatatablePermissions", type: "boolean", default: "false",
description: "With includeSettings, also sync each data table's roles and who may connect as them",
commented: true, templateValue: "true" },
{ name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync",
commented: true, templateValue: "true" },
+3 -1
View File
@@ -86,7 +86,8 @@ export async function downloadZip(
skipWorkspaceDependencies?: boolean,
skipDatatableMigrations?: boolean,
defaultTs?: "bun" | "deno",
syncBehavior?: string
syncBehavior?: string,
includeDatatablePermissions?: boolean,
): Promise<JSZip | TarAsZip | undefined> {
const requestHeaders = new Headers();
requestHeaders.set("Authorization", "Bearer " + workspace.token);
@@ -112,6 +113,7 @@ export async function downloadZip(
}&skip_secrets=${skipSecrets ?? false}&include_schedules=${includeSchedules ?? false
}&include_triggers=${includeTriggers ?? false}&include_users=${includeUsers ?? false
}&include_groups=${includeGroups ?? false}&include_settings=${includeSettings ?? false
}&include_datatable_permissions=${includeDatatablePermissions ?? false
}&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&skip_datatable_migrations=${skipDatatableMigrations ?? false}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2&preserve_extra_perms=true&sync_behavior_version=${syncBehavior ?? "v0"}`;
const baseUrl = workspace.remote + "api/w/" + workspace.workspaceId + "/workspaces/tarball?";
+21
View File
@@ -14,6 +14,7 @@ import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import * as log from "../../core/log.ts";
import { SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts";
import * as path from "node:path";
import { sep as SEP } from "node:path";
import {
@@ -2576,6 +2577,14 @@ const isWorkspaceSettingsFile = (p: string) =>
function stripUnappliedSettingsFields(local: any, remote: any) {
delete local?.name;
delete remote?.name;
for (const key of ["operator_settings", ...SETTINGS_MANAGED_ONLY_WHEN_PRESENT]) {
if (local?.[key] === undefined) delete remote?.[key];
}
// push clears an omitted mute_critical_alerts to false
if (local?.mute_critical_alerts == null && remote?.mute_critical_alerts === false) {
delete local?.mute_critical_alerts;
delete remote.mute_critical_alerts;
}
if (local?.color == null) {
delete local?.color;
delete remote?.color;
@@ -3843,6 +3852,7 @@ export async function pull(
opts.skipDatatableMigrations,
opts.defaultTs,
opts.syncBehavior,
opts.includeDatatablePermissions,
);
const remote = ZipFSElement(
@@ -4946,6 +4956,7 @@ export async function push(
opts.skipDatatableMigrations,
opts.defaultTs,
opts.syncBehavior,
opts.includeDatatablePermissions,
))!,
!opts.json,
opts.defaultTs ?? "bun",
@@ -6049,6 +6060,7 @@ export async function push(
(opts.yes ?? false) || !process.stdin.isTTY,
skipReencrypt: opts.skipReencryptOnKeyChange,
},
includeDatatablePermissions: opts.includeDatatablePermissions,
defaultTs: opts.defaultTs,
},
);
@@ -6187,6 +6199,7 @@ export async function push(
(opts.yes ?? false) || !process.stdin.isTTY,
skipReencrypt: opts.skipReencryptOnKeyChange,
},
includeDatatablePermissions: opts.includeDatatablePermissions,
defaultTs: opts.defaultTs,
},
);
@@ -6815,6 +6828,10 @@ const command = new Command()
.option("--include-users", "Include syncing users")
.option("--include-groups", "Include syncing groups")
.option("--include-settings", "Include syncing workspace settings")
.option(
"--include-datatable-permissions",
"With --include-settings, also sync who may connect to each data table as which role",
)
.option("--include-key", "Include workspace encryption key")
.option(
"--keep-deleted",
@@ -6878,6 +6895,10 @@ const command = new Command()
.option("--include-users", "Include syncing users")
.option("--include-groups", "Include syncing groups")
.option("--include-settings", "Include syncing workspace settings")
.option(
"--include-datatable-permissions",
"With --include-settings, also sync who may connect to each data table as which role",
)
.option("--include-key", "Include workspace encryption key")
.option(
"--skip-reencrypt-on-key-change",
+75
View File
@@ -0,0 +1,75 @@
import { colors } from "@cliffy/ansi/colors";
import { Confirm } from "@cliffy/prompt/confirm";
import { stringify as yamlStringify } from "yaml";
import { GlobalOptions } from "../../types.ts";
import * as log from "../../core/log.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { yamlParseFile } from "../../utils/yaml.ts";
import * as wmill from "../../../gen/services.gen.ts";
export async function getSettings(
opts: GlobalOptions & { encrypted?: boolean; json?: boolean },
) {
await requireLogin(opts);
const workspace = await resolveWorkspace(opts);
const document = await wmill.getWorkspaceSettingsDocument({
workspace: workspace.workspaceId,
secrets: opts.encrypted ? "encrypted" : "redacted",
});
console.log(
opts.json ? JSON.stringify(document, null, 2) : yamlStringify(document),
);
}
export async function applySettings(
opts: GlobalOptions & { dryRun?: boolean; replace?: boolean; yes?: boolean },
file: string,
) {
await requireLogin(opts);
const workspace = await resolveWorkspace(opts);
const document = await yamlParseFile(file);
if (!document || typeof document !== "object" || Array.isArray(document)) {
throw new Error(`${file} must hold a settings document (a mapping of setting to value)`);
}
const apply = (dry_run: boolean) =>
wmill.applyWorkspaceSettingsDocument({
workspace: workspace.workspaceId,
requestBody: { document, replace: opts.replace ?? false, dry_run },
});
const plan = await apply(true);
for (const warning of plan.warnings ?? []) {
log.warn(warning);
}
if (plan.changes.length === 0) {
log.info(colors.green("Workspace settings are already up to date"));
return;
}
for (const change of plan.changes) {
log.info(
`${colors.bold(change.key)}: ${JSON.stringify(change.before)} -> ${JSON.stringify(change.after)}`,
);
}
if (opts.dryRun) {
return;
}
if (
!opts.yes &&
!(await Confirm.prompt({
message: `Apply ${plan.changes.length} change(s) to workspace ${workspace.workspaceId}?`,
default: true,
}))
) {
return;
}
const result = await apply(false);
for (const warning of result.warnings ?? []) {
log.warn(warning);
}
log.info(
colors.bold.underline.green(
`Applied ${result.changes.length} change(s) to workspace ${workspace.workspaceId}`,
),
);
}
+18 -1
View File
@@ -18,6 +18,7 @@ import { requireLogin } from "../../core/auth.ts";
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
import { mergeWorkspaces } from "./merge.ts";
import { connectSlack, disconnectSlack } from "./slack.ts";
import { applySettings, getSettings } from "./settings.ts";
import * as wmill from "../../../gen/services.gen.ts";
@@ -905,6 +906,22 @@ Arguments (omit both to be prompted interactively):
.description(
"Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push."
)
.action(disconnectSlack as any);
.action(disconnectSlack as any)
.command("get-settings")
.description(
"Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only."
)
.option("--encrypted", "Write secrets encrypted with the workspace key, as 'wmill sync pull' does")
.option("--json", "Output as JSON")
.action(getSettings as any)
.command("apply-settings")
.description(
"Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only."
)
.arguments("<file:string>")
.option("--dry-run", "Only show the changes")
.option("--replace", "Also clear the settings the file omits, as 'wmill sync push' does")
.option("-y --yes", "Apply without asking")
.action(applySettings as any);
export default command;
+5
View File
@@ -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,
+93 -3
View File
@@ -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`
+9
View File
@@ -8889,6 +8889,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-users\` - Include syncing users
- \`--include-groups\` - Include syncing groups
- \`--include-settings\` - Include syncing workspace settings
- \`--include-datatable-permissions\` - With --include-settings, also sync who may connect to each data table as which role
- \`--include-key\` - Include workspace encryption key
- \`--keep-deleted\` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
@@ -8920,6 +8921,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-users\` - Include syncing users
- \`--include-groups\` - Include syncing groups
- \`--include-settings\` - Include syncing workspace settings
- \`--include-datatable-permissions\` - With --include-settings, also sync who may connect to each data table as which role
- \`--include-key\` - Include workspace encryption key
- \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- \`--keep-deleted\` - Do not delete remote items that no longer exist locally. Only adds and updates.
@@ -9113,6 +9115,13 @@ Arguments (omit both to be prompted interactively):
- \`--team-id <team_id:string>\` - Slack team id
- \`--team-name <team_name:string>\` - Slack team name
- \`workspace disconnect-slack\` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push.
- \`workspace get-settings\` - Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only.
- \`--encrypted\` - Write secrets encrypted with the workspace key, as 'wmill sync pull' does
- \`--json\` - Output as JSON
- \`workspace apply-settings <file:string>\` - Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only.
- \`--dry-run\` - Only show the changes
- \`--replace\` - Also clear the settings the file omits, as 'wmill sync push' does
- \`-y --yes\` - Apply without asking
+6 -1
View File
@@ -187,6 +187,8 @@ export interface PushObjOptions {
wsSpecific?: boolean;
/** encryption_key push: non-interactive flag and explicit re-encryption choice */
keyPushOpts?: PushWorkspaceKeyOptions;
/** settings push: whether settings.yaml manages the data tables' roles */
includeDatatablePermissions?: boolean;
/** TypeScript runtime a bare `.ts` denotes, for raw-app runnables */
defaultTs?: "bun" | "deno";
/** schedule push into a fork: the file's `enabled` is the parent's */
@@ -218,6 +220,7 @@ export async function pushObj(
permissionedAsContext,
wsSpecific,
keyPushOpts,
includeDatatablePermissions,
defaultTs,
enabledOwnedByParent,
} = opts;
@@ -285,7 +288,9 @@ export async function pushObj(
} else if (typeEnding === "workspace_dependencies") {
await pushWorkspaceDependencies(workspace, p, befObj, newObj);
} else if (typeEnding === "settings") {
await pushWorkspaceSettings(workspace, p, befObj, newObj);
await pushWorkspaceSettings(workspace, p, befObj, newObj, {
includeDatatablePermissions,
});
} else if (typeEnding === "encryption_key") {
await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts);
} else {