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