@@ -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 ) ]
3350pub 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
0 commit comments