mas_handlers/admin/
mod.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use std::sync::Arc;
8
9use aide::{
10    axum::ApiRouter,
11    openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server, Tag},
12    transform::TransformOpenApi,
13};
14use axum::{
15    Json, Router,
16    extract::{FromRef, FromRequestParts, State},
17    http::HeaderName,
18    response::Html,
19};
20use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
21use indexmap::IndexMap;
22use mas_axum_utils::InternalError;
23use mas_data_model::{AppVersion, BoxRng, SiteConfig};
24use mas_http::CorsLayerExt;
25use mas_matrix::HomeserverConnection;
26use mas_policy::PolicyFactory;
27use mas_router::{
28    ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
29    UrlBuilder,
30};
31use mas_templates::{ApiDocContext, Templates};
32use schemars::transform::AddNullable;
33use tower_http::cors::{Any, CorsLayer};
34
35mod call_context;
36mod model;
37mod params;
38mod response;
39mod schema;
40mod v1;
41
42use self::call_context::CallContext;
43use crate::passwords::PasswordManager;
44
45fn finish(t: TransformOpenApi) -> TransformOpenApi {
46    t.title("Matrix Authentication Service admin API")
47        .tag(Tag {
48            name: "server".to_owned(),
49            description: Some("Information about the server".to_owned()),
50            ..Tag::default()
51        })
52        .tag(Tag {
53            name: "compat-session".to_owned(),
54            description: Some("Manage compatibility sessions from legacy clients".to_owned()),
55            ..Tag::default()
56        })
57        .tag(Tag {
58            name: "policy-data".to_owned(),
59            description: Some("Manage the dynamic policy data".to_owned()),
60            ..Tag::default()
61        })
62        .tag(Tag {
63            name: "oauth2-session".to_owned(),
64            description: Some("Manage OAuth2 sessions".to_owned()),
65            ..Tag::default()
66        })
67        .tag(Tag {
68            name: "user".to_owned(),
69            description: Some("Manage users".to_owned()),
70            ..Tag::default()
71        })
72        .tag(Tag {
73            name: "user-email".to_owned(),
74            description: Some("Manage emails associated with users".to_owned()),
75            ..Tag::default()
76        })
77        .tag(Tag {
78            name: "user-session".to_owned(),
79            description: Some("Manage browser sessions of users".to_owned()),
80            ..Tag::default()
81        })
82        .tag(Tag {
83            name: "user-registration-token".to_owned(),
84            description: Some("Manage user registration tokens".to_owned()),
85            ..Tag::default()
86        })
87        .tag(Tag {
88            name: "upstream-oauth-link".to_owned(),
89            description: Some(
90                "Manage links between local users and identities from upstream OAuth 2.0 providers"
91                    .to_owned(),
92            ),
93            ..Default::default()
94        })
95        .tag(Tag {
96            name: "upstream-oauth-provider".to_owned(),
97            description: Some("Manage upstream OAuth 2.0 providers".to_owned()),
98            ..Tag::default()
99        })
100        .security_scheme("oauth2", oauth_security_scheme(None))
101        .security_scheme(
102            "token",
103            SecurityScheme::Http {
104                scheme: "bearer".to_owned(),
105                bearer_format: None,
106                description: Some("An access token with access to the admin API".to_owned()),
107                extensions: IndexMap::default(),
108            },
109        )
110        .security_requirement_scopes("oauth2", ["urn:mas:admin"])
111        .security_requirement_scopes("bearer", ["urn:mas:admin"])
112}
113
114fn oauth_security_scheme(url_builder: Option<&UrlBuilder>) -> SecurityScheme {
115    let (authorization_url, token_url) = if let Some(url_builder) = url_builder {
116        (
117            url_builder.oauth_authorization_endpoint().to_string(),
118            url_builder.oauth_token_endpoint().to_string(),
119        )
120    } else {
121        // This is a dirty fix for Swagger UI: when it joins the URLs with the
122        // base URL, if the path starts with a slash, it will go to the root of
123        // the domain instead of the API root.
124        // It works if we make it explicitly relative
125        (
126            format!(".{}", OAuth2AuthorizationEndpoint::PATH),
127            format!(".{}", OAuth2TokenEndpoint::PATH),
128        )
129    };
130
131    let scopes = IndexMap::from([(
132        "urn:mas:admin".to_owned(),
133        "Grant access to the admin API".to_owned(),
134    )]);
135
136    SecurityScheme::OAuth2 {
137        flows: OAuth2Flows {
138            client_credentials: Some(OAuth2Flow::ClientCredentials {
139                refresh_url: Some(token_url.clone()),
140                token_url: token_url.clone(),
141                scopes: scopes.clone(),
142            }),
143            authorization_code: Some(OAuth2Flow::AuthorizationCode {
144                authorization_url,
145                refresh_url: Some(token_url.clone()),
146                token_url,
147                scopes,
148            }),
149            implicit: None,
150            password: None,
151        },
152        description: None,
153        extensions: IndexMap::default(),
154    }
155}
156
157pub fn router<S>() -> (OpenApi, Router<S>)
158where
159    S: Clone + Send + Sync + 'static,
160    Arc<dyn HomeserverConnection>: FromRef<S>,
161    PasswordManager: FromRef<S>,
162    BoxRng: FromRequestParts<S>,
163    CallContext: FromRequestParts<S>,
164    Templates: FromRef<S>,
165    UrlBuilder: FromRef<S>,
166    Arc<PolicyFactory>: FromRef<S>,
167    SiteConfig: FromRef<S>,
168    AppVersion: FromRef<S>,
169{
170    // We *always* want to explicitly set the possible responses, beacuse the
171    // infered ones are not necessarily correct
172    aide::generate::infer_responses(false);
173
174    aide::generate::in_context(|ctx| {
175        ctx.schema = schemars::generate::SchemaGenerator::new(
176            schemars::generate::SchemaSettings::openapi3().with(|settings| {
177                // Remove the transform which adds nullable fields, as it's not
178                // valid with OpenAPI 3.1. For some reason, aide/schemars output
179                // an OpenAPI 3.1 schema with this nullable transform.
180                settings
181                    .transforms
182                    .retain(|transform| !transform.is::<AddNullable>());
183            }),
184        );
185    });
186
187    let mut api = OpenApi::default();
188    let router = ApiRouter::<S>::new()
189        .nest("/api/admin/v1", self::v1::router())
190        .finish_api_with(&mut api, finish);
191
192    let router = router
193        // Serve the OpenAPI spec as JSON
194        .route(
195            "/api/spec.json",
196            axum::routing::get({
197                let api = api.clone();
198                move |State(url_builder): State<UrlBuilder>| {
199                    // Let's set the servers to the HTTP base URL
200                    let mut api = api.clone();
201
202                    let _ = TransformOpenApi::new(&mut api)
203                        .server(Server {
204                            url: url_builder.http_base().to_string(),
205                            ..Server::default()
206                        })
207                        .security_scheme("oauth2", oauth_security_scheme(Some(&url_builder)));
208
209                    std::future::ready(Json(api))
210                }
211            }),
212        )
213        // Serve the Swagger API reference
214        .route(ApiDoc::route(), axum::routing::get(swagger))
215        .route(
216            ApiDocCallback::route(),
217            axum::routing::get(swagger_callback),
218        )
219        .layer(
220            CorsLayer::new()
221                .allow_origin(Any)
222                .allow_methods(Any)
223                .allow_otel_headers([
224                    AUTHORIZATION,
225                    ACCEPT,
226                    CONTENT_TYPE,
227                    // Swagger will send this header, so we have to allow it to avoid CORS errors
228                    HeaderName::from_static("x-requested-with"),
229                ]),
230        );
231
232    (api, router)
233}
234
235async fn swagger(
236    State(url_builder): State<UrlBuilder>,
237    State(templates): State<Templates>,
238) -> Result<Html<String>, InternalError> {
239    let ctx = ApiDocContext::from_url_builder(&url_builder);
240    let res = templates.render_swagger(&ctx)?;
241    Ok(Html(res))
242}
243
244async fn swagger_callback(
245    State(url_builder): State<UrlBuilder>,
246    State(templates): State<Templates>,
247) -> Result<Html<String>, InternalError> {
248    let ctx = ApiDocContext::from_url_builder(&url_builder);
249    let res = templates.render_swagger_callback(&ctx)?;
250    Ok(Html(res))
251}