Skip to content

Commit 95ff300

Browse files
authored
Stop overwriting global_user display_name on admin REST calls (ARB-515 follow-up) (#386)
1 parent e9d49f7 commit 95ff300

5 files changed

Lines changed: 179 additions & 61 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Backfill display_name for users whose display_name was set to something we now consider
2+
-- sensitive or ugly to show in the UI (the Kinde sub, their email, or the literal "Unknown"
3+
-- fallback from older code paths). Replace each such row with a fresh "Unnamed-XXXX"
4+
-- placeholder, where XXXX is 4 hex characters pulled from SQLite's per-row random blob so
5+
-- two simultaneously-backfilled users remain distinguishable in account lists.
6+
--
7+
-- `randomblob()` is a volatile function in SQLite and is re-evaluated once per updated row,
8+
-- so each row gets its own suffix.
9+
UPDATE "global_user"
10+
SET "display_name" = 'Unnamed-' || substr(hex(randomblob(2)), 1, 4)
11+
WHERE "display_name" = "kinde_id"
12+
OR "display_name" = "email"
13+
OR "display_name" = 'Unknown';

backend/src/auth.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,44 @@ pub struct AccessClaims {
4444

4545
#[derive(Debug, Deserialize)]
4646
struct IdClaims {
47-
pub name: String,
47+
#[serde(default)]
48+
pub name: Option<String>,
49+
#[serde(default)]
50+
pub given_name: Option<String>,
51+
#[serde(default)]
52+
pub family_name: Option<String>,
4853
pub sub: String,
4954
#[serde(default)]
5055
pub email: Option<String>,
5156
}
5257

58+
impl IdClaims {
59+
/// Best-effort display name from `id_token` claims. Prefers `name`, then
60+
/// `given_name` + `family_name` joined, ignoring empty values. Returns
61+
/// `None` when none of these claims yield a non-empty string.
62+
fn resolve_name(&self) -> Option<String> {
63+
if let Some(name) = self.name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
64+
return Some(name.to_owned());
65+
}
66+
let given = self
67+
.given_name
68+
.as_deref()
69+
.map(str::trim)
70+
.filter(|s| !s.is_empty());
71+
let family = self
72+
.family_name
73+
.as_deref()
74+
.map(str::trim)
75+
.filter(|s| !s.is_empty());
76+
match (given, family) {
77+
(Some(g), Some(f)) => Some(format!("{g} {f}")),
78+
(Some(g), None) => Some(g.to_owned()),
79+
(None, Some(f)) => Some(f.to_owned()),
80+
(None, None) => None,
81+
}
82+
}
83+
}
84+
5385
static AUTH_CONFIG: OnceCell<AuthConfig> = OnceCell::new();
5486

5587
#[async_trait]
@@ -166,7 +198,7 @@ pub async fn validate_access_and_id(
166198
id: access_claims.sub,
167199
roles: access_claims.roles,
168200
email: id_claims.as_ref().and_then(|c| c.email.clone()),
169-
name: id_claims.map(|c| c.name),
201+
name: id_claims.as_ref().and_then(IdClaims::resolve_name),
170202
})
171203
}
172204

@@ -200,7 +232,7 @@ pub async fn validate_id_token_for_sub(
200232
anyhow::bail!("sub mismatch");
201233
}
202234
Ok(IdTokenInfo {
203-
name: Some(id_claims.name),
235+
name: id_claims.resolve_name(),
204236
email: id_claims.email,
205237
})
206238
}

backend/src/global_db.rs

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
use std::{env, path::Path};
22

3+
use rand::{distributions::Alphanumeric, Rng};
34
use serde::Serialize;
45
use sqlx::{
56
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous},
67
Connection, FromRow, SqliteConnection, SqlitePool,
78
};
89

