mas_handlers/admin/v1/upstream_oauth_links/
list.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6use aide::{OperationIo, transform::TransformOperation};
7use axum::{
8    Json,
9    extract::{Query, rejection::QueryRejection},
10    response::IntoResponse,
11};
12use axum_macros::FromRequestParts;
13use hyper::StatusCode;
14use mas_storage::{Page, upstream_oauth2::UpstreamOAuthLinkFilter};
15use schemars::JsonSchema;
16use serde::Deserialize;
17use ulid::Ulid;
18
19use crate::{
20    admin::{
21        call_context::CallContext,
22        model::{Resource, UpstreamOAuthLink},
23        params::Pagination,
24        response::{ErrorResponse, PaginatedResponse},
25    },
26    impl_from_error_for_route,
27};
28
29#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
30#[serde(rename = "UpstreamOAuthLinkFilter")]
31#[aide(input_with = "Query<FilterParams>")]
32#[from_request(via(Query), rejection(RouteError))]
33pub struct FilterParams {
34    /// Retrieve the items for the given user
35    #[serde(rename = "filter[user]")]
36    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
37    user: Option<Ulid>,
38
39    /// Retrieve the items for the given provider
40    #[serde(rename = "filter[provider]")]
41    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
42    provider: Option<Ulid>,
43
44    /// Retrieve the items with the given subject
45    #[serde(rename = "filter[subject]")]
46    subject: Option<String>,
47}
48
49impl std::fmt::Display for FilterParams {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        let mut sep = '?';
52
53        if let Some(user) = self.user {
54            write!(f, "{sep}filter[user]={user}")?;
55            sep = '&';
56        }
57
58        if let Some(provider) = self.provider {
59            write!(f, "{sep}filter[provider]={provider}")?;
60            sep = '&';
61        }
62
63        if let Some(subject) = &self.subject {
64            write!(f, "{sep}filter[subject]={subject}")?;
65            sep = '&';
66        }
67
68        let _ = sep;
69        Ok(())
70    }
71}
72
73#[derive(Debug, thiserror::Error, OperationIo)]
74#[aide(output_with = "Json<ErrorResponse>")]
75pub enum RouteError {
76    #[error(transparent)]
77    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
78
79    #[error("User ID {0} not found")]
80    UserNotFound(Ulid),
81
82    #[error("Provider ID {0} not found")]
83    ProviderNotFound(Ulid),
84
85    #[error("Invalid filter parameters")]
86    InvalidFilter(#[from] QueryRejection),
87}
88
89impl_from_error_for_route!(mas_storage::RepositoryError);
90
91impl IntoResponse for RouteError {
92    fn into_response(self) -> axum::response::Response {
93        let error = ErrorResponse::from_error(&self);
94        let status = match self {
95            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
96            Self::UserNotFound(_) | Self::ProviderNotFound(_) => StatusCode::NOT_FOUND,
97            Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
98        };
99        (status, Json(error)).into_response()
100    }
101}
102
103pub fn doc(operation: TransformOperation) -> TransformOperation {
104    operation
105        .id("listUpstreamOAuthLinks")
106        .summary("List upstream OAuth 2.0 links")
107        .description("Retrieve a list of upstream OAuth 2.0 links.")
108        .tag("upstream-oauth-link")
109        .response_with::<200, Json<PaginatedResponse<UpstreamOAuthLink>>, _>(|t| {
110            let links = UpstreamOAuthLink::samples();
111            let pagination = mas_storage::Pagination::first(links.len());
112            let page = Page {
113                edges: links.into(),
114                has_next_page: true,
115                has_previous_page: false,
116            };
117
118            t.description("Paginated response of upstream OAuth 2.0 links")
119                .example(PaginatedResponse::new(
120                    page,
121                    pagination,
122                    42,
123                    UpstreamOAuthLink::PATH,
124                ))
125        })
126        .response_with::<404, RouteError, _>(|t| {
127            let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
128            t.description("User or provider was not found")
129                .example(response)
130        })
131}
132
133#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.list", skip_all, err)]
134pub async fn handler(
135    CallContext { mut repo, .. }: CallContext,
136    Pagination(pagination): Pagination,
137    params: FilterParams,
138) -> Result<Json<PaginatedResponse<UpstreamOAuthLink>>, RouteError> {
139    let base = format!("{path}{params}", path = UpstreamOAuthLink::PATH);
140    let filter = UpstreamOAuthLinkFilter::default();
141
142    // Load the user from the filter
143    let maybe_user = if let Some(user_id) = params.user {
144        let user = repo
145            .user()
146            .lookup(user_id)
147            .await?
148            .ok_or(RouteError::UserNotFound(user_id))?;
149        Some(user)
150    } else {
151        None
152    };
153
154    let filter = if let Some(user) = &maybe_user {
155        filter.for_user(user)
156    } else {
157        filter
158    };
159
160    // Load the provider from the filter
161    let maybe_provider = if let Some(provider_id) = params.provider {
162        let provider = repo
163            .upstream_oauth_provider()
164            .lookup(provider_id)
165            .await?
166            .ok_or(RouteError::ProviderNotFound(provider_id))?;
167        Some(provider)
168    } else {
169        None
170    };
171
172    let filter = if let Some(provider) = &maybe_provider {
173        filter.for_provider(provider)
174    } else {
175        filter
176    };
177
178    let filter = if let Some(subject) = &params.subject {
179        filter.for_subject(subject)
180    } else {
181        filter
182    };
183
184    let page = repo.upstream_oauth_link().list(filter, pagination).await?;
185    let count = repo.upstream_oauth_link().count(filter).await?;
186
187    Ok(Json(PaginatedResponse::new(
188        page.map(UpstreamOAuthLink::from),
189        pagination,
190        count,
191        &base,
192    )))
193}
194
195#[cfg(test)]
196mod tests {
197    use hyper::{Request, StatusCode};
198    use insta::assert_json_snapshot;
199    use sqlx::PgPool;
200
201    use super::super::test_utils;
202    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
203
204    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
205    async fn test_list(pool: PgPool) {
206        setup();
207        let mut state = TestState::from_pool(pool).await.unwrap();
208        let token = state.token_with_scope("urn:mas:admin").await;
209        let mut rng = state.rng();
210
211        // Provision users and providers
212        let mut repo = state.repository().await.unwrap();
213        let alice = repo
214            .user()
215            .add(&mut rng, &state.clock, "alice".to_owned())
216            .await
217            .unwrap();
218        let bob = repo
219            .user()
220            .add(&mut rng, &state.clock, "bob".to_owned())
221            .await
222            .unwrap();
223        let provider1 = repo
224            .upstream_oauth_provider()
225            .add(
226                &mut rng,
227                &state.clock,
228                test_utils::oidc_provider_params("acme"),
229            )
230            .await
231            .unwrap();
232        let provider2 = repo
233            .upstream_oauth_provider()
234            .add(
235                &mut rng,
236                &state.clock,
237                test_utils::oidc_provider_params("example"),
238            )
239            .await
240            .unwrap();
241
242        // Create some links
243        let link1 = repo
244            .upstream_oauth_link()
245            .add(
246                &mut rng,
247                &state.clock,
248                &provider1,
249                "subject1".to_owned(),
250                Some("alice@acme".to_owned()),
251            )
252            .await
253            .unwrap();
254        repo.upstream_oauth_link()
255            .associate_to_user(&link1, &alice)
256            .await
257            .unwrap();
258        let link2 = repo
259            .upstream_oauth_link()
260            .add(
261                &mut rng,
262                &state.clock,
263                &provider2,
264                "subject2".to_owned(),
265                Some("alice@example".to_owned()),
266            )
267            .await
268            .unwrap();
269        repo.upstream_oauth_link()
270            .associate_to_user(&link2, &alice)
271            .await
272            .unwrap();
273        let link3 = repo
274            .upstream_oauth_link()
275            .add(
276                &mut rng,
277                &state.clock,
278                &provider1,
279                "subject3".to_owned(),
280                Some("bob@acme".to_owned()),
281            )
282            .await
283            .unwrap();
284        repo.upstream_oauth_link()
285            .associate_to_user(&link3, &bob)
286            .await
287            .unwrap();
288
289        repo.save().await.unwrap();
290
291        let request = Request::get("/api/admin/v1/upstream-oauth-links")
292            .bearer(&token)
293            .empty();
294        let response = state.request(request).await;
295        response.assert_status(StatusCode::OK);
296        let body: serde_json::Value = response.json();
297        assert_json_snapshot!(body, @r###"
298        {
299          "meta": {
300            "count": 3
301          },
302          "data": [
303            {
304              "type": "upstream-oauth-link",
305              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
306              "attributes": {
307                "created_at": "2022-01-16T14:40:00Z",
308                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
309                "subject": "subject1",
310                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
311                "human_account_name": "alice@acme"
312              },
313              "links": {
314                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
315              }
316            },
317            {
318              "type": "upstream-oauth-link",
319              "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4",
320              "attributes": {
321                "created_at": "2022-01-16T14:40:00Z",
322                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
323                "subject": "subject3",
324                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
325                "human_account_name": "bob@acme"
326              },
327              "links": {
328                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4"
329              }
330            },
331            {
332              "type": "upstream-oauth-link",
333              "id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
334              "attributes": {
335                "created_at": "2022-01-16T14:40:00Z",
336                "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
337                "subject": "subject2",
338                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
339                "human_account_name": "alice@example"
340              },
341              "links": {
342                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
343              }
344            }
345          ],
346          "links": {
347            "self": "/api/admin/v1/upstream-oauth-links?page[first]=10",
348            "first": "/api/admin/v1/upstream-oauth-links?page[first]=10",
349            "last": "/api/admin/v1/upstream-oauth-links?page[last]=10"
350          }
351        }
352        "###);
353
354        // Filter by user ID
355        let request = Request::get(format!(
356            "/api/admin/v1/upstream-oauth-links?filter[user]={}",
357            alice.id
358        ))
359        .bearer(&token)
360        .empty();
361
362        let response = state.request(request).await;
363        response.assert_status(StatusCode::OK);
364        let body: serde_json::Value = response.json();
365        assert_json_snapshot!(body, @r###"
366        {
367          "meta": {
368            "count": 2
369          },
370          "data": [
371            {
372              "type": "upstream-oauth-link",
373              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
374              "attributes": {
375                "created_at": "2022-01-16T14:40:00Z",
376                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
377                "subject": "subject1",
378                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
379                "human_account_name": "alice@acme"
380              },
381              "links": {
382                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
383              }
384            },
385            {
386              "type": "upstream-oauth-link",
387              "id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
388              "attributes": {
389                "created_at": "2022-01-16T14:40:00Z",
390                "provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
391                "subject": "subject2",
392                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
393                "human_account_name": "alice@example"
394              },
395              "links": {
396                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
397              }
398            }
399          ],
400          "links": {
401            "self": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
402            "first": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
403            "last": "/api/admin/v1/upstream-oauth-links?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
404          }
405        }
406        "###);
407
408        // Filter by provider
409        let request = Request::get(format!(
410            "/api/admin/v1/upstream-oauth-links?filter[provider]={}",
411            provider1.id
412        ))
413        .bearer(&token)
414        .empty();
415
416        let response = state.request(request).await;
417        response.assert_status(StatusCode::OK);
418        let body: serde_json::Value = response.json();
419        assert_json_snapshot!(body, @r###"
420        {
421          "meta": {
422            "count": 2
423          },
424          "data": [
425            {
426              "type": "upstream-oauth-link",
427              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
428              "attributes": {
429                "created_at": "2022-01-16T14:40:00Z",
430                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
431                "subject": "subject1",
432                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
433                "human_account_name": "alice@acme"
434              },
435              "links": {
436                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
437              }
438            },
439            {
440              "type": "upstream-oauth-link",
441              "id": "01FSHN9AG0PJZ6DZNTAA1XKPT4",
442              "attributes": {
443                "created_at": "2022-01-16T14:40:00Z",
444                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
445                "subject": "subject3",
446                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
447                "human_account_name": "bob@acme"
448              },
449              "links": {
450                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0PJZ6DZNTAA1XKPT4"
451              }
452            }
453          ],
454          "links": {
455            "self": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[first]=10",
456            "first": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[first]=10",
457            "last": "/api/admin/v1/upstream-oauth-links?filter[provider]=01FSHN9AG09NMZYX8MFYH578R9&page[last]=10"
458          }
459        }
460        "###);
461
462        // Filter by subject
463        let request = Request::get(format!(
464            "/api/admin/v1/upstream-oauth-links?filter[subject]={}",
465            "subject1"
466        ))
467        .bearer(&token)
468        .empty();
469
470        let response = state.request(request).await;
471        response.assert_status(StatusCode::OK);
472        let body: serde_json::Value = response.json();
473        assert_json_snapshot!(body, @r###"
474        {
475          "meta": {
476            "count": 1
477          },
478          "data": [
479            {
480              "type": "upstream-oauth-link",
481              "id": "01FSHN9AG0AQZQP8DX40GD59PW",
482              "attributes": {
483                "created_at": "2022-01-16T14:40:00Z",
484                "provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
485                "subject": "subject1",
486                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
487                "human_account_name": "alice@acme"
488              },
489              "links": {
490                "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
491              }
492            }
493          ],
494          "links": {
495            "self": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[first]=10",
496            "first": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[first]=10",
497            "last": "/api/admin/v1/upstream-oauth-links?filter[subject]=subject1&page[last]=10"
498          }
499        }
500        "###);
501    }
502}