scufflecloud_core/operations/
users.rs

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