1use 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 #[serde(rename = "filter[user]")]
37 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
38 user: Option<Ulid>,
39
40 #[serde(rename = "filter[provider]")]
42 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
43 provider: Option<Ulid>,
44
45 #[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 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 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) = ¶ms.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 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 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 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 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 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}