diff --git a/Cargo.lock b/Cargo.lock index a328226f..8191df16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,7 @@ dependencies = [ "thiserror", "tracing", "tracing-futures", + "url", ] [[package]] @@ -2605,6 +2606,7 @@ dependencies = [ "sqlx", "tokio", "ulid", + "url", ] [[package]] diff --git a/crates/graphql/Cargo.toml b/crates/graphql/Cargo.toml index ab29eefc..87c2706a 100644 --- a/crates/graphql/Cargo.toml +++ b/crates/graphql/Cargo.toml @@ -6,12 +6,13 @@ edition = "2021" license = "Apache-2.0" [dependencies] -async-graphql = { version = "4.0.16", features = ["chrono"] } +async-graphql = { version = "4.0.16", features = ["chrono", "url"] } chrono = "0.4.22" serde = { version = "1.0.147", features = ["derive"] } sqlx = { version = "0.6.2", features = ["runtime-tokio-rustls", "postgres"] } tokio = { version = "1.21.2", features = ["time"] } ulid = "1.0.0" +url = "2.3.1" mas-axum-utils = { path = "../axum-utils" } mas-data-model = { path = "../data-model" } diff --git a/crates/graphql/src/model.rs b/crates/graphql/src/model.rs index ed622561..c5d757d7 100644 --- a/crates/graphql/src/model.rs +++ b/crates/graphql/src/model.rs @@ -13,6 +13,7 @@ // limitations under the License. mod browser_sessions; +mod compat_sessions; mod cursor; mod users; diff --git a/crates/graphql/src/model/compat_sessions.rs b/crates/graphql/src/model/compat_sessions.rs new file mode 100644 index 00000000..6bb4159f --- /dev/null +++ b/crates/graphql/src/model/compat_sessions.rs @@ -0,0 +1,88 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use async_graphql::{Object, ID}; +use chrono::{DateTime, Utc}; +use mas_data_model::CompatSsoLoginState; +use mas_storage::PostgresqlBackend; +use url::Url; + +use super::User; + +pub struct CompatSession(pub mas_data_model::CompatSession); + +#[Object] +impl CompatSession { + async fn id(&self) -> ID { + ID(self.0.data.to_string()) + } + + async fn user(&self) -> User { + User(self.0.user.clone()) + } + + async fn device_id(&self) -> &str { + self.0.device.as_str() + } + + async fn created_at(&self) -> DateTime { + self.0.created_at + } + + async fn finished_at(&self) -> Option> { + self.0.finished_at + } +} + +pub struct CompatSsoLogin(pub mas_data_model::CompatSsoLogin); + +#[Object] +impl CompatSsoLogin { + async fn id(&self) -> ID { + ID(self.0.data.to_string()) + } + + async fn created_at(&self) -> DateTime { + self.0.created_at + } + + async fn redirect_uri(&self) -> &Url { + &self.0.redirect_uri + } + + async fn fulfilled_at(&self) -> Option> { + match &self.0.state { + CompatSsoLoginState::Pending => None, + CompatSsoLoginState::Fulfilled { fulfilled_at, .. } + | CompatSsoLoginState::Exchanged { fulfilled_at, .. } => Some(*fulfilled_at), + } + } + + async fn exchanged_at(&self) -> Option> { + match &self.0.state { + CompatSsoLoginState::Pending | CompatSsoLoginState::Fulfilled { .. } => None, + CompatSsoLoginState::Exchanged { exchanged_at, .. } => Some(*exchanged_at), + } + } + + async fn session(&self) -> Option { + match &self.0.state { + CompatSsoLoginState::Pending => None, + CompatSsoLoginState::Fulfilled { session, .. } + | CompatSsoLoginState::Exchanged { session, .. } => { + Some(CompatSession(session.clone())) + } + } + } +} diff --git a/crates/graphql/src/model/cursor.rs b/crates/graphql/src/model/cursor.rs index f72d36a7..43893758 100644 --- a/crates/graphql/src/model/cursor.rs +++ b/crates/graphql/src/model/cursor.rs @@ -21,6 +21,7 @@ use ulid::Ulid; pub enum NodeType { UserEmail, BrowserSession, + CompatSsoLogin, } #[derive(Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index 9605721d..fb552f83 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -20,7 +20,7 @@ use chrono::{DateTime, Utc}; use mas_storage::PostgresqlBackend; use sqlx::PgPool; -use super::{BrowserSession, Cursor, NodeCursor, NodeType}; +use super::{compat_sessions::CompatSsoLogin, BrowserSession, Cursor, NodeCursor, NodeType}; pub struct User(pub mas_data_model::User); @@ -50,6 +50,50 @@ impl User { self.0.primary_email.clone().map(UserEmail) } + async fn compat_sso_logins( + &self, + ctx: &Context<'_>, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> Result, async_graphql::Error> { + let database = ctx.data::()?; + + query( + after, + before, + first, + last, + |after, before, first, last| async move { + let mut conn = database.acquire().await?; + let after_id = after + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::UserEmail)) + .transpose()?; + let before_id = before + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::UserEmail)) + .transpose()?; + + let (has_previous_page, has_next_page, edges) = + mas_storage::compat::get_paginated_user_compat_sso_logins( + &mut conn, &self.0, before_id, after_id, first, last, + ) + .await?; + + let mut connection = Connection::new(has_previous_page, has_next_page); + connection.edges.extend(edges.into_iter().map(|u| { + Edge::new( + OpaqueCursor(NodeCursor(NodeType::CompatSsoLogin, u.data)), + CompatSsoLogin(u), + ) + })); + + Ok::<_, async_graphql::Error>(connection) + }, + ) + .await + } + async fn browser_sessions( &self, ctx: &Context<'_>, diff --git a/crates/storage/src/compat.rs b/crates/storage/src/compat.rs index 6b0b9679..c42fd721 100644 --- a/crates/storage/src/compat.rs +++ b/crates/storage/src/compat.rs @@ -20,7 +20,7 @@ use mas_data_model::{ Device, User, UserEmail, }; use rand::Rng; -use sqlx::{Acquire, PgExecutor, Postgres}; +use sqlx::{postgres::PgArguments, Acquire, Arguments, PgExecutor, Postgres}; use thiserror::Error; use tokio::task; use tracing::{info_span, Instrument}; @@ -28,7 +28,11 @@ use ulid::Ulid; use url::Url; use uuid::Uuid; -use crate::{user::lookup_user_by_username, Clock, DatabaseInconsistencyError, PostgresqlBackend}; +use crate::{ + pagination::{generate_pagination, process_page}, + user::lookup_user_by_username, + Clock, DatabaseInconsistencyError, PostgresqlBackend, +}; struct CompatAccessTokenLookup { compat_access_token_id: Uuid, @@ -632,6 +636,7 @@ pub async fn insert_compat_sso_login( }) } +#[derive(sqlx::FromRow)] struct CompatSsoLoginLookup { compat_sso_login_id: Uuid, compat_sso_login_token: String, @@ -803,6 +808,83 @@ pub async fn get_compat_sso_login_by_id( Ok(res.try_into()?) } +#[tracing::instrument( + skip_all, + fields( + user.id = %user.data, + user.username = user.username, + ), + err(Display), +)] +pub async fn get_paginated_user_compat_sso_logins( + executor: impl PgExecutor<'_>, + user: &User, + before: Option, + after: Option, + first: Option, + last: Option, +) -> Result<(bool, bool, Vec>), anyhow::Error> { + // TODO: this queries too much (like user info) which we probably don't need + // because we already have them + let mut query = String::from( + r#" + SELECT + cl.compat_sso_login_id, + cl.login_token AS "compat_sso_login_token", + cl.redirect_uri AS "compat_sso_login_redirect_uri", + cl.created_at AS "compat_sso_login_created_at", + cl.fulfilled_at AS "compat_sso_login_fulfilled_at", + cl.exchanged_at AS "compat_sso_login_exchanged_at", + cs.compat_session_id AS "compat_session_id", + cs.created_at AS "compat_session_created_at", + cs.finished_at AS "compat_session_finished_at", + cs.device_id AS "compat_session_device_id", + u.user_id AS "user_id", + u.username AS "user_username", + ue.user_email_id AS "user_email_id", + ue.email AS "user_email", + ue.created_at AS "user_email_created_at", + ue.confirmed_at AS "user_email_confirmed_at" + FROM compat_sso_logins cl + LEFT JOIN compat_sessions cs + USING (compat_session_id) + LEFT JOIN users u + USING (user_id) + LEFT JOIN user_emails ue + ON ue.user_email_id = u.primary_user_email_id + "#, + ); + + let mut arguments = PgArguments::default(); + + query += " WHERE cs.user_id = "; + arguments.add(Uuid::from(user.data)); + arguments.format_placeholder(&mut query)?; + + generate_pagination( + &mut query, + "cl.compat_sso_login_id", + &mut arguments, + before, + after, + first, + last, + )?; + + let page: Vec = sqlx::query_as_with(&query, arguments) + .fetch_all(executor) + .instrument(info_span!( + "Fetch paginated user compat SSO logins", + query = query + )) + .await?; + + let (has_previous_page, has_next_page, page) = process_page(page, first, last)?; + + let page: Result, _> = page.into_iter().map(TryInto::try_into).collect(); + Ok((has_previous_page, has_next_page, page?)) +} + #[tracing::instrument(skip_all, err)] pub async fn get_compat_sso_login_by_token( executor: impl PgExecutor<'_>,