Skip to content

Commit 77168e3

Browse files
authored
Sync Kinde admin role into global_user.is_admin (ARB-512) (#379)
1 parent c5461ee commit 77168e3

9 files changed

Lines changed: 208 additions & 66 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Split `global_user.is_admin` into two source columns so we can distinguish
2+
-- admin status granted via the Kinde JWT role from admin status granted via
3+
-- the Admin page. The effective `is_admin` becomes a generated column that is
4+
-- the union of the two sources, so existing read sites don't need to change.
5+
--
6+
-- Migration strategy for existing rows: treat the current `is_admin` as an
7+
-- Admin-page grant. On the next authentication, `is_kinde_admin` will be
8+
-- re-synced from the user's JWT, so Kinde admins will also have the Kinde
9+
-- source flag set. This is slightly over-granting on the grant column for
10+
-- existing Kinde admins until they re-auth once, but effective `is_admin`
11+
-- stays true throughout, so nobody silently loses access.
12+
13+
ALTER TABLE "global_user" ADD COLUMN "admin_grant" BOOLEAN NOT NULL DEFAULT FALSE;
14+
ALTER TABLE "global_user" ADD COLUMN "is_kinde_admin" BOOLEAN NOT NULL DEFAULT FALSE;
15+
16+
UPDATE "global_user" SET "admin_grant" = "is_admin";
17+
18+
ALTER TABLE "global_user" DROP COLUMN "is_admin";
19+
ALTER TABLE "global_user"
20+
ADD COLUMN "is_admin" BOOLEAN
21+
GENERATED ALWAYS AS ("is_kinde_admin" OR "admin_grant") VIRTUAL;

backend/src/global_db.rs

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ pub struct GlobalUser {
1616
pub id: i64,
1717
pub kinde_id: String,
1818
pub display_name: String,
19+
/// Effective admin status: `is_kinde_admin OR admin_grant`. Maintained by a `SQLite`
20+
/// generated column, so this is always consistent with the two source fields below.
1921
pub is_admin: bool,
22+
/// True when the user's most recent authentication carried the Kinde admin role.
23+
/// Auto-synced on every auth — flips false when the role is removed in Kinde.
24+
pub is_kinde_admin: bool,
25+
/// True when an existing admin has toggled this user to admin via the Admin page.
26+
/// Only changes through the Admin page toggle endpoint.
27+
pub admin_grant: bool,
2028
pub email: Option<String>,
2129
}
2230

@@ -29,6 +37,15 @@ pub struct CohortInfo {
2937
pub is_read_only: bool,
3038
}
3139

40+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41+
pub enum SetUserAdminResult {
42+
Ok,
43+
UserNotFound,
44+
/// The target still has the Kinde admin role, so the effective `is_admin` cannot
45+
/// drop to false via an Admin-page revoke. The Kinde role must be revoked instead.
46+
BlockedByKindeRole,
47+
}
48+
3249
#[derive(Debug, Clone, Serialize)]
3350
pub struct CohortMember {
3451
pub id: i64,
@@ -80,62 +97,89 @@ impl GlobalDB {
8097

8198
/// Create or find a global user by `kinde_id`. Updates `display_name` and email if changed.
8299
///
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.
104+
///
83105
/// # Errors
84106
/// Returns an error on database failure.
85107
pub async fn ensure_global_user(
86108
&self,
87109
kinde_id: &str,
88110
name: &str,
89111
email: Option<&str>,
112+
is_kinde_admin: bool,
90113
) -> Result<GlobalUser, sqlx::Error> {
91114
// Try to find existing user
92115
let existing = sqlx::query_as::<_, GlobalUser>(
93-
r"SELECT id, kinde_id, display_name, is_admin, email FROM global_user WHERE kinde_id = ?",
116+
r"SELECT id, kinde_id, display_name, is_admin, is_kinde_admin, admin_grant, email
117+
FROM global_user WHERE kinde_id = ?",
94118
)
95119
.bind(kinde_id)
96120
.fetch_optional(&self.pool)
97121
.await?;
98122

99123
if let Some(mut user) = existing {
100-
// Update display_name and email if changed
101-
if user.display_name != name || user.email.as_deref() != email {
102-
sqlx::query("UPDATE global_user SET display_name = ?, email = COALESCE(?, email) WHERE id = ?")
103-
.bind(name)
104-
.bind(email)
105-
.bind(user.id)
106-
.execute(&self.pool)
107-
.await?;
124+
let name_changed = user.display_name != name || user.email.as_deref() != email;
125+
let kinde_admin_changed = user.is_kinde_admin != is_kinde_admin;
126+
if name_changed || kinde_admin_changed {
127+
sqlx::query(
128+
r"UPDATE global_user
129+
SET display_name = ?,
130+
email = COALESCE(?, email),
131+
is_kinde_admin = ?
132+
WHERE id = ?",
133+
)
134+
.bind(name)
135+
.bind(email)
136+
.bind(is_kinde_admin)
137+
.bind(user.id)
138+
.execute(&self.pool)
139+
.await?;
108140
user.display_name = name.to_string();
109141
if email.is_some() {
110142
user.email = email.map(String::from);
111143
}
144+
if kinde_admin_changed {
145+
user.is_kinde_admin = is_kinde_admin;
146+
user.is_admin = is_kinde_admin || user.admin_grant;
147+
}
112148
}
113149
return Ok(user);
114150
}
115151

116152
// Create new user
117153
let id = sqlx::query_scalar::<_, i64>(
118-
r"INSERT INTO global_user (kinde_id, display_name, email) VALUES (?, ?, ?) RETURNING id",
154+
r"INSERT INTO global_user (kinde_id, display_name, email, is_kinde_admin)
155+
VALUES (?, ?, ?, ?) RETURNING id",
119156
)
120157
.bind(kinde_id)
121158
.bind(name)
122159
.bind(email)
160+
.bind(is_kinde_admin)
123161
.fetch_one(&self.pool)
124162
.await?;
125163

126164
Ok(GlobalUser {
127165
id,
128166
kinde_id: kinde_id.to_string(),
129167
display_name: name.to_string(),
130-
is_admin: false,
168+
is_admin: is_kinde_admin,
169+
is_kinde_admin,
170+
admin_grant: false,
131171
email: email.map(String::from),
132172
})
133173
}
134174

135175
/// Find a global user by `kinde_id`, creating one with a placeholder
136-
/// display name if none exists. Unlike `ensure_global_user`, this does NOT
137-
/// update the display name or email of an existing user — use it from
138-
/// code paths that don't yet have a trusted name for the user.
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.
179+
///
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.
139183
///
140184
/// # Errors
141185
/// Returns an error on database failure.
@@ -144,25 +188,39 @@ impl GlobalDB {
144188
kinde_id: &str,
145189
placeholder_name: &str,
146190
email: Option<&str>,
191+
is_kinde_admin: bool,
147192
) -> Result<GlobalUser, sqlx::Error> {
148-
if let Some(user) = self.get_global_user_by_kinde_id(kinde_id).await? {
193+
if let Some(mut user) = self.get_global_user_by_kinde_id(kinde_id).await? {
194+
if user.is_kinde_admin != is_kinde_admin {
195+
sqlx::query("UPDATE global_user SET is_kinde_admin = ? WHERE id = ?")
196+
.bind(is_kinde_admin)
197+
.bind(user.id)
198+
.execute(&self.pool)
199+
.await?;
200+
user.is_kinde_admin = is_kinde_admin;
201+
user.is_admin = is_kinde_admin || user.admin_grant;
202+
}
149203
return Ok(user);
150204
}
151205

152206
let id = sqlx::query_scalar::<_, i64>(
153-
r"INSERT INTO global_user (kinde_id, display_name, email) VALUES (?, ?, ?) RETURNING id",
207+
r"INSERT INTO global_user (kinde_id, display_name, email, is_kinde_admin)
208+
VALUES (?, ?, ?, ?) RETURNING id",
154209
)
155210
.bind(kinde_id)
156211
.bind(placeholder_name)
157212
.bind(email)
213+
.bind(is_kinde_admin)
158214
.fetch_one(&self.pool)
159215
.await?;
160216

161217
Ok(GlobalUser {
162218
id,
163219
kinde_id: kinde_id.to_string(),
164220
display_name: placeholder_name.to_string(),
165-
is_admin: false,
221+
is_admin: is_kinde_admin,
222+
is_kinde_admin,
223+
admin_grant: false,
166224
email: email.map(String::from),
167225
})
168226
}
@@ -176,7 +234,8 @@ impl GlobalDB {
176234
kinde_id: &str,
177235
) -> Result<Option<GlobalUser>, sqlx::Error> {
178236
sqlx::query_as::<_, GlobalUser>(
179-
r"SELECT id, kinde_id, display_name, is_admin, email FROM global_user WHERE kinde_id = ?",
237+
r"SELECT id, kinde_id, display_name, is_admin, is_kinde_admin, admin_grant, email
238+
FROM global_user WHERE kinde_id = ?",
180239
)
181240
.bind(kinde_id)
182241
.fetch_optional(&self.pool)
@@ -478,21 +537,37 @@ impl GlobalDB {
478537
.collect())
479538
}
480539

481-
/// Set a user's admin status.
540+
/// Set a user's Admin-page grant. Returns `Ok(false)` when the caller tried to revoke
541+
/// a user whose effective admin status comes (at least in part) from the Kinde admin
542+
/// role — the caller must revoke the Kinde role upstream instead.
482543
///
483544
/// # Errors
484545
/// Returns an error on database failure.
485546
pub async fn set_user_admin(
486547
&self,
487548
global_user_id: i64,
488-
is_admin: bool,
489-
) -> Result<(), sqlx::Error> {
490-
sqlx::query("UPDATE global_user SET is_admin = ? WHERE id = ?")
491-
.bind(is_admin)
549+
admin_grant: bool,
550+
) -> Result<SetUserAdminResult, sqlx::Error> {
551+
if !admin_grant {
552+
let is_kinde_admin = sqlx::query_scalar::<_, bool>(
553+
r"SELECT is_kinde_admin FROM global_user WHERE id = ?",
554+
)
555+
.bind(global_user_id)
556+
.fetch_optional(&self.pool)
557+
.await?;
558+
match is_kinde_admin {
559+
None => return Ok(SetUserAdminResult::UserNotFound),
560+
Some(true) => return Ok(SetUserAdminResult::BlockedByKindeRole),
561+
Some(false) => {}
562+
}
563+
}
564+
565+
sqlx::query("UPDATE global_user SET admin_grant = ? WHERE id = ?")
566+
.bind(admin_grant)
492567
.bind(global_user_id)
493568
.execute(&self.pool)
494569
.await?;
495-
Ok(())
570+
Ok(SetUserAdminResult::Ok)
496571
}
497572

498573
/// Link a pre-authorized email to a global user. When a user signs up and their email
@@ -539,7 +614,8 @@ impl GlobalDB {
539614
/// Returns an error on database failure.
540615
pub async fn get_all_users(&self) -> Result<Vec<GlobalUser>, sqlx::Error> {
541616
sqlx::query_as::<_, GlobalUser>(
542-
r"SELECT id, kinde_id, display_name, is_admin, email FROM global_user ORDER BY created_at",
617+
r"SELECT id, kinde_id, display_name, is_admin, is_kinde_admin, admin_grant, email
618+
FROM global_user ORDER BY created_at",
543619
)
544620
.fetch_all(&self.pool)
545621
.await

backend/src/handle_socket.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,13 +1238,16 @@ async fn authenticate(
12381238
}
12391239
};
12401240

1241-
// Get or create global user
1241+
// Get or create global user. The Kinde admin role is synced into
1242+
// `global_user.is_admin` so downstream code can rely on a single source of truth.
12421243
let display_name = valid_client.name.as_deref().unwrap_or("Unknown");
1244+
let is_kinde_admin = valid_client.roles.contains(&Role::Admin);
12431245
let global_user = match global_db
12441246
.ensure_global_user(
12451247
&valid_client.id,
12461248
display_name,
12471249
valid_client.email.as_deref(),
1250+
is_kinde_admin,
12481251
)
12491252
.await
12501253
{
@@ -1268,8 +1271,7 @@ async fn authenticate(
12681271
}
12691272
}
12701273

1271-
// Check admin status (Kinde role OR global DB flag)
1272-
let is_admin = valid_client.roles.contains(&Role::Admin) || global_user.is_admin;
1274+
let is_admin = global_user.is_admin;
12731275

12741276
// Check cohort access
12751277
#[allow(unused_mut)]

backend/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ impl AppState {
134134
) -> anyhow::Result<()> {
135135
let legacy_users = cohort_db.get_legacy_kinde_users().await?;
136136
for (account_id, kinde_id, name) in legacy_users {
137-
let global_user = global_db.ensure_global_user(&kinde_id, &name, None).await?;
137+
let global_user = global_db
138+
.ensure_global_user(&kinde_id, &name, None, false)
139+
.await?;
138140
cohort_db
139141
.set_global_user_id(account_id, global_user.id)
140142
.await?;
@@ -169,7 +171,7 @@ impl AppState {
169171
for (account_id, kinde_id, name) in legacy_users {
170172
let global_user = self
171173
.global_db
172-
.ensure_global_user(&kinde_id, &name, None)
174+
.ensure_global_user(&kinde_id, &name, None, false)
173175
.await?;
174176
db.set_global_user_id(account_id, global_user.id).await?;
175177
self.global_db

0 commit comments

Comments
 (0)