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