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
co-authored by Claude Opus 4.7
parent 568d9cc8a0
commit 4483d0cab9
26 changed files with 221 additions and 30 deletions
@@ -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))
}