diff --git a/crates/axum-utils/src/user_authorization.rs b/crates/axum-utils/src/user_authorization.rs index 4a74c0f1..8de0c9e5 100644 --- a/crates/axum-utils/src/user_authorization.rs +++ b/crates/axum-utils/src/user_authorization.rs @@ -29,7 +29,7 @@ use http::{header::WWW_AUTHENTICATE, HeaderMap, HeaderValue, Request, StatusCode use mas_data_model::Session; use mas_storage::{ oauth2::access_token::{lookup_active_access_token, AccessTokenLookupError}, - PostgresqlBackend, + LookupError, PostgresqlBackend, }; use serde::{de::DeserializeOwned, Deserialize}; use sqlx::PgConnection; diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 015bd977..aeffa5f6 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -20,7 +20,7 @@ use mas_storage::{ user::{ lookup_user_by_username, lookup_user_email, mark_user_email_as_verified, register_user, }, - Clock, + Clock, LookupError, }; use rand::SeedableRng; use tracing::{info, warn}; diff --git a/crates/graphql/src/lib.rs b/crates/graphql/src/lib.rs index fa73924a..7b34f4a1 100644 --- a/crates/graphql/src/lib.rs +++ b/crates/graphql/src/lib.rs @@ -24,10 +24,10 @@ use async_graphql::{Context, Description, EmptyMutation, EmptySubscription, ID}; use mas_axum_utils::SessionInfo; -use model::NodeType; +use mas_storage::LookupResultExt; use sqlx::PgPool; -use self::model::{BrowserSession, Node, User}; +use self::model::{BrowserSession, Node, NodeType, OAuth2Client, User, UserEmail}; mod model; @@ -80,33 +80,122 @@ impl RootQuery { Ok(session.map(User::from)) } - /// Fetches an object given its ID. - async fn node(&self, ctx: &Context<'_>, id: ID) -> Result, async_graphql::Error> { - let (node_type, id) = NodeType::from_id(&id)?; + /// Fetch an OAuth 2.0 client by its ID. + async fn oauth2_client( + &self, + ctx: &Context<'_>, + id: ID, + ) -> Result, async_graphql::Error> { + let id = NodeType::OAuth2Client.extract_ulid(&id)?; + let database = ctx.data::()?; + let mut conn = database.acquire().await?; + + let client = mas_storage::oauth2::client::lookup_client(&mut conn, id) + .await + .to_option()?; + + Ok(client.map(OAuth2Client)) + } + + /// Fetch a user by its ID. + async fn user(&self, ctx: &Context<'_>, id: ID) -> Result, async_graphql::Error> { + let id = NodeType::User.extract_ulid(&id)?; let database = ctx.data::()?; let session_info = ctx.data::()?; let mut conn = database.acquire().await?; let session = session_info.load_session(&mut conn).await?; let Some(session) = session else { return Ok(None) }; + let current_user = session.user; - match node_type { - // TODO - NodeType::Authentication - | NodeType::BrowserSession - | NodeType::CompatSession - | NodeType::CompatSsoLogin - | NodeType::OAuth2Client - | NodeType::UserEmail - | NodeType::OAuth2Session => Ok(None), - - NodeType::User => { - if session.user.data == id { - Ok(Some(Box::new(User(session.user)).into())) - } else { - Ok(None) - } - } + if current_user.data == id { + Ok(Some(User(current_user))) + } else { + Ok(None) } } + + /// Fetch a browser session by its ID. + async fn browser_session( + &self, + ctx: &Context<'_>, + id: ID, + ) -> Result, async_graphql::Error> { + let id = NodeType::BrowserSession.extract_ulid(&id)?; + let database = ctx.data::()?; + let session_info = ctx.data::()?; + let mut conn = database.acquire().await?; + let session = session_info.load_session(&mut conn).await?; + + let Some(session) = session else { return Ok(None) }; + let current_user = session.user; + + let browser_session = mas_storage::user::lookup_active_session(&mut conn, id) + .await + .to_option()?; + + let ret = browser_session.and_then(|browser_session| { + if browser_session.user.data == current_user.data { + Some(BrowserSession(browser_session)) + } else { + None + } + }); + + Ok(ret) + } + + /// Fetch a user email by its ID. + async fn user_email( + &self, + ctx: &Context<'_>, + id: ID, + ) -> Result, async_graphql::Error> { + let id = NodeType::UserEmail.extract_ulid(&id)?; + let database = ctx.data::()?; + let session_info = ctx.data::()?; + let mut conn = database.acquire().await?; + let session = session_info.load_session(&mut conn).await?; + + let Some(session) = session else { return Ok(None) }; + let current_user = session.user; + + let user_email = mas_storage::user::lookup_user_email_by_id(&mut conn, ¤t_user, id) + .await + .to_option()?; + + Ok(user_email.map(UserEmail)) + } + + /// Fetches an object given its ID. + async fn node(&self, ctx: &Context<'_>, id: ID) -> Result, async_graphql::Error> { + let (node_type, _id) = NodeType::from_id(&id)?; + + let ret = match node_type { + // TODO + NodeType::Authentication + | NodeType::CompatSession + | NodeType::CompatSsoLogin + | NodeType::OAuth2Session => None, + + NodeType::OAuth2Client => self + .oauth2_client(ctx, id) + .await? + .map(|c| Node::OAuth2Client(Box::new(c))), + + NodeType::UserEmail => self + .user_email(ctx, id) + .await? + .map(|e| Node::UserEmail(Box::new(e))), + + NodeType::BrowserSession => self + .browser_session(ctx, id) + .await? + .map(|s| Node::BrowserSession(Box::new(s))), + + NodeType::User => self.user(ctx, id).await?.map(|u| Node::User(Box::new(u))), + }; + + Ok(ret) + } } diff --git a/crates/graphql/src/model/node.rs b/crates/graphql/src/model/node.rs index 59d24513..124cfeca 100644 --- a/crates/graphql/src/model/node.rs +++ b/crates/graphql/src/model/node.rs @@ -40,6 +40,7 @@ pub enum InvalidID { InvalidFormat, InvalidUlid(#[from] ulid::DecodeError), UnknownPrefix, + TypeMismatch { got: NodeType, expected: NodeType }, } impl NodeType { @@ -90,6 +91,19 @@ impl NodeType { pub fn from_id(id: &ID) -> Result<(Self, Ulid), InvalidID> { Self::deserialize(&id.0) } + + pub fn extract_ulid(self, id: &ID) -> Result { + let (node_type, ulid) = Self::deserialize(&id.0)?; + + if node_type == self { + Ok(ulid) + } else { + Err(InvalidID::TypeMismatch { + got: node_type, + expected: self, + }) + } + } } /// An object with an ID. diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index 771d856d..518212c9 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -256,7 +256,7 @@ impl User { /// A user email address #[derive(Description)] -pub struct UserEmail(mas_data_model::UserEmail); +pub struct UserEmail(pub mas_data_model::UserEmail); #[Object(use_type_description)] impl UserEmail { diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index d3af3402..2c481182 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -22,7 +22,7 @@ use mas_storage::{ get_compat_sso_login_by_token, mark_compat_sso_login_as_exchanged, CompatSsoLoginLookupError, }, - Clock, PostgresqlBackend, + Clock, LookupError, PostgresqlBackend, }; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, skip_serializing_none, DurationMilliSeconds}; diff --git a/crates/handlers/src/compat/refresh.rs b/crates/handlers/src/compat/refresh.rs index fb1297bb..43e4be01 100644 --- a/crates/handlers/src/compat/refresh.rs +++ b/crates/handlers/src/compat/refresh.rs @@ -16,9 +16,13 @@ use axum::{extract::State, response::IntoResponse, Json}; use chrono::Duration; use hyper::StatusCode; use mas_data_model::{TokenFormatError, TokenType}; -use mas_storage::compat::{ - add_compat_access_token, add_compat_refresh_token, consume_compat_refresh_token, - expire_compat_access_token, lookup_active_compat_refresh_token, CompatRefreshTokenLookupError, +use mas_storage::{ + compat::{ + add_compat_access_token, add_compat_refresh_token, consume_compat_refresh_token, + expire_compat_access_token, lookup_active_compat_refresh_token, + CompatRefreshTokenLookupError, + }, + LookupError, }; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DurationMilliSeconds}; diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index 69149206..53930769 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -26,9 +26,12 @@ use mas_data_model::{AuthorizationCode, Pkce}; use mas_keystore::Encrypter; use mas_policy::PolicyFactory; use mas_router::{PostAuthAction, Route}; -use mas_storage::oauth2::{ - authorization_grant::new_authorization_grant, - client::{lookup_client_by_client_id, ClientFetchError}, +use mas_storage::{ + oauth2::{ + authorization_grant::new_authorization_grant, + client::{lookup_client_by_client_id, ClientFetchError}, + }, + LookupError, }; use mas_templates::Templates; use oauth2_types::{ diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 504562be..75f898a3 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -28,7 +28,7 @@ use mas_storage::{ client::ClientFetchError, refresh_token::{lookup_active_refresh_token, RefreshTokenLookupError}, }, - Clock, + Clock, LookupError, }; use oauth2_types::requests::{IntrospectionRequest, IntrospectionResponse}; use sqlx::PgPool; diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 0b14cedf..f15416b0 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -40,7 +40,7 @@ use mas_storage::{ RefreshTokenLookupError, }, }, - DatabaseInconsistencyError, PostgresqlBackend, + DatabaseInconsistencyError, LookupError, PostgresqlBackend, }; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, diff --git a/crates/storage/src/compat.rs b/crates/storage/src/compat.rs index ac02848a..d2cf2a1d 100644 --- a/crates/storage/src/compat.rs +++ b/crates/storage/src/compat.rs @@ -31,7 +31,7 @@ use uuid::Uuid; use crate::{ pagination::{process_page, QueryBuilderExt}, user::lookup_user_by_username, - Clock, DatabaseInconsistencyError, PostgresqlBackend, + Clock, DatabaseInconsistencyError, LookupError, PostgresqlBackend, }; struct CompatAccessTokenLookup { @@ -59,9 +59,8 @@ pub enum CompatAccessTokenLookupError { Inconsistency(#[from] DatabaseInconsistencyError), } -impl CompatAccessTokenLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for CompatAccessTokenLookupError { + fn not_found(&self) -> bool { matches!( self, Self::Database(sqlx::Error::RowNotFound) | Self::Expired { .. } @@ -194,9 +193,8 @@ pub enum CompatRefreshTokenLookupError { Inconsistency(#[from] DatabaseInconsistencyError), } -impl CompatRefreshTokenLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for CompatRefreshTokenLookupError { + fn not_found(&self) -> bool { matches!(self, Self::Database(sqlx::Error::RowNotFound)) } } @@ -752,9 +750,8 @@ pub enum CompatSsoLoginLookupError { Inconsistency(#[from] DatabaseInconsistencyError), } -impl CompatSsoLoginLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for CompatSsoLoginLookupError { + fn not_found(&self) -> bool { matches!(self, Self::Database(sqlx::Error::RowNotFound)) } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 86b0febe..bd0db87c 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -35,6 +35,54 @@ use sqlx::migrate::Migrator; use thiserror::Error; use ulid::Ulid; +#[derive(Debug, Error)] +#[error("failed to lookup {what}")] +pub struct GenericLookupError { + what: &'static str, + source: sqlx::Error, +} + +impl GenericLookupError { + #[must_use] + pub fn what(what: &'static str) -> Box Self> { + Box::new(move |source: sqlx::Error| Self { what, source }) + } +} + +impl LookupError for GenericLookupError { + fn not_found(&self) -> bool { + matches!(self.source, sqlx::Error::RowNotFound) + } +} + +pub trait LookupError { + fn not_found(&self) -> bool; +} + +pub trait LookupResultExt { + type Error; + type Output; + + /// Transform a [`Result`] with a [`LookupError`] to transform "not + /// found" errors into [`None`] + fn to_option(self) -> Result, Self::Error>; +} + +impl LookupResultExt for Result +where + E: LookupError, +{ + type Output = T; + type Error = E; + fn to_option(self) -> Result, Self::Error> { + match self { + Ok(v) => Ok(Some(v)), + Err(e) if e.not_found() => Ok(None), + Err(e) => Err(e), + } + } +} + #[derive(Default, Debug, Clone, Copy)] pub struct Clock { _private: (), diff --git a/crates/storage/src/oauth2/access_token.rs b/crates/storage/src/oauth2/access_token.rs index 2f42c654..b26bc1ad 100644 --- a/crates/storage/src/oauth2/access_token.rs +++ b/crates/storage/src/oauth2/access_token.rs @@ -22,7 +22,7 @@ use ulid::Ulid; use uuid::Uuid; use super::client::{lookup_client, ClientFetchError}; -use crate::{Clock, DatabaseInconsistencyError, PostgresqlBackend}; +use crate::{Clock, DatabaseInconsistencyError, LookupError, PostgresqlBackend}; #[tracing::instrument( skip_all, @@ -103,9 +103,8 @@ pub enum AccessTokenLookupError { Inconsistency(#[from] DatabaseInconsistencyError), } -impl AccessTokenLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for AccessTokenLookupError { + fn not_found(&self) -> bool { matches!(self, Self::Database(sqlx::Error::RowNotFound)) } } diff --git a/crates/storage/src/oauth2/client.rs b/crates/storage/src/oauth2/client.rs index 5f6f5015..7a2ed18b 100644 --- a/crates/storage/src/oauth2/client.rs +++ b/crates/storage/src/oauth2/client.rs @@ -28,7 +28,7 @@ use ulid::Ulid; use url::Url; use uuid::Uuid; -use crate::{Clock, PostgresqlBackend}; +use crate::{Clock, LookupError, PostgresqlBackend}; // XXX: response_types & contacts #[derive(Debug)] @@ -81,9 +81,8 @@ pub enum ClientFetchError { Database(#[from] sqlx::Error), } -impl ClientFetchError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for ClientFetchError { + fn not_found(&self) -> bool { matches!( self, Self::Database(sqlx::Error::RowNotFound) | Self::InvalidClientId(_) diff --git a/crates/storage/src/oauth2/refresh_token.rs b/crates/storage/src/oauth2/refresh_token.rs index 324879ac..0bedf5b8 100644 --- a/crates/storage/src/oauth2/refresh_token.rs +++ b/crates/storage/src/oauth2/refresh_token.rs @@ -24,7 +24,7 @@ use ulid::Ulid; use uuid::Uuid; use super::client::{lookup_client, ClientFetchError}; -use crate::{Clock, DatabaseInconsistencyError, PostgresqlBackend}; +use crate::{Clock, DatabaseInconsistencyError, LookupError, PostgresqlBackend}; #[tracing::instrument( skip_all, @@ -106,9 +106,8 @@ pub enum RefreshTokenLookupError { Conversion(#[from] DatabaseInconsistencyError), } -impl RefreshTokenLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for RefreshTokenLookupError { + fn not_found(&self) -> bool { matches!(self, Self::Fetch(sqlx::Error::RowNotFound)) } } diff --git a/crates/storage/src/user.rs b/crates/storage/src/user.rs index 52aacabe..d796ef3d 100644 --- a/crates/storage/src/user.rs +++ b/crates/storage/src/user.rs @@ -33,7 +33,7 @@ use uuid::Uuid; use super::{DatabaseInconsistencyError, PostgresqlBackend}; use crate::{ pagination::{process_page, QueryBuilderExt}, - Clock, + Clock, GenericLookupError, LookupError, }; #[derive(Debug, Clone)] @@ -117,9 +117,8 @@ pub enum ActiveSessionLookupError { Conversion(#[from] DatabaseInconsistencyError), } -impl ActiveSessionLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for ActiveSessionLookupError { + fn not_found(&self) -> bool { matches!(self, Self::Fetch(sqlx::Error::RowNotFound)) } } @@ -566,9 +565,8 @@ pub enum UserLookupError { Inconsistency(#[from] DatabaseInconsistencyError), } -impl UserLookupError { - #[must_use] - pub fn not_found(&self) -> bool { +impl LookupError for UserLookupError { + fn not_found(&self) -> bool { matches!(self, Self::Database(sqlx::Error::RowNotFound)) } } @@ -955,13 +953,13 @@ pub async fn lookup_user_email( user.id = %user.data, user_email.id = %id, ), - err(Display), + err, )] pub async fn lookup_user_email_by_id( executor: impl PgExecutor<'_>, user: &User, id: Ulid, -) -> Result, anyhow::Error> { +) -> Result, GenericLookupError> { let res = sqlx::query_as!( UserEmailLookup, r#" @@ -981,7 +979,7 @@ pub async fn lookup_user_email_by_id( .fetch_one(executor) .instrument(info_span!("Lookup user email")) .await - .context("could not lookup user email")?; + .map_err(GenericLookupError::what("user email"))?; Ok(res.into()) } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ed48a3a1..4ef30da8 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -294,6 +294,22 @@ type RootQuery { """ currentUser: User """ + Fetch an OAuth 2.0 client by its ID. + """ + oauth2Client(id: ID!): Oauth2Client + """ + Fetch a user by its ID. + """ + user(id: ID!): User + """ + Fetch a browser session by its ID. + """ + browserSession(id: ID!): BrowserSession + """ + Fetch a user email by its ID. + """ + userEmail(id: ID!): UserEmail + """ Fetches an object given its ID. """ node(id: ID!): Node diff --git a/frontend/src/components/OAuth2SessionList.tsx b/frontend/src/components/OAuth2SessionList.tsx index 784cc2c6..0943db7b 100644 --- a/frontend/src/components/OAuth2SessionList.tsx +++ b/frontend/src/components/OAuth2SessionList.tsx @@ -13,7 +13,6 @@ // limitations under the License. import { graphql, usePaginationFragment } from "react-relay"; -import Block from "./Block"; import BlockList from "./BlockList"; import Button from "./Button"; import OAuth2Session from "./OAuth2Session";