10+
/// Generate a user-facing placeholder display name for users whose real name we don't yet
11+
/// know. The Kinde sub and the user's email are both considered sensitive/ugly to expose
12+
/// in the UI, so we deliberately surface neither — callers should use this helper instead
13+
/// of making something up. The 4-character suffix exists purely so two unnamed users in the
14+
/// same account list are distinguishable at a glance.
15+
#[must_use]
16+
pub fn generate_unnamed_placeholder() -> String {
17+
let suffix: String = rand::thread_rng()
18+
.sample_iter(&Alphanumeric)
19+
.take(4)
20+
.map(char::from)
21+
.collect();
22+
format!("Unnamed-{suffix}")
23+
}
24+
925
#[derive(Clone, Debug)]
1026
pub struct GlobalDB {
1127
pool: SqlitePool,
@@ -95,12 +111,16 @@ impl GlobalDB {
95111
Ok(Self { pool })
96112
}
97113

98-
/// Create or find a global user by `kinde_id`. Updates `display_name` and email if changed.
114+
/// Create a new global user, or return the existing one. The `name` parameter is only
115+
/// used on creation — once a row exists, its `display_name` is treated as user-owned and
116+
/// is never overwritten by this function. Manual updates made via
117+
/// [`Self::update_user_display_name`] (by the user themselves or an admin) therefore
118+
/// stick, and subsequent Kinde logins won't silently clobber them. `email` and
119+
/// `is_kinde_admin` are still synced on every call so that email changes and Kinde
120+
/// admin-role revocations propagate.
99121
///
100-
/// `is_kinde_admin` is written verbatim — flipping it false on a later auth correctly
101-
/// revokes admin status if the user is no longer granted via the Admin page. The
102-
/// `admin_grant` column is only touched by [`Self::set_user_admin`], so Admin-page
103-
/// grants survive auth.
122+
/// `admin_grant` is never touched here — it is only set via
123+
/// [`Self::set_user_admin`] from the Admin page.
104124
///
105125
/// # Errors
106126
/// Returns an error on database failure.
@@ -121,24 +141,21 @@ impl GlobalDB {
121141
.await?;
122142

123143
if let Some(mut user) = existing {
124-
let name_changed = user.display_name != name || user.email.as_deref() != email;
144+
let email_changed = email.is_some() && user.email.as_deref() != email;
125145
let kinde_admin_changed = user.is_kinde_admin != is_kinde_admin;
126-
if name_changed || kinde_admin_changed {
146+
if email_changed || kinde_admin_changed {
127147
sqlx::query(
128148
r"UPDATE global_user
129-
SET display_name = ?,
130-
email = COALESCE(?, email),
149+
SET email = COALESCE(?, email),
131150
is_kinde_admin = ?
132151
WHERE id = ?",
133152
)
134-
.bind(name)
135153
.bind(email)
136154
.bind(is_kinde_admin)
137155
.bind(user.id)
138156
.execute(&self.pool)
139157
.await?;
140-
user.display_name = name.to_string();
141-
if email.is_some() {
158+
if email_changed {
142159
user.email = email.map(String::from);
143160
}
144161
if kinde_admin_changed {
@@ -172,21 +189,21 @@ impl GlobalDB {
172189
})
173190
}
174191

175-
/// Find a global user by `kinde_id`, creating one with a placeholder
176-
/// display name if none exists. Unlike [`Self::ensure_global_user`], this
177-
/// does NOT update the display name or email of an existing user — use it
178-
/// from code paths that don't yet have a trusted name for the user.
192+
/// Find a global user by `kinde_id`, creating one with a generated `"Unnamed-XXXX"`
193+
/// placeholder if none exists. Unlike [`Self::ensure_global_user`], this does NOT
194+
/// update the display name or email of an existing user — use it from code paths that
195+
/// don't yet have a trusted name for the user.
179196
///
180-
/// `is_kinde_admin` is still synced (written verbatim) even on the
181-
/// no-name path, so the Kinde admin role is recognised on the very first
182-
/// REST request even before the WS auth flow has populated a real name.
197+
/// The placeholder is generated by [`generate_unnamed_placeholder`] and is deliberately
198+
/// not derived from the Kinde sub or the user's email: exposing either in the UI is
199+
/// considered a privacy/UX regression. `is_kinde_admin` is still synced verbatim so the
200+
/// Kinde admin role is recognised even on a user's first request.
183201
///
184202
/// # Errors
185203
/// Returns an error on database failure.
186204
pub async fn find_or_create_global_user(
187205
&self,
188206
kinde_id: &str,
189-
placeholder_name: &str,
190207
email: Option<&str>,
191208
is_kinde_admin: bool,
192209
) -> Result<GlobalUser, sqlx::Error> {
@@ -203,12 +220,13 @@ impl GlobalDB {
203220
return Ok(user);
204221
}
205222

223+
let placeholder_name = generate_unnamed_placeholder();
206224
let id = sqlx::query_scalar::<_, i64>(
207225
r"INSERT INTO global_user (kinde_id, display_name, email, is_kinde_admin)
208226
VALUES (?, ?, ?, ?) RETURNING id",
209227
)
210228
.bind(kinde_id)
211-
.bind(placeholder_name)
229+
.bind(&placeholder_name)
212230
.bind(email)
213231
.bind(is_kinde_admin)
214232
.fetch_one(&self.pool)
@@ -217,14 +235,41 @@ impl GlobalDB {
217235
Ok(GlobalUser {
218236
id,
219237
kinde_id: kinde_id.to_string(),
220-
display_name: placeholder_name.to_string(),
238+
display_name: placeholder_name,
221239
is_admin: is_kinde_admin,
222240
is_kinde_admin,
223241
admin_grant: false,
224242
email: email.map(String::from),
225243
})
226244
}
227245

246+
/// Look up a global user by `kinde_id` and sync their `is_kinde_admin` flag in place if
247+
/// it differs from the passed value. Returns `None` when no row exists — callers that
248+
/// cannot create the user themselves (e.g. `check_admin`, which has no trusted display
249+
/// name) should treat this as "not authorised" rather than creating a placeholder row.
250+
///
251+
/// # Errors
252+
/// Returns an error on database failure.
253+
pub async fn sync_is_kinde_admin_by_kinde_id(
254+
&self,
255+
kinde_id: &str,
256+
is_kinde_admin: bool,
257+
) -> Result<Option<GlobalUser>, sqlx::Error> {
258+
let Some(mut user) = self.get_global_user_by_kinde_id(kinde_id).await? else {
259+
return Ok(None);
260+
};
261+
if user.is_kinde_admin != is_kinde_admin {
262+
sqlx::query("UPDATE global_user SET is_kinde_admin = ? WHERE id = ?")
263+
.bind(is_kinde_admin)
264+
.bind(user.id)
265+
.execute(&self.pool)
266+
.await?;
267+
user.is_kinde_admin = is_kinde_admin;
268+
user.is_admin = is_kinde_admin || user.admin_grant;
269+
}
270+
Ok(Some(user))
271+
}
272+
228273
/// Get a global user by `kinde_id`.
229274
///
230275
/// # Errors

backend/src/handle_socket.rs

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,17 +1240,36 @@ async fn authenticate(
12401240

12411241
// Get or create global user. The Kinde admin role is synced into
12421242
// `global_user.is_admin` so downstream code can rely on a single source of truth.
1243-
let display_name = valid_client.name.as_deref().unwrap_or("Unknown");
1243+
// When the id_token yields a real display name we call `ensure_global_user`
1244+
// for its create-with-name path; otherwise we fall back to
1245+
// `find_or_create_global_user`, which preserves any previously-populated name
1246+
// and, for brand-new rows, generates an `Unnamed-XXXX` placeholder rather than
1247+
// exposing the Kinde sub or the user's email in the UI.
12441248
let is_kinde_admin = valid_client.roles.contains(&Role::Admin);
1245-
let global_user = match global_db
1246-
.ensure_global_user(
1247-
&valid_client.id,
1248-
display_name,
1249-
valid_client.email.as_deref(),
1250-
is_kinde_admin,
1251-
)
1252-
.await
1249+
let global_user_result = if let Some(display_name) = valid_client
1250+
.name
1251+
.as_deref()
1252+
.map(str::trim)
1253+
.filter(|s| !s.is_empty())
12531254
{
1255+
global_db
1256+
.ensure_global_user(
1257+
&valid_client.id,
1258+
display_name,
1259+
valid_client.email.as_deref(),
1260+
is_kinde_admin,
1261+
)
1262+
.await
1263+
} else {
1264+
global_db
1265+
.find_or_create_global_user(
1266+
&valid_client.id,
1267+
valid_client.email.as_deref(),
1268+
is_kinde_admin,
1269+
)
1270+
.await
1271+
};
1272+
let global_user = match global_user_result {
12541273
Ok(user) => user,
12551274
Err(e) => {
12561275
tracing::error!("Failed to ensure global user: {e}");
@@ -1338,9 +1357,15 @@ async fn authenticate(
13381357
}
13391358
};
13401359

1341-
// Create/find user in cohort DB using global_user_id
1360+
// Create/find user in cohort DB using global_user_id. Use the already-resolved
1361+
// `display_name` from the global user record, which our ensure/find-or-create
1362+
// logic above has populated with the best name available.
13421363
let result = db
1343-
.ensure_user_created_by_global_id(global_user.id, display_name, initial_balance)
1364+
.ensure_user_created_by_global_id(
1365+
global_user.id,
1366+
&global_user.display_name,
1367+
initial_balance,
1368+
)
13441369
.await?;
13451370

13461371
let id = match result {

backend/src/main.rs

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -163,21 +163,24 @@ async fn list_cohorts(
163163
// the JWT role so the Admin-page toggle and admin checks can rely on the
164164
// single effective `is_admin` field.
165165
let is_kinde_admin = claims.roles.contains(&backend::auth::Role::Admin);
166-
let global_user = if let Some(name) = id_token_name.as_deref().filter(|n| !n.is_empty()) {
166+
let global_user = if let Some(name) = id_token_name
167+
.as_deref()
168+
.map(str::trim)
169+
.filter(|n| !n.is_empty())
170+
{
167171
state
168172
.global_db
169173
.ensure_global_user(&claims.sub, name, resolved_email.as_deref(), is_kinde_admin)
170174
.await
171175
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
172176
} else {
177+
// No trusted name from the id_token. Fall back to `find_or_create_global_user`,
178+
// which never clobbers an existing display_name and generates an "Unnamed-XXXX"
179+
// placeholder for brand-new rows — we deliberately avoid exposing either the Kinde
180+
// sub or the user's email in the UI as a display name.
173181
state
174182
.global_db
175-
.find_or_create_global_user(
176-
&claims.sub,
177-
&claims.sub,
178-
resolved_email.as_deref(),
179-
is_kinde_admin,
180-
)
183+
.find_or_create_global_user(&claims.sub, resolved_email.as_deref(), is_kinde_admin)
181184
.await
182185
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
183186
};
@@ -250,20 +253,18 @@ async fn get_active_auction_cohort_name(state: &AppState) -> Option<String> {
250253
// --- Admin Endpoints ---
251254

252255
async fn check_admin(state: &AppState, claims: &AccessClaims) -> Result<(), (StatusCode, String)> {
253-
// Sync the Kinde admin role into `global_user.is_admin` and then read the DB field as the
254-
// single source of truth. `claims.sub` is used as a fallback display_name; the WS auth flow
255-
// will update it with the real name on the next connection.
256+
// Look up the caller's global_user row. We deliberately do NOT create the row here:
257+
// this endpoint has no trusted display name to create with, and users always reach the
258+
// admin API via the main app (which hits `/api/cohorts` first and creates the row
259+
// there). Syncing the Kinde admin role is done in-place so that role revocations in
260+
// Kinde take effect on the next admin call rather than waiting for the next login.
256261
let is_kinde_admin = claims.roles.contains(&backend::auth::Role::Admin);
257262
let global_user = state
258263
.global_db
259-
.ensure_global_user(
260-
&claims.sub,
261-
&claims.sub,
262-
claims.email.as_deref(),
263-
is_kinde_admin,
264-
)
264+
.sync_is_kinde_admin_by_kinde_id(&claims.sub, is_kinde_admin)
265265
.await
266-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
266+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
267+
.ok_or((StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
267268

268269
if global_user.is_admin {
269270
Ok(())
@@ -723,17 +724,19 @@ async fn update_my_display_name(
723724
"Display name cannot be empty".to_string(),
724725
));
725726
}
726-
let is_kinde_admin = claims.roles.contains(&backend::auth::Role::Admin);
727+
// Look up the caller's row instead of creating one here — the main app hits
728+
// `/api/cohorts` before any settings page, and creating a row with `claims.sub`
729+
// as the display name would be a regression in the "name ends up as the Kinde sub"
730+
// bug this patch exists to fix.
727731
let global_user = state
728732
.global_db
729-
.ensure_global_user(
730-
&claims.sub,
731-
&claims.sub,
732-
claims.email.as_deref(),
733-
is_kinde_admin,
734-
)
733+
.get_global_user_by_kinde_id(&claims.sub)
735734
.await
736-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
735+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
736+
.ok_or((
737+
StatusCode::NOT_FOUND,
738+
"User not found; load the app before updating your display name".to_string(),
739+
))?;
737740

738741
state
739742
.global_db

0 commit comments

Comments
 (0)