mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
fix(workspaces): split get_settings into admin-only + public endpoint (#8990)
* fix: redact GitHub App tokens and Slack OAuth secret for non-admins `GET /workspaces/get_settings` returned the full `git_app_installations` JSONB to any workspace member. That column caches the GitHub App JWT and installation token used by git-sync; the installation token is refreshed on every git-sync action and valid for ~55 minutes, so the value sitting in the DB is essentially always live. Null it out for non-admins, matching the existing `slack_oauth_client_secret` redaction. The tarball export's v2 settings format (added in #8935) included `slack_oauth_client_secret` with no admin gating, regressing the same redaction. Mirror the admin check there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: split get_settings into admin-only + public endpoint Adds `WorkspacePublicSettings` and `GET /workspaces/get_public_settings`, which returns only fields safe for any workspace member to read (workspace_id, slack/teams team identity, mute_critical_alerts, deploy_ui, large_file_storage, datatable). `get_settings` is now admin-only via `require_admin`. Migrates frontend callers: every caller that read non-sensitive fields (deploy_ui on trigger pages, mute_critical_alerts on the root layout, slack team identity for handler pickers, etc.) now uses `getPublicSettings`. The admin-managed settings UI, git-sync admin context, operator settings, checkout polling, and full settings page stay on `getSettings`. This replaces the field-level redactions added in the previous commit: the type system itself defines the public surface, so adding a sensitive column to `workspace_settings` no longer defaults to leaking — it stays out of `WorkspacePublicSettings` unless explicitly added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71eeda25c59d724d6e0c4b2b52078567d6e477a2c62656de1deaef602221edcf"
|
||||
}
|
||||
@@ -83,6 +83,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get_imports/{*importer_path}", get(get_imports))
|
||||
.route("/get_dependents_amounts", post(get_dependents_amounts))
|
||||
.route("/get_settings", get(get_settings))
|
||||
.route("/get_public_settings", get(get_public_settings))
|
||||
.route(
|
||||
"/get_copilot_settings_state",
|
||||
get(get_copilot_settings_state),
|
||||
@@ -279,6 +280,34 @@ pub struct WorkspaceSettings {
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
|
||||
/// member. Adding a field here means it will be readable by every authed user
|
||||
/// in the workspace — anything sensitive (OAuth secrets, GitHub App tokens,
|
||||
/// billing/customer info, integration credentials, etc.) must NOT be added.
|
||||
/// The full `WorkspaceSettings` struct is admin-only via `get_settings`.
|
||||
#[derive(FromRow, Serialize, Debug)]
|
||||
pub struct WorkspacePublicSettings {
|
||||
pub workspace_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slack_team_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slack_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub teams_team_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub teams_team_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub teams_team_guid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mute_critical_alerts: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deploy_ui: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub large_file_storage: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub datatable: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct CopilotSettingsState {
|
||||
pub has_instance_ai_config: bool,
|
||||
@@ -622,6 +651,10 @@ async fn get_settings(
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<WorkspaceSettings> {
|
||||
// Admin-only: this struct contains OAuth secrets, GitHub App tokens, billing
|
||||
// info, and other admin-managed integration credentials. Non-admin callers
|
||||
// should use `get_public_settings`.
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let settings = sqlx::query_as!(
|
||||
WorkspaceSettings,
|
||||
@@ -669,12 +702,46 @@ async fn get_settings(
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
|
||||
|
||||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
async fn get_public_settings(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<WorkspacePublicSettings> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let settings = sqlx::query_as!(
|
||||
WorkspacePublicSettings,
|
||||
r#"
|
||||
SELECT
|
||||
workspace_id,
|
||||
slack_team_id,
|
||||
slack_name,
|
||||
teams_team_id,
|
||||
teams_team_name,
|
||||
teams_team_guid,
|
||||
mute_critical_alerts,
|
||||
deploy_ui,
|
||||
large_file_storage,
|
||||
datatable
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
workspace_id = $1
|
||||
"#,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?;
|
||||
|
||||
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
tx.commit().await?;
|
||||
|
||||
if !authed.is_admin {
|
||||
settings.slack_oauth_client_secret = None;
|
||||
}
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
|
||||
@@ -3027,9 +3027,51 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/WorkspaceInvite"
|
||||
|
||||
/w/{workspace}/workspaces/get_public_settings:
|
||||
get:
|
||||
summary: get public settings
|
||||
description: Returns the subset of workspace settings safe to expose to any workspace member. The full settings struct is admin-only via `getSettings`.
|
||||
operationId: getPublicSettings
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
slack_name:
|
||||
type: string
|
||||
slack_team_id:
|
||||
type: string
|
||||
teams_team_id:
|
||||
type: string
|
||||
teams_team_name:
|
||||
type: string
|
||||
teams_team_guid:
|
||||
type: string
|
||||
large_file_storage:
|
||||
$ref: "#/components/schemas/LargeFileStorage"
|
||||
datatable:
|
||||
$ref: "#/components/schemas/DataTableSettings"
|
||||
deploy_ui:
|
||||
$ref: "#/components/schemas/WorkspaceDeployUISettings"
|
||||
mute_critical_alerts:
|
||||
type: boolean
|
||||
required:
|
||||
- workspace_id
|
||||
|
||||
/w/{workspace}/workspaces/get_settings:
|
||||
get:
|
||||
summary: get settings
|
||||
summary: get settings (admin only)
|
||||
description: Returns the full workspace settings including admin-managed integration credentials. Admin-only — non-admin callers should use `getPublicSettings`.
|
||||
operationId: getSettings
|
||||
tags:
|
||||
- workspace
|
||||
|
||||
@@ -1038,7 +1038,13 @@ pub(crate) async fn tarball_workspace(
|
||||
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(),
|
||||
// Mirror the non-admin redaction in `get_settings`: the OAuth
|
||||
// client secret is admin-only and must not leak via tarball.
|
||||
slack_oauth_client_secret: if authed.is_admin {
|
||||
row.slack_oauth_client_secret.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
serde_json::to_value(settings)
|
||||
.map(|v| serde_json::to_string_pretty(&v).ok())
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
error = undefined
|
||||
diffs = []
|
||||
try {
|
||||
const forkSettings = await WorkspaceService.getSettings({
|
||||
const forkSettings = await WorkspaceService.getPublicSettings({
|
||||
workspace: currentWorkspaceId
|
||||
})
|
||||
const datatables = forkSettings.datatable?.datatables ?? {}
|
||||
@@ -364,7 +364,7 @@
|
||||
const { schemaName, tableName } = drawerChange
|
||||
const newTableDef = sourceSchema[schemaName]?.[tableName]
|
||||
|
||||
const forkSettings = await WorkspaceService.getSettings({
|
||||
const forkSettings = await WorkspaceService.getPublicSettings({
|
||||
workspace: currentWorkspaceId
|
||||
})
|
||||
const datatableConfig = forkSettings.datatable ?? { datatables: {} }
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
const CHANNEL_KEY = 'channel'
|
||||
|
||||
async function loadSlackResources() {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
if (!emptyString(settings.slack_name) && !emptyString(settings.slack_team_id)) {
|
||||
workspaceConnectedToSlack = true
|
||||
slack_team_name = settings.slack_name
|
||||
@@ -124,7 +124,7 @@
|
||||
}
|
||||
|
||||
async function loadTeamsResources() {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
if (!emptyString(settings.teams_team_name) && !emptyString(settings.teams_team_id)) {
|
||||
workspaceConnectedToTeams = true
|
||||
} else {
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
async function checkS3Storage() {
|
||||
try {
|
||||
if ($workspaceStore) {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
|
||||
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
|
||||
s3StorageConfigured = settings.large_file_storage?.s3_resource_path !== undefined
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
let isSlackConnectedWorkspace = $state(false)
|
||||
async function getWorspaceSlackSetting() {
|
||||
const settings = await WorkspaceService.getSettings({
|
||||
const settings = await WorkspaceService.getPublicSettings({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (settings.slack_name) {
|
||||
|
||||
@@ -18,6 +18,6 @@ async function getDeployUiSettingsInner(): Promise<WorkspaceDeployUISettings> {
|
||||
if (!get(enterpriseLicense)) {
|
||||
return ALL_DEPLOYABLE
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: get(workspaceStore)! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: get(workspaceStore)! })
|
||||
return settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
async function loadForkedDatatables() {
|
||||
if (!$workspaceStore) return
|
||||
try {
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
|
||||
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
|
||||
const datatables = settings.datatable?.datatables ?? {}
|
||||
forkedDatatables = Object.entries(datatables)
|
||||
.filter(([_, dt]) => dt.forked_from != null)
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
async function loadCriticalAlertsMuted() {
|
||||
let g_muted = true
|
||||
const ws_muted =
|
||||
(await WorkspaceService.getSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
|
||||
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
|
||||
false
|
||||
|
||||
if ($superadmin) {
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -359,7 +359,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -471,7 +471,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
Reference in New Issue
Block a user