scufflecloud_core/operations/
users.rs

1use argon2::Argon2;
2use argon2::password_hash::{PasswordHasher, SaltString};
3use base64::Engine;
4use core_db_types::models::{
5    MfaRecoveryCode, MfaRecoveryCodeId, MfaTotpCredential, MfaTotpCredentialId, MfaTotpRegistrationSession,
6    MfaWebauthnAuthenticationSession, MfaWebauthnCredential, MfaWebauthnCredentialId, MfaWebauthnRegistrationSession,
7    NewUserEmailRequest, NewUserEmailRequestId, User, UserEmail, UserId,
8};
9use core_db_types::schema::{
10    mfa_recovery_codes, mfa_totp_credentials, mfa_totp_reg_sessions, mfa_webauthn_auth_sessions, mfa_webauthn_credentials,
11    mfa_webauthn_reg_sessions, new_user_email_requests, user_emails, users,
12};
13use core_traits::EmailServiceClient;
14use diesel::{BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
15use diesel_async::RunQueryDsl;
16use ext_traits::{DisplayExt, OptionExt, RequestExt, ResultExt};
17use rand::distributions::DistString;
18use tonic::Code;
19use tonic_types::{ErrorDetails, StatusExt};
20
21use crate::cedar::Action;
22use crate::http_ext::CoreRequestExt;
23use crate::operations::{Operation, OperationDriver};
24use crate::totp::TotpError;
25use crate::{common, totp};
26
27impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::GetUserRequest> {
28    type Principal = User;
29    type Resource = User;
30    type Response = pb::scufflecloud::core::v1::User;
31
32    const ACTION: Action = Action::GetUser;
33
34    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
35        let global = &self.global::<G>()?;
36        let session = self.session_or_err()?;
37        common::get_user_by_id(global, session.user_id).await
38    }
39
40    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
41        let global = &self.global::<G>()?;
42        let user_id: UserId = self
43            .get_ref()
44            .id
45            .parse()
46            .into_tonic_err_with_field_violation("id", "invalid ID")?;
47
48        common::get_user_by_id(global, user_id).await
49    }
50
51    async fn execute(
52        self,
53        _driver: &mut OperationDriver<'_, G>,
54        _principal: Self::Principal,
55        resource: Self::Resource,
56    ) -> Result<Self::Response, tonic::Status> {
57        Ok(resource.into())
58    }
59}
60
61impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::UpdateUserRequest> {
62    type Principal = User;
63    type Resource = User;
64    type Response = pb::scufflecloud::core::v1::User;
65
66    const ACTION: Action = Action::UpdateUser;
67
68    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
69        let global = &self.global::<G>()?;
70        let session = self.session_or_err()?;
71        common::get_user_by_id(global, session.user_id).await
72    }
73
74    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
75        let user_id: UserId = self
76            .get_ref()
77            .id
78            .parse()
79            .into_tonic_err_with_field_violation("id", "invalid ID")?;
80
81        let conn = driver.conn().await?;
82        common::get_user_by_id_in_tx(conn, user_id).await
83    }
84
85    async fn execute(
86        self,
87        driver: &mut OperationDriver<'_, G>,
88        _principal: Self::Principal,
89        mut resource: Self::Resource,
90    ) -> Result<Self::Response, tonic::Status> {
91        let payload = self.into_inner();
92        let conn = driver.conn().await?;
93
94        if let Some(password_update) = payload.password {
95            // Verify password
96            if let Some(password_hash) = &resource.password_hash {
97                common::verify_password(password_hash, &password_update.current_password.require("current_password")?)?;
98            }
99
100            let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
101            let new_hash = Argon2::default()
102                .hash_password(password_update.new_password.as_bytes(), &salt)
103                .into_tonic_internal_err("failed to hash password")?
104                .to_string();
105
106            resource = diesel::update(users::dsl::users)
107                .filter(users::dsl::id.eq(resource.id))
108                .set(users::dsl::password_hash.eq(&new_hash))
109                .returning(User::as_returning())
110                .get_result::<User>(conn)
111                .await
112                .into_tonic_internal_err("failed to update user password")?;
113        }
114
115        if let Some(names_update) = payload.names {
116            resource = diesel::update(users::dsl::users)
117                .filter(users::dsl::id.eq(resource.id))
118                .set((
119                    users::dsl::preferred_name.eq(&names_update.preferred_name),
120                    users::dsl::first_name.eq(&names_update.first_name),
121                    users::dsl::last_name.eq(&names_update.last_name),
122                ))
123                .returning(User::as_returning())
124                .get_result::<User>(conn)
125                .await
126                .into_tonic_internal_err("failed to update user password")?;
127        }
128
129        if let Some(primary_email_update) = payload.primary_email {
130            let email = common::normalize_email(&primary_email_update.primary_email);
131
132            let email = user_emails::dsl::user_emails
133                .filter(
134                    user_emails::dsl::email
135                        .eq(&email)
136                        .and(user_emails::dsl::user_id.eq(resource.id)),
137                )
138                .select(user_emails::dsl::email)
139                .first::<String>(conn)
140                .await
141                .optional()
142                .into_tonic_internal_err("failed to query user email")?
143                .into_tonic_not_found("user email not found")?;
144
145            resource = diesel::update(users::dsl::users)
146                .filter(users::dsl::id.eq(resource.id))
147                .set(users::dsl::primary_email.eq(&email))
148                .returning(User::as_returning())
149                .get_result::<User>(conn)
150                .await
151                .into_tonic_internal_err("failed to update user password")?;
152        }
153
154        Ok(resource.into())
155    }
156}
157
158impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListUserEmailsRequest> {
159    type Principal = User;
160    type Resource = User;
161    type Response = pb::scufflecloud::core::v1::UserEmailsList;
162
163    const ACTION: Action = Action::ListUserEmails;
164
165    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
166        let global = &self.global::<G>()?;
167        let session = self.session_or_err()?;
168        common::get_user_by_id(global, session.user_id).await
169    }
170
171    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
172        let global = &self.global::<G>()?;
173        let user_id: UserId = self
174            .get_ref()
175            .id
176            .parse()
177            .into_tonic_err_with_field_violation("id", "invalid ID")?;
178
179        common::get_user_by_id(global, user_id).await
180    }
181
182    async fn execute(
183        self,
184        _driver: &mut OperationDriver<'_, G>,
185        _principal: Self::Principal,
186        resource: Self::Resource,
187    ) -> Result<Self::Response, tonic::Status> {
188        let global = &self.global::<G>()?;
189        let mut db = global.db().await.into_tonic_internal_err("failed to connect to database")?;
190
191        let emails = user_emails::dsl::user_emails
192            .filter(user_emails::dsl::user_id.eq(resource.id))
193            .select(UserEmail::as_select())
194            .load::<UserEmail>(&mut db)
195            .await
196            .into_tonic_internal_err("failed to query user emails")?;
197
198        Ok(pb::scufflecloud::core::v1::UserEmailsList {
199            emails: emails.into_iter().map(Into::into).collect(),
200        })
201    }
202}
203
204impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateUserEmailRequest> {
205    type Principal = User;
206    type Resource = UserEmail;
207    type Response = ();
208
209    const ACTION: Action = Action::CreateUserEmail;
210
211    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
212        let global = &self.global::<G>()?;
213        let session = self.session_or_err()?;
214        common::get_user_by_id(global, session.user_id).await
215    }
216
217    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
218        let user_id: UserId = self
219            .get_ref()
220            .id
221            .parse()
222            .into_tonic_err_with_field_violation("id", "invalid ID")?;
223
224        Ok(UserEmail {
225            email: common::normalize_email(&self.get_ref().email),
226            user_id,
227            created_at: chrono::Utc::now(),
228        })
229    }
230
231    async fn execute(
232        self,
233        driver: &mut OperationDriver<'_, G>,
234        _principal: Self::Principal,
235        resource: Self::Resource,
236    ) -> Result<Self::Response, tonic::Status> {
237        let global = &self.global::<G>()?;
238
239        // Generate random code
240        let code = common::generate_random_bytes().into_tonic_internal_err("failed to generate registration code")?;
241        let code_base64 = base64::prelude::BASE64_URL_SAFE.encode(code);
242        let conn = driver.conn().await?;
243
244        // Check if email is already registered
245        if user_emails::dsl::user_emails
246            .find(&resource.email)
247            .select(user_emails::dsl::email)
248            .first::<String>(conn)
249            .await
250            .optional()
251            .into_tonic_internal_err("failed to query database")?
252            .is_some()
253        {
254            return Err(tonic::Status::with_error_details(
255                Code::AlreadyExists,
256                "email is already registered",
257                ErrorDetails::new(),
258            ));
259        }
260
261        let user = common::get_user_by_id(global, resource.user_id).await?;
262
263        let timeout = global.timeout_config().new_user_email_request;
264
265        // Create email registration request
266        let registration_request = NewUserEmailRequest {
267            id: NewUserEmailRequestId::new(),
268            user_id: resource.user_id,
269            email: resource.email.clone(),
270            code: code.to_vec(),
271            expires_at: chrono::Utc::now() + timeout,
272        };
273
274        diesel::insert_into(new_user_email_requests::dsl::new_user_email_requests)
275            .values(registration_request)
276            .execute(conn)
277            .await
278            .into_tonic_internal_err("failed to insert email registration request")?;
279
280        // Send email
281        let email = core_emails::add_new_email_email(global.dashboard_origin(), code_base64, timeout)
282            .into_tonic_internal_err("failed to render add new email email")?;
283        let email = common::email_to_pb(global, resource.email.clone(), user.preferred_name, email);
284
285        global
286            .email_service()
287            .send_email(email)
288            .await
289            .into_tonic_internal_err("failed to send add new email email")?;
290
291        Ok(())
292    }
293}
294
295impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteCreateUserEmailRequest> {
296    type Principal = User;
297    type Resource = UserEmail;
298    type Response = pb::scufflecloud::core::v1::UserEmail;
299
300    const ACTION: Action = Action::CreateUserEmail;
301
302    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
303        let global = &self.global::<G>()?;
304        let session = self.session_or_err()?;
305        common::get_user_by_id(global, session.user_id).await
306    }
307
308    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
309        let user_id: UserId = self
310            .get_ref()
311            .id
312            .parse()
313            .into_tonic_err_with_field_violation("id", "invalid ID")?;
314
315        let conn = driver.conn().await?;
316
317        // Delete email registration request
318        let Some(registration_request) = diesel::delete(new_user_email_requests::dsl::new_user_email_requests)
319            .filter(
320                new_user_email_requests::dsl::code
321                    .eq(&self.get_ref().code)
322                    .and(new_user_email_requests::dsl::user_id.eq(user_id))
323                    .and(new_user_email_requests::dsl::expires_at.gt(chrono::Utc::now())),
324            )
325            .returning(NewUserEmailRequest::as_select())
326            .get_result::<NewUserEmailRequest>(conn)
327            .await
328            .optional()
329            .into_tonic_internal_err("failed to delete email registration request")?
330        else {
331            return Err(tonic::Status::with_error_details(
332                Code::NotFound,
333                "unknown code",
334                ErrorDetails::new(),
335            ));
336        };
337
338        // Check if email is already registered
339        if user_emails::dsl::user_emails
340            .find(&registration_request.email)
341            .select(user_emails::dsl::email)
342            .first::<String>(conn)
343            .await
344            .optional()
345            .into_tonic_internal_err("failed to query user emails")?
346            .is_some()
347        {
348            return Err(tonic::Status::with_error_details(
349                Code::AlreadyExists,
350                "email is already registered",
351                ErrorDetails::new(),
352            ));
353        }
354
355        Ok(UserEmail {
356            email: registration_request.email,
357            user_id,
358            created_at: chrono::Utc::now(),
359        })
360    }
361
362    async fn execute(
363        self,
364        driver: &mut OperationDriver<'_, G>,
365        _principal: Self::Principal,
366        resource: Self::Resource,
367    ) -> Result<Self::Response, tonic::Status> {
368        let conn = driver.conn().await?;
369
370        diesel::insert_into(user_emails::dsl::user_emails)
371            .values(&resource)
372            .execute(conn)
373            .await
374            .into_tonic_internal_err("failed to insert user email")?;
375
376        Ok(resource.into())
377    }
378}
379
380impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteUserEmailRequest> {
381    type Principal = User;
382    type Resource = UserEmail;
383    type Response = pb::scufflecloud::core::v1::UserEmail;
384
385    const ACTION: Action = Action::DeleteUserEmail;
386
387    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
388        let global = &self.global::<G>()?;
389        let session = self.session_or_err()?;
390        common::get_user_by_id(global, session.user_id).await
391    }
392
393    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
394        let user_id: UserId = self
395            .get_ref()
396            .id
397            .parse()
398            .into_tonic_err_with_field_violation("id", "invalid ID")?;
399
400        let conn = driver.conn().await?;
401
402        let user_email = user_emails::dsl::user_emails
403            .filter(
404                user_emails::dsl::user_id
405                    .eq(user_id)
406                    .and(user_emails::dsl::email.eq(&self.get_ref().email)),
407            )
408            .select(UserEmail::as_select())
409            .first::<UserEmail>(conn)
410            .await
411            .into_tonic_internal_err("failed to delete user email")?;
412
413        Ok(user_email)
414    }
415
416    async fn execute(
417        self,
418        driver: &mut OperationDriver<'_, G>,
419        _principal: Self::Principal,
420        resource: Self::Resource,
421    ) -> Result<Self::Response, tonic::Status> {
422        let conn = driver.conn().await?;
423
424        diesel::delete(user_emails::dsl::user_emails)
425            .filter(user_emails::dsl::email.eq(&resource.email))
426            .execute(conn)
427            .await
428            .into_tonic_internal_err("failed to delete user email")?;
429
430        Ok(resource.into())
431    }
432}
433
434impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateWebauthnCredentialRequest> {
435    type Principal = User;
436    type Resource = User;
437    type Response = pb::scufflecloud::core::v1::CreateWebauthnCredentialResponse;
438
439    const ACTION: Action = Action::CreateWebauthnCredential;
440
441    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
442        let global = &self.global::<G>()?;
443        let session = self.session_or_err()?;
444        common::get_user_by_id(global, session.user_id).await
445    }
446
447    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
448        let user_id: UserId = self
449            .get_ref()
450            .id
451            .parse()
452            .into_tonic_err_with_field_violation("id", "invalid ID")?;
453
454        let conn = driver.conn().await?;
455        common::get_user_by_id_in_tx(conn, user_id).await
456    }
457
458    async fn execute(
459        self,
460        driver: &mut OperationDriver<'_, G>,
461        _principal: Self::Principal,
462        resource: Self::Resource,
463    ) -> Result<Self::Response, tonic::Status> {
464        let global = &self.global::<G>()?;
465
466        let conn = driver.conn().await?;
467        let exclude_credentials: Vec<_> = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
468            .filter(mfa_webauthn_credentials::dsl::user_id.eq(resource.id))
469            .select(mfa_webauthn_credentials::dsl::credential_id)
470            .load::<Vec<u8>>(conn)
471            .await
472            .into_tonic_internal_err("failed to query webauthn credentials")?
473            .into_iter()
474            .map(webauthn_rs::prelude::CredentialID::from)
475            .collect();
476
477        let user_name = resource.primary_email.unwrap_or(resource.id.to_string());
478        let user_display_name = resource.preferred_name.or_else(|| {
479            if let (Some(first_name), Some(last_name)) = (resource.first_name, resource.last_name) {
480                Some(format!("{} {}", first_name, last_name))
481            } else {
482                None
483            }
484        });
485
486        let (response, state) = global
487            .webauthn()
488            .start_passkey_registration(
489                resource.id.into(),
490                &user_name,
491                user_display_name.as_ref().unwrap_or(&user_name),
492                Some(exclude_credentials),
493            )
494            .into_tonic_internal_err("failed to start webauthn registration")?;
495
496        let reg_session = MfaWebauthnRegistrationSession {
497            user_id: resource.id,
498            state: serde_json::to_value(&state).into_tonic_internal_err("failed to serialize webauthn state")?,
499            expires_at: chrono::Utc::now() + global.timeout_config().mfa,
500        };
501
502        let options_json =
503            serde_json::to_string(&response).into_tonic_internal_err("failed to serialize webauthn options")?;
504
505        diesel::insert_into(mfa_webauthn_reg_sessions::dsl::mfa_webauthn_reg_sessions)
506            .values(&reg_session)
507            .on_conflict(mfa_webauthn_reg_sessions::dsl::user_id)
508            .do_update()
509            .set((
510                mfa_webauthn_reg_sessions::dsl::state.eq(&reg_session.state),
511                mfa_webauthn_reg_sessions::dsl::expires_at.eq(&reg_session.expires_at),
512            ))
513            .execute(conn)
514            .await
515            .into_tonic_internal_err("failed to insert webauthn registration session")?;
516
517        Ok(pb::scufflecloud::core::v1::CreateWebauthnCredentialResponse { options_json })
518    }
519}
520
521impl<G: core_traits::Global> Operation<G>
522    for tonic::Request<pb::scufflecloud::core::v1::CompleteCreateWebauthnCredentialRequest>
523{
524    type Principal = User;
525    type Resource = MfaWebauthnCredential;
526    type Response = pb::scufflecloud::core::v1::WebauthnCredential;
527
528    const ACTION: Action = Action::CompleteCreateWebauthnCredential;
529
530    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
531        let global = &self.global::<G>()?;
532        let session = self.session_or_err()?;
533        common::get_user_by_id(global, session.user_id).await
534    }
535
536    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
537        let global = &self.global::<G>()?;
538
539        let user_id: UserId = self
540            .get_ref()
541            .id
542            .parse()
543            .into_tonic_err_with_field_violation("id", "invalid ID")?;
544
545        let reg = serde_json::from_str(&self.get_ref().response_json)
546            .into_tonic_err_with_field_violation("response_json", "invalid register public key credential")?;
547
548        let conn = driver.conn().await?;
549        let state = diesel::delete(mfa_webauthn_reg_sessions::dsl::mfa_webauthn_reg_sessions)
550            .filter(
551                mfa_webauthn_reg_sessions::dsl::user_id
552                    .eq(user_id)
553                    .and(mfa_webauthn_reg_sessions::dsl::expires_at.gt(chrono::Utc::now())),
554            )
555            .returning(mfa_webauthn_reg_sessions::dsl::state)
556            .get_result::<serde_json::Value>(conn)
557            .await
558            .optional()
559            .into_tonic_internal_err("failed to query webauthn registration session")?
560            .into_tonic_err(
561                tonic::Code::FailedPrecondition,
562                "no webauthn registration session found",
563                ErrorDetails::new(),
564            )?;
565
566        let state: webauthn_rs::prelude::PasskeyRegistration =
567            serde_json::from_value(state).into_tonic_internal_err("failed to deserialize webauthn state")?;
568
569        let credential = global
570            .webauthn()
571            .finish_passkey_registration(&reg, &state)
572            .into_tonic_internal_err("failed to finish webauthn registration")?;
573
574        Ok(MfaWebauthnCredential {
575            id: MfaWebauthnCredentialId::new(),
576            user_id,
577            name: self.get_ref().name.clone(),
578            credential_id: credential.cred_id().to_vec(),
579            credential: serde_json::to_value(credential).into_tonic_internal_err("failed to serialize credential")?,
580            counter: None,
581            last_used_at: chrono::Utc::now(),
582        })
583    }
584
585    async fn execute(
586        self,
587        driver: &mut OperationDriver<'_, G>,
588        _principal: Self::Principal,
589        resource: Self::Resource,
590    ) -> Result<Self::Response, tonic::Status> {
591        let conn = driver.conn().await?;
592        diesel::insert_into(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
593            .values(&resource)
594            .execute(conn)
595            .await
596            .into_tonic_internal_err("failed to insert webauthn credential")?;
597
598        Ok(resource.into())
599    }
600}
601
602impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListWebauthnCredentialsRequest> {
603    type Principal = User;
604    type Resource = User;
605    type Response = pb::scufflecloud::core::v1::WebauthnCredentialsList;
606
607    const ACTION: Action = Action::ListWebauthnCredentials;
608
609    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
610        let global = &self.global::<G>()?;
611        let session = self.session_or_err()?;
612        common::get_user_by_id(global, session.user_id).await
613    }
614
615    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
616        let global = &self.global::<G>()?;
617        let user_id: UserId = self
618            .get_ref()
619            .id
620            .parse()
621            .into_tonic_err_with_field_violation("id", "invalid ID")?;
622        common::get_user_by_id(global, user_id).await
623    }
624
625    async fn execute(
626        self,
627        _driver: &mut OperationDriver<'_, G>,
628        _principal: Self::Principal,
629        resource: Self::Resource,
630    ) -> Result<Self::Response, tonic::Status> {
631        let global = &self.global::<G>()?;
632        let mut db = global.db().await.into_tonic_internal_err("failed to connect to database")?;
633
634        let credentials = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
635            .filter(mfa_webauthn_credentials::dsl::user_id.eq(resource.id))
636            .select(MfaWebauthnCredential::as_select())
637            .load::<MfaWebauthnCredential>(&mut db)
638            .await
639            .into_tonic_internal_err("failed to query webauthn credentials")?;
640
641        Ok(pb::scufflecloud::core::v1::WebauthnCredentialsList {
642            credentials: credentials.into_iter().map(Into::into).collect(),
643        })
644    }
645}
646
647impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::UpdateWebauthnCredentialRequest> {
648    type Principal = User;
649    type Resource = MfaWebauthnCredential;
650    type Response = pb::scufflecloud::core::v1::WebauthnCredential;
651
652    const ACTION: Action = Action::UpdateWebauthnCredential;
653
654    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
655        let global = &self.global::<G>()?;
656        let session = self.session_or_err()?;
657        common::get_user_by_id(global, session.user_id).await
658    }
659
660    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
661        let user_id: UserId = self
662            .get_ref()
663            .user_id
664            .parse()
665            .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
666
667        let credential_id: MfaWebauthnCredentialId = self
668            .get_ref()
669            .id
670            .parse()
671            .into_tonic_err_with_field_violation("id", "invalid ID")?;
672
673        let conn = driver.conn().await?;
674        let credential = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
675            .filter(
676                mfa_webauthn_credentials::dsl::id
677                    .eq(credential_id)
678                    .and(mfa_webauthn_credentials::dsl::user_id.eq(user_id)),
679            )
680            .select(MfaWebauthnCredential::as_select())
681            .first::<MfaWebauthnCredential>(conn)
682            .await
683            .into_tonic_internal_err("failed to find webauthn credential")?;
684
685        Ok(credential)
686    }
687
688    async fn execute(
689        self,
690        driver: &mut OperationDriver<'_, G>,
691        _principal: Self::Principal,
692        resource: Self::Resource,
693    ) -> Result<Self::Response, tonic::Status> {
694        let conn = driver.conn().await?;
695
696        let updated_credential = if let Some(name) = &self.get_ref().name {
697            diesel::update(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
698                .filter(mfa_webauthn_credentials::dsl::id.eq(resource.id))
699                .set(mfa_webauthn_credentials::dsl::name.eq(name))
700                .returning(MfaWebauthnCredential::as_returning())
701                .get_result::<MfaWebauthnCredential>(conn)
702                .await
703                .into_tonic_internal_err("failed to update webauthn credential")?
704        } else {
705            resource
706        };
707
708        Ok(updated_credential.into())
709    }
710}
711
712impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteWebauthnCredentialRequest> {
713    type Principal = User;
714    type Resource = MfaWebauthnCredential;
715    type Response = pb::scufflecloud::core::v1::WebauthnCredential;
716
717    const ACTION: Action = Action::DeleteWebauthnCredential;
718
719    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
720        let global = &self.global::<G>()?;
721        let session = self.session_or_err()?;
722        common::get_user_by_id(global, session.user_id).await
723    }
724
725    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
726        let user_id: UserId = self
727            .get_ref()
728            .user_id
729            .parse()
730            .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
731
732        let credential_id: MfaWebauthnCredentialId = self
733            .get_ref()
734            .id
735            .parse()
736            .into_tonic_err_with_field_violation("id", "invalid ID")?;
737
738        let conn = driver.conn().await?;
739        let credential = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
740            .filter(
741                mfa_webauthn_credentials::dsl::id
742                    .eq(credential_id)
743                    .and(mfa_webauthn_credentials::dsl::user_id.eq(user_id)),
744            )
745            .select(MfaWebauthnCredential::as_select())
746            .first::<MfaWebauthnCredential>(conn)
747            .await
748            .into_tonic_internal_err("failed to delete webauthn credential")?;
749
750        Ok(credential)
751    }
752
753    async fn execute(
754        self,
755        driver: &mut OperationDriver<'_, G>,
756        _principal: Self::Principal,
757        resource: Self::Resource,
758    ) -> Result<Self::Response, tonic::Status> {
759        let conn = driver.conn().await?;
760        diesel::delete(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
761            .filter(mfa_webauthn_credentials::dsl::id.eq(resource.id))
762            .execute(conn)
763            .await
764            .into_tonic_internal_err("failed to delete webauthn credential")?;
765
766        Ok(resource.into())
767    }
768}
769
770impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateWebauthnChallengeRequest> {
771    type Principal = User;
772    type Resource = User;
773    type Response = pb::scufflecloud::core::v1::WebauthnChallenge;
774
775    const ACTION: Action = Action::CreateWebauthnChallenge;
776
777    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
778        let global = &self.global::<G>()?;
779        let session = self.session_or_err()?;
780        common::get_user_by_id(global, session.user_id).await
781    }
782
783    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
784        let global = &self.global::<G>()?;
785        let user_id: UserId = self
786            .get_ref()
787            .id
788            .parse()
789            .into_tonic_err_with_field_violation("id", "invalid ID")?;
790        common::get_user_by_id(global, user_id).await
791    }
792
793    async fn execute(
794        self,
795        driver: &mut OperationDriver<'_, G>,
796        _principal: Self::Principal,
797        resource: Self::Resource,
798    ) -> Result<Self::Response, tonic::Status> {
799        let global = &self.global::<G>()?;
800
801        let conn = driver.conn().await?;
802        let credentials = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
803            .filter(mfa_webauthn_credentials::dsl::user_id.eq(resource.id))
804            .select(mfa_webauthn_credentials::dsl::credential)
805            .load::<serde_json::Value>(conn)
806            .await
807            .into_tonic_internal_err("failed to query webauthn credentials")?
808            .into_iter()
809            .map(serde_json::from_value)
810            .collect::<Result<Vec<webauthn_rs::prelude::Passkey>, _>>()
811            .into_tonic_internal_err("failed to deserialize webauthn credentials")?;
812
813        let (response, state) = global
814            .webauthn()
815            .start_passkey_authentication(&credentials)
816            .into_tonic_internal_err("failed to start webauthn authentication")?;
817
818        let auth_session = MfaWebauthnAuthenticationSession {
819            user_id: resource.id,
820            state: serde_json::to_value(&state).into_tonic_internal_err("failed to serialize webauthn state")?,
821            expires_at: chrono::Utc::now() + global.timeout_config().mfa,
822        };
823
824        let options_json =
825            serde_json::to_string(&response).into_tonic_internal_err("failed to serialize webauthn options")?;
826
827        diesel::insert_into(mfa_webauthn_auth_sessions::dsl::mfa_webauthn_auth_sessions)
828            .values(&auth_session)
829            .on_conflict(mfa_webauthn_auth_sessions::dsl::user_id)
830            .do_update()
831            .set((
832                mfa_webauthn_auth_sessions::dsl::state.eq(&auth_session.state),
833                mfa_webauthn_auth_sessions::dsl::expires_at.eq(&auth_session.expires_at),
834            ))
835            .execute(conn)
836            .await
837            .into_tonic_internal_err("failed to insert webauthn authentication session")?;
838
839        Ok(pb::scufflecloud::core::v1::WebauthnChallenge { options_json })
840    }
841}
842
843impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateTotpCredentialRequest> {
844    type Principal = User;
845    type Resource = User;
846    type Response = pb::scufflecloud::core::v1::CreateTotpCredentialResponse;
847
848    const ACTION: Action = Action::CreateTotpCredential;
849
850    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
851        let global = &self.global::<G>()?;
852        let session = self.session_or_err()?;
853        common::get_user_by_id(global, session.user_id).await
854    }
855
856    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
857        let user_id: UserId = self
858            .get_ref()
859            .id
860            .parse()
861            .into_tonic_err_with_field_violation("id", "invalid ID")?;
862
863        let conn = driver.conn().await?;
864        common::get_user_by_id_in_tx(conn, user_id).await
865    }
866
867    async fn execute(
868        self,
869        driver: &mut OperationDriver<'_, G>,
870        _principal: Self::Principal,
871        resource: Self::Resource,
872    ) -> Result<Self::Response, tonic::Status> {
873        let global = &self.global::<G>()?;
874
875        let totp = totp::new_token(resource.primary_email.unwrap_or(resource.id.to_string()))
876            .into_tonic_internal_err("failed to generate TOTP token")?;
877
878        let response = pb::scufflecloud::core::v1::CreateTotpCredentialResponse {
879            secret_url: totp.get_url(),
880            secret_qrcode_png: totp.get_qr_png().into_tonic_internal_err("failed to generate TOTP QR code")?,
881        };
882
883        let reg_session = MfaTotpRegistrationSession {
884            user_id: resource.id,
885            secret: totp.secret,
886            expires_at: chrono::Utc::now() + global.timeout_config().mfa,
887        };
888
889        let conn = driver.conn().await?;
890        diesel::insert_into(mfa_totp_reg_sessions::dsl::mfa_totp_reg_sessions)
891            .values(&reg_session)
892            .on_conflict(mfa_totp_reg_sessions::dsl::user_id)
893            .do_update()
894            .set((
895                mfa_totp_reg_sessions::dsl::secret.eq(&reg_session.secret),
896                mfa_totp_reg_sessions::dsl::expires_at.eq(reg_session.expires_at),
897            ))
898            .execute(conn)
899            .await
900            .into_tonic_internal_err("failed to insert TOTP registration session")?;
901
902        Ok(response)
903    }
904}
905
906impl<G: core_traits::Global> Operation<G>
907    for tonic::Request<pb::scufflecloud::core::v1::CompleteCreateTotpCredentialRequest>
908{
909    type Principal = User;
910    type Resource = MfaTotpCredential;
911    type Response = pb::scufflecloud::core::v1::TotpCredential;
912
913    const ACTION: Action = Action::CompleteCreateTotpCredential;
914
915    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
916        let global = &self.global::<G>()?;
917        let session = self.session_or_err()?;
918        common::get_user_by_id(global, session.user_id).await
919    }
920
921    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
922        let user_id: UserId = self
923            .get_ref()
924            .id
925            .parse()
926            .into_tonic_err_with_field_violation("id", "invalid ID")?;
927
928        let conn = driver.conn().await?;
929        let secret = mfa_totp_reg_sessions::dsl::mfa_totp_reg_sessions
930            .find(user_id)
931            .filter(mfa_totp_reg_sessions::dsl::expires_at.gt(chrono::Utc::now()))
932            .select(mfa_totp_reg_sessions::dsl::secret)
933            .first::<Vec<u8>>(conn)
934            .await
935            .optional()
936            .into_tonic_internal_err("failed to query TOTP registration session")?
937            .into_tonic_err(
938                tonic::Code::FailedPrecondition,
939                "no TOTP registration session found",
940                ErrorDetails::new(),
941            )?;
942
943        match totp::verify_token(secret.clone(), &self.get_ref().code) {
944            Ok(()) => {}
945            Err(TotpError::InvalidToken) => {
946                return Err(TotpError::InvalidToken.into_tonic_err_with_field_violation("code", "invalid TOTP token"));
947            }
948            Err(e) => return Err(e.into_tonic_internal_err("failed to verify TOTP token")),
949        }
950
951        Ok(MfaTotpCredential {
952            id: MfaTotpCredentialId::new(),
953            user_id,
954            name: self.get_ref().name.clone(),
955            secret,
956            last_used_at: chrono::Utc::now(),
957        })
958    }
959
960    async fn execute(
961        self,
962        driver: &mut OperationDriver<'_, G>,
963        _principal: Self::Principal,
964        resource: Self::Resource,
965    ) -> Result<Self::Response, tonic::Status> {
966        let conn = driver.conn().await?;
967        diesel::insert_into(mfa_totp_credentials::dsl::mfa_totp_credentials)
968            .values(&resource)
969            .execute(conn)
970            .await
971            .into_tonic_internal_err("failed to insert TOTP credential")?;
972
973        Ok(resource.into())
974    }
975}
976
977impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListTotpCredentialsRequest> {
978    type Principal = User;
979    type Resource = User;
980    type Response = pb::scufflecloud::core::v1::TotpCredentialsList;
981
982    const ACTION: Action = Action::ListTotpCredentials;
983
984    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
985        let global = &self.global::<G>()?;
986        let session = self.session_or_err()?;
987        common::get_user_by_id(global, session.user_id).await
988    }
989
990    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
991        let global = &self.global::<G>()?;
992        let user_id: UserId = self
993            .get_ref()
994            .id
995            .parse()
996            .into_tonic_err_with_field_violation("id", "invalid ID")?;
997        common::get_user_by_id(global, user_id).await
998    }
999
1000    async fn execute(
1001        self,
1002        _driver: &mut OperationDriver<'_, G>,
1003        _principal: Self::Principal,
1004        resource: Self::Resource,
1005    ) -> Result<Self::Response, tonic::Status> {
1006        let global = &self.global::<G>()?;
1007        let mut db = global.db().await.into_tonic_internal_err("failed to connect to database")?;
1008
1009        let credentials = mfa_totp_credentials::dsl::mfa_totp_credentials
1010            .filter(mfa_totp_credentials::dsl::user_id.eq(resource.id))
1011            .select(MfaTotpCredential::as_select())
1012            .load::<MfaTotpCredential>(&mut db)
1013            .await
1014            .into_tonic_internal_err("failed to query TOTP credentials")?;
1015
1016        Ok(pb::scufflecloud::core::v1::TotpCredentialsList {
1017            credentials: credentials.into_iter().map(Into::into).collect(),
1018        })
1019    }
1020}
1021
1022impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::UpdateTotpCredentialRequest> {
1023    type Principal = User;
1024    type Resource = MfaTotpCredential;
1025    type Response = pb::scufflecloud::core::v1::TotpCredential;
1026
1027    const ACTION: Action = Action::UpdateTotpCredential;
1028
1029    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
1030        let global = &self.global::<G>()?;
1031        let session = self.session_or_err()?;
1032        common::get_user_by_id(global, session.user_id).await
1033    }
1034
1035    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
1036        let user_id: UserId = self
1037            .get_ref()
1038            .user_id
1039            .parse()
1040            .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
1041
1042        let credential_id: MfaTotpCredentialId = self
1043            .get_ref()
1044            .id
1045            .parse()
1046            .into_tonic_err_with_field_violation("id", "invalid ID")?;
1047
1048        let conn = driver.conn().await?;
1049        let credential = mfa_totp_credentials::dsl::mfa_totp_credentials
1050            .filter(
1051                mfa_totp_credentials::dsl::id
1052                    .eq(credential_id)
1053                    .and(mfa_totp_credentials::dsl::user_id.eq(user_id)),
1054            )
1055            .select(MfaTotpCredential::as_select())
1056            .first::<MfaTotpCredential>(conn)
1057            .await
1058            .into_tonic_internal_err("failed to find webauthn credential")?;
1059
1060        Ok(credential)
1061    }
1062
1063    async fn execute(
1064        self,
1065        driver: &mut OperationDriver<'_, G>,
1066        _principal: Self::Principal,
1067        resource: Self::Resource,
1068    ) -> Result<Self::Response, tonic::Status> {
1069        let conn = driver.conn().await?;
1070
1071        let updated_credential = if let Some(name) = &self.get_ref().name {
1072            diesel::update(mfa_totp_credentials::dsl::mfa_totp_credentials)
1073                .filter(mfa_totp_credentials::dsl::id.eq(resource.id))
1074                .set(mfa_totp_credentials::dsl::name.eq(name))
1075                .returning(MfaTotpCredential::as_returning())
1076                .get_result::<MfaTotpCredential>(conn)
1077                .await
1078                .into_tonic_internal_err("failed to update webauthn credential")?
1079        } else {
1080            resource
1081        };
1082
1083        Ok(updated_credential.into())
1084    }
1085}
1086
1087impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteTotpCredentialRequest> {
1088    type Principal = User;
1089    type Resource = MfaTotpCredential;
1090    type Response = pb::scufflecloud::core::v1::TotpCredential;
1091
1092    const ACTION: Action = Action::DeleteTotpCredential;
1093
1094    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
1095        let global = &self.global::<G>()?;
1096        let session = self.session_or_err()?;
1097        common::get_user_by_id(global, session.user_id).await
1098    }
1099
1100    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
1101        let user_id: UserId = self
1102            .get_ref()
1103            .user_id
1104            .parse()
1105            .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
1106
1107        let credential_id: MfaTotpCredentialId = self
1108            .get_ref()
1109            .id
1110            .parse()
1111            .into_tonic_err_with_field_violation("id", "invalid ID")?;
1112
1113        let conn = driver.conn().await?;
1114        let credential = mfa_totp_credentials::dsl::mfa_totp_credentials
1115            .filter(
1116                mfa_totp_credentials::dsl::id
1117                    .eq(credential_id)
1118                    .and(mfa_totp_credentials::dsl::user_id.eq(user_id)),
1119            )
1120            .select(MfaTotpCredential::as_select())
1121            .first::<MfaTotpCredential>(conn)
1122            .await
1123            .into_tonic_internal_err("failed to delete TOTP credential")?;
1124
1125        Ok(credential)
1126    }
1127
1128    async fn execute(
1129        self,
1130        driver: &mut OperationDriver<'_, G>,
1131        _principal: Self::Principal,
1132        resource: Self::Resource,
1133    ) -> Result<Self::Response, tonic::Status> {
1134        let conn = driver.conn().await?;
1135        diesel::delete(mfa_totp_credentials::dsl::mfa_totp_credentials)
1136            .filter(mfa_totp_credentials::dsl::id.eq(resource.id))
1137            .execute(conn)
1138            .await
1139            .into_tonic_internal_err("failed to delete TOTP credential")?;
1140
1141        Ok(resource.into())
1142    }
1143}
1144
1145impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::RegenerateRecoveryCodesRequest> {
1146    type Principal = User;
1147    type Resource = User;
1148    type Response = pb::scufflecloud::core::v1::RecoveryCodes;
1149
1150    const ACTION: Action = Action::RegenerateRecoveryCodes;
1151
1152    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
1153        let global = &self.global::<G>()?;
1154        let session = self.session_or_err()?;
1155        common::get_user_by_id(global, session.user_id).await
1156    }
1157
1158    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
1159        let global = &self.global::<G>()?;
1160        let user_id: UserId = self
1161            .get_ref()
1162            .id
1163            .parse()
1164            .into_tonic_err_with_field_violation("id", "invalid ID")?;
1165        common::get_user_by_id(global, user_id).await
1166    }
1167
1168    async fn execute(
1169        self,
1170        driver: &mut OperationDriver<'_, G>,
1171        _principal: Self::Principal,
1172        resource: Self::Resource,
1173    ) -> Result<Self::Response, tonic::Status> {
1174        let mut rng = rand::rngs::OsRng;
1175        let codes: Vec<_> = (0..12)
1176            .map(|_| rand::distributions::Alphanumeric.sample_string(&mut rng, 8))
1177            .collect();
1178
1179        let argon2 = Argon2::default();
1180        let recovery_codes = codes
1181            .iter()
1182            .map(|code| {
1183                let salt = SaltString::generate(&mut rng);
1184                argon2.hash_password(code.as_bytes(), &salt).map(|hash| hash.to_string())
1185            })
1186            .map(|code_hash| {
1187                code_hash.map(|code_hash| MfaRecoveryCode {
1188                    id: MfaRecoveryCodeId::new(),
1189                    user_id: resource.id,
1190                    code_hash,
1191                })
1192            })
1193            .collect::<Result<Vec<_>, _>>()
1194            .into_tonic_internal_err("failed to generate recovery codes")?;
1195
1196        let conn = driver.conn().await?;
1197        diesel::delete(mfa_recovery_codes::dsl::mfa_recovery_codes)
1198            .filter(mfa_recovery_codes::dsl::user_id.eq(resource.id))
1199            .execute(conn)
1200            .await
1201            .into_tonic_internal_err("failed to delete existing recovery codes")?;
1202
1203        diesel::insert_into(mfa_recovery_codes::dsl::mfa_recovery_codes)
1204            .values(recovery_codes)
1205            .execute(conn)
1206            .await
1207            .into_tonic_internal_err("failed to insert new recovery codes")?;
1208
1209        Ok(pb::scufflecloud::core::v1::RecoveryCodes { codes })
1210    }
1211}
1212
1213impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteUserRequest> {
1214    type Principal = User;
1215    type Resource = User;
1216    type Response = pb::scufflecloud::core::v1::User;
1217
1218    const ACTION: Action = Action::DeleteUser;
1219
1220    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
1221        let global = &self.global::<G>()?;
1222        let session = self.session_or_err()?;
1223        common::get_user_by_id(global, session.user_id).await
1224    }
1225
1226    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
1227        let user_id: UserId = self
1228            .get_ref()
1229            .id
1230            .parse()
1231            .into_tonic_err_with_field_violation("id", "invalid ID")?;
1232
1233        let conn = driver.conn().await?;
1234        common::get_user_by_id_in_tx(conn, user_id).await
1235    }
1236
1237    async fn execute(
1238        self,
1239        driver: &mut OperationDriver<'_, G>,
1240        _principal: Self::Principal,
1241        resource: Self::Resource,
1242    ) -> Result<Self::Response, tonic::Status> {
1243        let conn = driver.conn().await?;
1244
1245        diesel::delete(users::dsl::users)
1246            .filter(users::dsl::id.eq(resource.id))
1247            .execute(conn)
1248            .await
1249            .into_tonic_internal_err("failed to delete webauthn credential")?;
1250
1251        Ok(resource.into())
1252    }
1253}