diff --git a/crates/graphql/src/lib.rs b/crates/graphql/src/lib.rs index 5f0201b8..fbc7dbfe 100644 --- a/crates/graphql/src/lib.rs +++ b/crates/graphql/src/lib.rs @@ -26,8 +26,10 @@ clippy::unused_async )] +use anyhow::Context; use async_graphql::EmptySubscription; -use mas_data_model::{BrowserSession, User}; +use mas_data_model::{BrowserSession, Session, User}; +use ulid::Ulid; mod model; mod mutations; @@ -60,18 +62,51 @@ pub enum Requester { /// The requester is a browser session, stored in a cookie. BrowserSession(BrowserSession), + + /// The requester is a OAuth2 session, with an access token. + OAuth2Session(Session, User), } impl Requester { fn browser_session(&self) -> Option<&BrowserSession> { match self { Self::BrowserSession(session) => Some(session), - Self::Anonymous => None, + Self::OAuth2Session(_, _) | Self::Anonymous => None, } } fn user(&self) -> Option<&User> { - self.browser_session().map(|session| &session.user) + match self { + Self::BrowserSession(session) => Some(&session.user), + Self::OAuth2Session(_session, user) => Some(user), + Self::Anonymous => None, + } + } + + fn ensure_owner_or_admin(&self, user_id: Ulid) -> Result<(), async_graphql::Error> { + // If the requester is an admin, they can do anything. + if self.is_admin() { + return Ok(()); + } + + // Else check that they are the owner. + let user = self.user().context("Unauthorized")?; + if user.id == user_id { + Ok(()) + } else { + Err(async_graphql::Error::new("Unauthorized")) + } + } + + fn is_admin(&self) -> bool { + match self { + Self::OAuth2Session(session, _user) => { + // TODO: is this the right scope? + // This has to be in sync with the policy + session.scope.contains("urn:mas:admin") + } + Self::BrowserSession(_) | Self::Anonymous => false, + } } } diff --git a/crates/graphql/src/model/viewer/mod.rs b/crates/graphql/src/model/viewer/mod.rs index 056aa24d..b90083a2 100644 --- a/crates/graphql/src/model/viewer/mod.rs +++ b/crates/graphql/src/model/viewer/mod.rs @@ -14,7 +14,7 @@ use async_graphql::Union; -use crate::model::{BrowserSession, User}; +use crate::model::{BrowserSession, OAuth2Session, User}; mod anonymous; pub use self::anonymous::Anonymous; @@ -40,6 +40,7 @@ impl Viewer { #[derive(Union)] pub enum ViewerSession { BrowserSession(BrowserSession), + OAuth2Session(OAuth2Session), Anonymous(Anonymous), } @@ -48,6 +49,10 @@ impl ViewerSession { Self::BrowserSession(BrowserSession(session)) } + pub fn oauth2_session(session: mas_data_model::Session) -> Self { + Self::OAuth2Session(OAuth2Session(session)) + } + pub fn anonymous() -> Self { Self::Anonymous(Anonymous) } diff --git a/crates/graphql/src/query/mod.rs b/crates/graphql/src/query/mod.rs index 8f903212..0a645bc2 100644 --- a/crates/graphql/src/query/mod.rs +++ b/crates/graphql/src/query/mod.rs @@ -88,6 +88,13 @@ impl BaseQuery { if current_user.id == id { Ok(Some(User(current_user.clone()))) + } else if requester.is_admin() { + // An admin can fetch any user, not just themselves + let state = ctx.state(); + let mut repo = state.repository().await?; + let user = repo.user().lookup(id).await?; + repo.cancel().await?; + Ok(user.map(User)) } else { Ok(None) } @@ -113,7 +120,7 @@ impl BaseQuery { repo.cancel().await?; let ret = browser_session.and_then(|browser_session| { - if browser_session.user.id == current_user.id { + if browser_session.user.id == current_user.id || requester.is_admin() { Some(BrowserSession(browser_session)) } else { None @@ -142,7 +149,7 @@ impl BaseQuery { .user_email() .lookup(id) .await? - .filter(|e| e.user_id == current_user.id); + .filter(|e| e.user_id == current_user.id || requester.is_admin()); repo.cancel().await?; diff --git a/crates/graphql/src/query/upstream_oauth.rs b/crates/graphql/src/query/upstream_oauth.rs index 3ecc347d..43e48238 100644 --- a/crates/graphql/src/query/upstream_oauth.rs +++ b/crates/graphql/src/query/upstream_oauth.rs @@ -49,7 +49,8 @@ impl UpstreamOAuthQuery { let link = repo.upstream_oauth_link().lookup(id).await?; // Ensure that the link belongs to the current user - let link = link.filter(|link| link.user_id == Some(current_user.id)); + let link = + link.filter(|link| link.user_id == Some(current_user.id) || requester.is_admin()); Ok(link.map(UpstreamOAuth2Link::new)) } diff --git a/crates/graphql/src/query/viewer.rs b/crates/graphql/src/query/viewer.rs index 9a6cd24f..352803ba 100644 --- a/crates/graphql/src/query/viewer.rs +++ b/crates/graphql/src/query/viewer.rs @@ -31,6 +31,7 @@ impl ViewerQuery { match requester { Requester::BrowserSession(session) => Viewer::user(session.user.clone()), + Requester::OAuth2Session(_session, user) => Viewer::user(user.clone()), Requester::Anonymous => Viewer::anonymous(), } } @@ -41,6 +42,9 @@ impl ViewerQuery { match requester { Requester::BrowserSession(session) => ViewerSession::browser_session(session.clone()), + Requester::OAuth2Session(session, _user) => { + ViewerSession::oauth2_session(session.clone()) + } Requester::Anonymous => ViewerSession::anonymous(), } } diff --git a/crates/handlers/src/graphql.rs b/crates/handlers/src/graphql.rs index 405487d1..84488574 100644 --- a/crates/handlers/src/graphql.rs +++ b/crates/handlers/src/graphql.rs @@ -21,24 +21,29 @@ use async_graphql::{ use axum::{ async_trait, extract::{BodyStream, RawQuery, State}, - response::{Html, IntoResponse}, + http::StatusCode, + response::{Html, IntoResponse, Response}, Json, TypedHeader, }; use axum_extra::extract::PrivateCookieJar; use futures_util::TryStreamExt; -use headers::{ContentType, HeaderValue}; +use headers::{authorization::Bearer, Authorization, ContentType, HeaderValue}; use hyper::header::CACHE_CONTROL; -use mas_axum_utils::{FancyError, SessionInfoExt}; +use mas_axum_utils::{FancyError, SessionInfo, SessionInfoExt}; use mas_graphql::{Requester, Schema}; use mas_keystore::Encrypter; use mas_matrix::HomeserverConnection; -use mas_storage::{BoxClock, BoxRepository, BoxRng, Repository, RepositoryError, SystemClock}; +use mas_storage::{ + BoxClock, BoxRepository, BoxRng, Clock, Repository, RepositoryError, SystemClock, +}; use mas_storage_pg::PgRepository; use rand::{thread_rng, SeedableRng}; use rand_chacha::ChaChaRng; use sqlx::PgPool; use tracing::{info_span, Instrument}; +use crate::impl_from_error_for_route; + struct GraphQLState { pool: PgPool, homeserver_connection: Arc>, @@ -107,17 +112,129 @@ fn span_for_graphql_request(request: &async_graphql::Request) -> tracing::Span { span } +#[derive(thiserror::Error, Debug)] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Loading of some database objects failed")] + LoadFailed, + + #[error("Invalid access token")] + InvalidToken, + + #[error("Missing scope")] + MissingScope, + + #[error(transparent)] + ParseRequest(#[from] async_graphql::ParseRequestError), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> Response { + sentry::capture_error(&self); + + match self { + e @ (Self::Internal(_) | Self::LoadFailed) => { + let error = async_graphql::Error::new_with_source(e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"errors": [error]})), + ) + .into_response() + } + + Self::InvalidToken => { + let error = async_graphql::Error::new("Invalid token"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"errors": [error]})), + ) + .into_response() + } + + Self::MissingScope => { + let error = async_graphql::Error::new("Missing urn:mas:graphql:* scope"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"errors": [error]})), + ) + .into_response() + } + + Self::ParseRequest(e) => { + let error = async_graphql::Error::new_with_source(e); + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"errors": [error]})), + ) + .into_response() + } + } + } +} + +async fn get_requester( + clock: &impl Clock, + mut repo: BoxRepository, + session_info: SessionInfo, + token: Option<&str>, +) -> Result { + let requester = if let Some(token) = token { + let token = repo + .oauth2_access_token() + .find_by_token(token) + .await? + .ok_or(RouteError::InvalidToken)?; + + let session = repo + .oauth2_session() + .lookup(token.session_id) + .await? + .ok_or(RouteError::LoadFailed)?; + + // XXX: The user_id should really be directly on the OAuth session + let browser_session = repo + .browser_session() + .lookup(session.user_session_id) + .await? + .ok_or(RouteError::LoadFailed)?; + + let user = browser_session.user; + + if !token.is_valid(clock.now()) || !session.is_valid() || !user.is_valid() { + return Err(RouteError::InvalidToken); + } + + if !session.scope.contains("urn:mas:graphql:*") { + return Err(RouteError::MissingScope); + } + + Requester::OAuth2Session(session, user) + } else { + let maybe_session = session_info.load_session(&mut repo).await?; + Requester::from(maybe_session) + }; + repo.cancel().await?; + Ok(requester) +} + pub async fn post( State(schema): State, - mut repo: BoxRepository, + clock: BoxClock, + repo: BoxRepository, cookie_jar: PrivateCookieJar, content_type: Option>, + authorization: Option>>, body: BodyStream, -) -> Result { +) -> Result { + let token = authorization + .as_ref() + .map(|TypedHeader(Authorization(bearer))| bearer.token()); let (session_info, _cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut repo).await?; - let requester = Requester::from(maybe_session); - repo.cancel().await?; + let requester = get_requester(&clock, repo, session_info, token).await?; let content_type = content_type.map(|TypedHeader(h)| h.to_string()); @@ -146,14 +263,17 @@ pub async fn post( pub async fn get( State(schema): State, - mut repo: BoxRepository, + clock: BoxClock, + repo: BoxRepository, cookie_jar: PrivateCookieJar, + authorization: Option>>, RawQuery(query): RawQuery, ) -> Result { + let token = authorization + .as_ref() + .map(|TypedHeader(Authorization(bearer))| bearer.token()); let (session_info, _cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut repo).await?; - let requester = Requester::from(maybe_session); - repo.cancel().await?; + let requester = get_requester(&clock, repo, session_info, token).await?; let request = async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester); diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index ea816635..0a1d3034 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -108,6 +108,7 @@ where S: Clone + Send + Sync + 'static, mas_graphql::Schema: FromRef, BoxRepository: FromRequestParts, + BoxClock: FromRequestParts, Encrypter: FromRef, { let mut router = Router::new().route( diff --git a/policies/authorization_grant.rego b/policies/authorization_grant.rego index a7c604e1..2fd0c717 100644 --- a/policies/authorization_grant.rego +++ b/policies/authorization_grant.rego @@ -15,11 +15,21 @@ allowed_scope("openid") = true allowed_scope("email") = true +# This grants access to Synapse's admin API endpoints allowed_scope("urn:synapse:admin:*") { some user in data.admin_users input.user.username == user } +# This grants access to the /graphql API endpoint +allowed_scope("urn:mas:graphql:*") = true + +# This makes it possible to query and do anything in the GraphQL API as an admin +allowed_scope("urn:mas:admin") { + some user in data.admin_users + input.user.username == user +} + allowed_scope(scope) { regex.match("urn:matrix:org.matrix.msc2967.client:device:[A-Za-z0-9-]{10,}", scope) }