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:
Ruben Fiszel
2026-04-30 14:02:54 +00:00
committed by GitHub
parent 568d9cc8a0
commit 4483d0cab9
26 changed files with 221 additions and 30 deletions
@@ -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))
}
+43 -1
View File
@@ -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()