Skip to main content

mas_handlers/graphql/
mod.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8#![allow(
9    clippy::module_name_repetitions,
10    // async-graphql's `#[Object]` requires resolvers to be `async fn`, and its
11    // `Enum` derive emits async trait impls without awaits
12    clippy::unused_async_trait_impl,
13)]
14
15use std::{net::IpAddr, ops::Deref, sync::Arc};
16
17use async_graphql::{
18    EmptySubscription, InputObject,
19    extensions::Tracing,
20    http::MultipartOptions,
21    parser::types::{DocumentOperations, OperationType},
22};
23use axum::{
24    Extension, Json,
25    body::Body,
26    extract::{RawQuery, State as AxumState},
27    http::StatusCode,
28    response::{IntoResponse, Response},
29};
30use axum_extra::typed_header::TypedHeader;
31use chrono::{DateTime, Utc};
32use futures_util::TryStreamExt;
33use headers::{Authorization, ContentType, HeaderValue, authorization::Bearer};
34use hyper::header::CACHE_CONTROL;
35use mas_axum_utils::{
36    InternalError, RecordAsRequester, SessionInfo, SessionInfoExt, cookies::CookieJar, record_error,
37};
38use mas_data_model::{
39    BoxClock, BoxRng, BrowserSession, Clock, Session, SiteConfig, SystemClock, User,
40};
41use mas_matrix::HomeserverConnection;
42use mas_policy::{InstantiateError, Policy, PolicyFactory};
43use mas_router::UrlBuilder;
44use mas_storage::{BoxRepository, BoxRepositoryFactory, RepositoryError};
45use opentelemetry_semantic_conventions::trace::{
46    GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE,
47};
48use rand::{SeedableRng, rng};
49use rand_chacha::ChaChaRng;
50use state::has_session_ended;
51use tracing::{Instrument, info_span};
52use ulid::Ulid;
53
54mod model;
55mod mutations;
56mod query;
57mod state;
58
59pub use self::state::{BoxState, State};
60use self::{
61    model::{CreationEvent, Node},
62    mutations::Mutation,
63    query::Query,
64};
65use crate::{
66    BoundActivityTracker, Limiter, RequesterFingerprint, impl_from_error_for_route,
67    passwords::PasswordManager,
68};
69
70#[cfg(test)]
71mod tests;
72
73/// Extra parameters we get from the listener configuration, because they are
74/// per-listener options. We pass them through request extensions.
75#[derive(Debug, Clone)]
76pub struct ExtraRouterParameters {
77    pub undocumented_oauth2_access: bool,
78}
79
80struct GraphQLState {
81    repository_factory: BoxRepositoryFactory,
82    homeserver_connection: Arc<dyn HomeserverConnection>,
83    policy_factory: Arc<PolicyFactory>,
84    site_config: SiteConfig,
85    password_manager: PasswordManager,
86    url_builder: UrlBuilder,
87    limiter: Limiter,
88}
89
90#[async_trait::async_trait]
91impl state::State for GraphQLState {
92    async fn repository(&self) -> Result<BoxRepository, RepositoryError> {
93        self.repository_factory.create().await
94    }
95
96    async fn policy(&self) -> Result<Policy, InstantiateError> {
97        self.policy_factory.instantiate().await
98    }
99
100    fn password_manager(&self) -> PasswordManager {
101        self.password_manager.clone()
102    }
103
104    fn site_config(&self) -> &SiteConfig {
105        &self.site_config
106    }
107
108    fn homeserver_connection(&self) -> &dyn HomeserverConnection {
109        self.homeserver_connection.as_ref()
110    }
111
112    fn url_builder(&self) -> &UrlBuilder {
113        &self.url_builder
114    }
115
116    fn limiter(&self) -> &Limiter {
117        &self.limiter
118    }
119
120    fn clock(&self) -> BoxClock {
121        let clock = SystemClock::default();
122        Box::new(clock)
123    }
124
125    fn rng(&self) -> BoxRng {
126        #[expect(clippy::disallowed_methods)]
127        let mut rng = rng();
128
129        let rng = ChaChaRng::from_rng(&mut rng);
130        Box::new(rng)
131    }
132}
133
134#[must_use]
135pub fn schema(
136    repository_factory: BoxRepositoryFactory,
137    policy_factory: &Arc<PolicyFactory>,
138    homeserver_connection: impl HomeserverConnection + 'static,
139    site_config: SiteConfig,
140    password_manager: PasswordManager,
141    url_builder: UrlBuilder,
142    limiter: Limiter,
143) -> Schema {
144    let state = GraphQLState {
145        repository_factory,
146        policy_factory: Arc::clone(policy_factory),
147        homeserver_connection: Arc::new(homeserver_connection),
148        site_config,
149        password_manager,
150        url_builder,
151        limiter,
152    };
153    let state: BoxState = Box::new(state);
154
155    schema_builder().extension(Tracing).data(state).finish()
156}
157
158fn span_and_operation_for_graphql_request(
159    request: &mut async_graphql::Request,
160) -> (tracing::Span, GraphQLOperation) {
161    let span = info_span!(
162        "GraphQL operation",
163        "otel.name" = tracing::field::Empty,
164        "otel.kind" = "server",
165        { GRAPHQL_DOCUMENT } = request.query,
166        { GRAPHQL_OPERATION_NAME } = tracing::field::Empty,
167        { GRAPHQL_OPERATION_TYPE } = tracing::field::Empty,
168    );
169
170    let mut graphql_operation = GraphQLOperation {
171        operation_type: None,
172        operation_name: None,
173    };
174
175    // We need to clone the operation_name before parsing the query, else we're
176    // going to have a borrow conflict between request.parsed_query() and
177    // request.operation_name
178    let operation_name = request.operation_name.clone();
179    if let Ok(document) = request.parsed_query() {
180        match (&document.operations, operation_name) {
181            // A single anonymous operation, with no name requested: the
182            // document defines no name for it, so we only record the type.
183            (DocumentOperations::Single(operation), None) => {
184                span.record("otel.name", format!("GraphQL {}", operation.node.ty));
185                span.record(
186                    GRAPHQL_OPERATION_TYPE,
187                    tracing::field::display(operation.node.ty),
188                );
189                graphql_operation.operation_type = Some(operation.node.ty);
190            }
191
192            (DocumentOperations::Multiple(operations), Some(name)) => {
193                if let Some((name, operation)) = operations.get_key_value(name.as_str()) {
194                    span.record(
195                        "otel.name",
196                        format!("GraphQL {} {}", operation.node.ty, name),
197                    );
198                    span.record(
199                        GRAPHQL_OPERATION_TYPE,
200                        tracing::field::display(operation.node.ty),
201                    );
202                    span.record(GRAPHQL_OPERATION_NAME, tracing::field::display(name));
203                    graphql_operation.operation_type = Some(operation.node.ty);
204                    graphql_operation.operation_name = Some(name.to_string());
205                }
206            }
207
208            (DocumentOperations::Multiple(operations), None) if operations.len() == 1 => {
209                let mut iter = operations.iter();
210                let (name, operation) = iter.next().unwrap();
211                span.record(
212                    "otel.name",
213                    format!("GraphQL {} {}", operation.node.ty, name),
214                );
215                span.record(
216                    GRAPHQL_OPERATION_TYPE,
217                    tracing::field::display(operation.node.ty),
218                );
219                span.record(GRAPHQL_OPERATION_NAME, name.as_ref());
220                graphql_operation.operation_type = Some(operation.node.ty);
221                graphql_operation.operation_name = Some(name.to_string());
222            }
223
224            // Cases the executor rejects, so we don't record a misleading
225            // operation: a single anonymous operation with a name requested, or
226            // several named operations with no requested name (ambiguous).
227            (DocumentOperations::Single(_), Some(_)) | (DocumentOperations::Multiple(_), None) => {}
228        }
229    }
230
231    (span, graphql_operation)
232}
233
234/// The GraphQL operation being executed, attached to the response extensions so
235/// the HTTP logging middleware can record it on the request log line.
236#[derive(Clone, Debug)]
237pub struct GraphQLOperation {
238    /// The type of the operation: query, mutation or subscription.
239    pub operation_type: Option<OperationType>,
240    /// The name of the operation, as defined in the query document.
241    pub operation_name: Option<String>,
242}
243
244#[derive(thiserror::Error, Debug)]
245pub enum RouteError {
246    #[error(transparent)]
247    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
248
249    #[error("Loading of some database objects failed")]
250    LoadFailed,
251
252    #[error("Invalid access token")]
253    InvalidToken,
254
255    #[error("Missing scope")]
256    MissingScope,
257
258    #[error(transparent)]
259    ParseRequest(#[from] async_graphql::ParseRequestError),
260}
261
262impl_from_error_for_route!(mas_storage::RepositoryError);
263
264impl IntoResponse for RouteError {
265    fn into_response(self) -> Response {
266        let sentry_event_id = record_error!(self, Self::Internal(_) | Self::LoadFailed);
267
268        let response = match self {
269            e @ (Self::Internal(_) | Self::LoadFailed) => {
270                let error = async_graphql::Error::new_with_source(e);
271                (
272                    StatusCode::INTERNAL_SERVER_ERROR,
273                    Json(serde_json::json!({"errors": [error]})),
274                )
275                    .into_response()
276            }
277
278            Self::InvalidToken => {
279                let error = async_graphql::Error::new("Invalid token");
280                (
281                    StatusCode::UNAUTHORIZED,
282                    Json(serde_json::json!({"errors": [error]})),
283                )
284                    .into_response()
285            }
286
287            Self::MissingScope => {
288                let error = async_graphql::Error::new("Missing urn:mas:graphql:* scope");
289                (
290                    StatusCode::UNAUTHORIZED,
291                    Json(serde_json::json!({"errors": [error]})),
292                )
293                    .into_response()
294            }
295
296            Self::ParseRequest(e) => {
297                let error = async_graphql::Error::new_with_source(e);
298                (
299                    StatusCode::BAD_REQUEST,
300                    Json(serde_json::json!({"errors": [error]})),
301                )
302                    .into_response()
303            }
304        };
305
306        (sentry_event_id, response).into_response()
307    }
308}
309
310async fn get_requester(
311    undocumented_oauth2_access: bool,
312    clock: &impl Clock,
313    activity_tracker: &BoundActivityTracker,
314    mut repo: BoxRepository,
315    session_info: &SessionInfo,
316    user_agent: Option<String>,
317    token: Option<&str>,
318) -> Result<Requester, RouteError> {
319    let entity = if let Some(token) = token {
320        // If we haven't enabled undocumented_oauth2_access on the listener, we bail out
321        if !undocumented_oauth2_access {
322            return Err(RouteError::InvalidToken);
323        }
324
325        let token = repo
326            .oauth2_access_token()
327            .find_by_token(token)
328            .await?
329            .ok_or(RouteError::InvalidToken)?;
330
331        let session = repo
332            .oauth2_session()
333            .lookup(token.session_id)
334            .await?
335            .ok_or(RouteError::LoadFailed)?;
336
337        activity_tracker
338            .record_oauth2_session(clock, &session)
339            .await;
340
341        // Load the user if there is one
342        let user = if let Some(user_id) = session.user_id {
343            let user = repo
344                .user()
345                .lookup(user_id)
346                .await?
347                .ok_or(RouteError::LoadFailed)?;
348            Some(user)
349        } else {
350            None
351        };
352
353        // If there is a user for this session, check that it is not locked
354        let user_valid = user.as_ref().is_none_or(User::is_valid);
355
356        if !token.is_valid(clock.now()) || !session.is_valid() || !user_valid {
357            return Err(RouteError::InvalidToken);
358        }
359
360        if !session.scope.contains("urn:mas:graphql:*") {
361            return Err(RouteError::MissingScope);
362        }
363
364        if let Some(user) = &user {
365            user.maybe_record_as_requester();
366        }
367
368        RequestingEntity::OAuth2Session(Box::new((session, user)))
369    } else {
370        let maybe_session = session_info.load_active_session(&mut repo).await?;
371
372        if let Some(session) = maybe_session.as_ref() {
373            activity_tracker
374                .record_browser_session(clock, session)
375                .await;
376        }
377
378        RequestingEntity::from(maybe_session)
379    };
380
381    let requester = Requester {
382        entity,
383        ip_address: activity_tracker.ip(),
384        user_agent,
385    };
386
387    repo.cancel().await?;
388    Ok(requester)
389}
390
391/// Log the errors of a GraphQL response which were caused by an internal
392/// error, so that they get reported to Sentry.
393///
394/// Resolvers return internal errors through `?` on an [`anyhow::Error`] or a
395/// [`mas_storage::RepositoryError`], which async-graphql keeps as the error
396/// source. Other errors are user-facing and only logged by the tracing
397/// extension, so a new internal error type reaching a resolver through `?`
398/// has to be added here to be reported.
399fn record_internal_errors(response: &async_graphql::Response) {
400    for error in &response.errors {
401        let Some(source) = error.source.as_deref() else {
402            continue;
403        };
404
405        let source: &(dyn std::error::Error + 'static) =
406            if let Some(err) = source.downcast_ref::<anyhow::Error>() {
407                err.as_ref()
408            } else if let Some(err) = source.downcast_ref::<mas_storage::RepositoryError>() {
409                err
410            } else {
411                continue;
412            };
413
414        // Build a string of the path to where the error was, like "users.nodes.id",
415        // leaving out the array indices in the path if any.
416        let path = error
417            .path
418            .iter()
419            .filter_map(|segment| match segment {
420                async_graphql::PathSegment::Field(name) => Some(name.as_str()),
421                async_graphql::PathSegment::Index(_) => None,
422            })
423            .collect::<Vec<_>>()
424            .join(".");
425
426        tracing::error!(
427            error = source,
428            tags.graphql.path = path,
429            "Internal error while resolving a GraphQL request"
430        );
431    }
432}
433
434pub async fn post(
435    AxumState(schema): AxumState<Schema>,
436    Extension(ExtraRouterParameters {
437        undocumented_oauth2_access,
438    }): Extension<ExtraRouterParameters>,
439    clock: BoxClock,
440    repo: BoxRepository,
441    activity_tracker: BoundActivityTracker,
442    cookie_jar: CookieJar,
443    content_type: Option<TypedHeader<ContentType>>,
444    authorization: Option<TypedHeader<Authorization<Bearer>>>,
445    user_agent: Option<TypedHeader<headers::UserAgent>>,
446    body: Body,
447) -> Result<impl IntoResponse, RouteError> {
448    let body = body.into_data_stream();
449    let token = authorization
450        .as_ref()
451        .map(|TypedHeader(Authorization(bearer))| bearer.token());
452    let user_agent = user_agent.map(|TypedHeader(h)| h.to_string());
453    let (session_info, mut cookie_jar) = cookie_jar.session_info();
454    let requester = get_requester(
455        undocumented_oauth2_access,
456        &clock,
457        &activity_tracker,
458        repo,
459        &session_info,
460        user_agent,
461        token,
462    )
463    .await?;
464
465    let content_type = content_type.map(|TypedHeader(h)| h.to_string());
466
467    let mut request = async_graphql::http::receive_body(
468        content_type,
469        body.map_err(std::io::Error::other).into_async_read(),
470        MultipartOptions::default(),
471    )
472    .await?
473    .data(requester); // XXX: this should probably return another error response?
474
475    let (span, operation) = span_and_operation_for_graphql_request(&mut request);
476    let mut response = schema.execute(request).instrument(span).await;
477    record_internal_errors(&response);
478
479    if has_session_ended(&mut response) {
480        let session_info = session_info.mark_session_ended(clock.now());
481        cookie_jar = cookie_jar.update_session_info(&session_info);
482    }
483
484    let cache_control = response
485        .cache_control
486        .value()
487        .and_then(|v| HeaderValue::from_str(&v).ok())
488        .map(|h| [(CACHE_CONTROL, h)]);
489
490    let headers = response.http_headers.clone();
491
492    Ok((
493        headers,
494        cache_control,
495        cookie_jar,
496        Extension(operation),
497        Json(response),
498    ))
499}
500
501pub async fn get(
502    AxumState(schema): AxumState<Schema>,
503    Extension(ExtraRouterParameters {
504        undocumented_oauth2_access,
505    }): Extension<ExtraRouterParameters>,
506    clock: BoxClock,
507    repo: BoxRepository,
508    activity_tracker: BoundActivityTracker,
509    cookie_jar: CookieJar,
510    authorization: Option<TypedHeader<Authorization<Bearer>>>,
511    user_agent: Option<TypedHeader<headers::UserAgent>>,
512    RawQuery(query): RawQuery,
513) -> Result<impl IntoResponse, InternalError> {
514    let token = authorization
515        .as_ref()
516        .map(|TypedHeader(Authorization(bearer))| bearer.token());
517    let user_agent = user_agent.map(|TypedHeader(h)| h.to_string());
518    let (session_info, mut cookie_jar) = cookie_jar.session_info();
519    let requester = get_requester(
520        undocumented_oauth2_access,
521        &clock,
522        &activity_tracker,
523        repo,
524        &session_info,
525        user_agent,
526        token,
527    )
528    .await?;
529
530    let mut request =
531        async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester);
532
533    let (span, operation) = span_and_operation_for_graphql_request(&mut request);
534    let mut response = schema.execute(request).instrument(span).await;
535    record_internal_errors(&response);
536
537    if has_session_ended(&mut response) {
538        let session_info = session_info.mark_session_ended(clock.now());
539        cookie_jar = cookie_jar.update_session_info(&session_info);
540    }
541
542    let cache_control = response
543        .cache_control
544        .value()
545        .and_then(|v| HeaderValue::from_str(&v).ok())
546        .map(|h| [(CACHE_CONTROL, h)]);
547
548    let headers = response.http_headers.clone();
549
550    Ok((
551        headers,
552        cache_control,
553        cookie_jar,
554        Extension(operation),
555        Json(response),
556    ))
557}
558
559pub type Schema = async_graphql::Schema<Query, Mutation, EmptySubscription>;
560pub type SchemaBuilder = async_graphql::SchemaBuilder<Query, Mutation, EmptySubscription>;
561
562#[must_use]
563pub fn schema_builder() -> SchemaBuilder {
564    async_graphql::Schema::build(Query::new(), Mutation::new(), EmptySubscription)
565        .register_output_type::<Node>()
566        .register_output_type::<CreationEvent>()
567}
568
569pub struct Requester {
570    entity: RequestingEntity,
571    ip_address: Option<IpAddr>,
572    user_agent: Option<String>,
573}
574
575impl Requester {
576    pub fn fingerprint(&self) -> RequesterFingerprint {
577        if let Some(ip) = self.ip_address {
578            RequesterFingerprint::new(ip)
579        } else {
580            RequesterFingerprint::EMPTY
581        }
582    }
583
584    pub fn for_policy(&self) -> mas_policy::Requester {
585        mas_policy::Requester {
586            ip_address: self.ip_address,
587            user_agent: self.user_agent.clone(),
588        }
589    }
590}
591
592impl Deref for Requester {
593    type Target = RequestingEntity;
594
595    fn deref(&self) -> &Self::Target {
596        &self.entity
597    }
598}
599
600/// The identity of the requester.
601#[derive(Debug, Clone, Default, PartialEq, Eq)]
602pub enum RequestingEntity {
603    /// The requester presented no authentication information.
604    #[default]
605    Anonymous,
606
607    /// The requester is a browser session, stored in a cookie.
608    BrowserSession(Box<BrowserSession>),
609
610    /// The requester is a `OAuth2` session, with an access token.
611    OAuth2Session(Box<(Session, Option<User>)>),
612}
613
614trait OwnerId {
615    fn owner_id(&self) -> Option<Ulid>;
616}
617
618impl OwnerId for User {
619    fn owner_id(&self) -> Option<Ulid> {
620        Some(self.id)
621    }
622}
623
624impl OwnerId for BrowserSession {
625    fn owner_id(&self) -> Option<Ulid> {
626        Some(self.user.id)
627    }
628}
629
630impl OwnerId for mas_data_model::UserEmail {
631    fn owner_id(&self) -> Option<Ulid> {
632        Some(self.user_id)
633    }
634}
635
636impl OwnerId for Session {
637    fn owner_id(&self) -> Option<Ulid> {
638        self.user_id
639    }
640}
641
642impl OwnerId for mas_data_model::CompatSession {
643    fn owner_id(&self) -> Option<Ulid> {
644        Some(self.user_id)
645    }
646}
647
648impl OwnerId for mas_data_model::UpstreamOAuthLink {
649    fn owner_id(&self) -> Option<Ulid> {
650        self.user_id
651    }
652}
653
654/// A dumb wrapper around a `Ulid` to implement `OwnerId` for it.
655pub struct UserId(Ulid);
656
657impl OwnerId for UserId {
658    fn owner_id(&self) -> Option<Ulid> {
659        Some(self.0)
660    }
661}
662
663impl RequestingEntity {
664    fn browser_session(&self) -> Option<&BrowserSession> {
665        match self {
666            Self::BrowserSession(session) => Some(session),
667            Self::OAuth2Session(_) | Self::Anonymous => None,
668        }
669    }
670
671    fn user(&self) -> Option<&User> {
672        match self {
673            Self::BrowserSession(session) => Some(&session.user),
674            Self::OAuth2Session(tuple) => tuple.1.as_ref(),
675            Self::Anonymous => None,
676        }
677    }
678
679    fn oauth2_session(&self) -> Option<&Session> {
680        match self {
681            Self::OAuth2Session(tuple) => Some(&tuple.0),
682            Self::BrowserSession(_) | Self::Anonymous => None,
683        }
684    }
685
686    /// Returns true if the requester can access the resource.
687    fn is_owner_or_admin(&self, resource: &impl OwnerId) -> bool {
688        // If the requester is an admin, they can do anything.
689        if self.is_admin() {
690            return true;
691        }
692
693        // Otherwise, they must be the owner of the resource.
694        let Some(owner_id) = resource.owner_id() else {
695            return false;
696        };
697
698        let Some(user) = self.user() else {
699            return false;
700        };
701
702        user.id == owner_id
703    }
704
705    fn is_admin(&self) -> bool {
706        match self {
707            Self::OAuth2Session(tuple) => {
708                // TODO: is this the right scope?
709                // This has to be in sync with the policy
710                tuple.0.scope.contains("urn:mas:admin")
711            }
712            Self::BrowserSession(_) | Self::Anonymous => false,
713        }
714    }
715}
716
717impl From<BrowserSession> for RequestingEntity {
718    fn from(session: BrowserSession) -> Self {
719        Self::BrowserSession(Box::new(session))
720    }
721}
722
723impl<T> From<Option<T>> for RequestingEntity
724where
725    T: Into<RequestingEntity>,
726{
727    fn from(session: Option<T>) -> Self {
728        session.map(Into::into).unwrap_or_default()
729    }
730}
731
732/// A filter for dates, with a lower bound and an upper bound
733#[derive(InputObject, Default, Clone, Copy)]
734pub struct DateFilter {
735    /// The lower bound of the date range
736    after: Option<DateTime<Utc>>,
737
738    /// The upper bound of the date range
739    before: Option<DateTime<Utc>>,
740}