diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 1fb11dec8..31d28c155 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -26,6 +26,7 @@ mod user_emails; mod user_sessions; mod users; +#[allow(clippy::too_many_lines)] pub fn router() -> ApiRouter where S: Clone + Send + Sync + 'static, @@ -123,6 +124,10 @@ where get_with( self::upstream_oauth_links::list, self::upstream_oauth_links::list_doc, + ) + .post_with( + self::upstream_oauth_links::add, + self::upstream_oauth_links::add_doc, ), ) .api_route( diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs new file mode 100644 index 000000000..867a5e557 --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/add.rs @@ -0,0 +1,368 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_storage::{BoxRng, upstream_oauth2::UpstreamOAuthLinkFilter}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UpstreamOAuthLink, User}, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} already has an upstream link for Upstream Oauth 2.0 Provider ID {1}")] + LinkAlreadyExists(Ulid, Ulid), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("Upstream OAuth 2.0 Provider ID {0} not found")] + ProviderNotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::LinkAlreadyExists(_, _) => StatusCode::CONFLICT, + Self::UserNotFound(_) | Self::ProviderNotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/upstream-oauth-links` +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "AddUpstreamOauthLinkRequest")] +pub struct Request { + /// The ID of the user to which the link should be added. + #[schemars(with = "crate::admin::schema::Ulid")] + user_id: Ulid, + + /// The ID of the upstream provider to which the link is for. + #[schemars(with = "crate::admin::schema::Ulid")] + provider_id: Ulid, + + /// The subject (sub) claim of the user on the provider. + subject: String, + + /// A human readable account name. + human_account_name: Option, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("addUpstreamOAuthLink") + .summary("Add an upstream OAuth 2.0 link") + .tag("upstream-oauth-link") + .response_with::<201, Json>, _>(|t| { + let [sample, ..] = UpstreamOAuthLink::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("Upstream OAuth 2.0 link was created") + .example(response) + }) + .response_with::<409, RouteError, _>(|t| { + let [provider_sample, ..] = UpstreamOAuthLink::samples(); + let [user_sample, ..] = User::samples(); + let response = ErrorResponse::from_error(&RouteError::LinkAlreadyExists( + user_sample.id(), + provider_sample.id(), + )); + t.description("User already has an upstream link for this provider") + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User or provider was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.post", skip_all, err)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + // Find the user + let user = repo + .user() + .lookup(params.user_id) + .await? + .ok_or(RouteError::UserNotFound(params.user_id))?; + + // Find the provider + let provider = repo + .upstream_oauth_provider() + .lookup(params.provider_id) + .await? + .ok_or(RouteError::ProviderNotFound(params.provider_id))?; + + let filter = UpstreamOAuthLinkFilter::new() + .for_user(&user) + .for_provider(&provider); + let count = repo.upstream_oauth_link().count(filter).await?; + + if count > 0 { + return Err(RouteError::LinkAlreadyExists( + params.user_id, + params.provider_id, + )); + } + + let mut link = repo + .upstream_oauth_link() + .add( + &mut rng, + &clock, + &provider, + params.subject, + params.human_account_name, + ) + .await?; + + repo.upstream_oauth_link() + .associate_to_user(&link, &user) + .await?; + link.user_id = Some(user.id); + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical(link.into())), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + use ulid::Ulid; + + use super::super::test_utils; + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + test_utils::oidc_provider_params("provider1"), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/upstream-oauth-links") + .bearer(&token) + .json(serde_json::json!({ + "user_id": alice.id, + "provider_id": provider.id, + "subject": "subject1" + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "data": { + "type": "upstream-oauth-link", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "provider_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "subject": "subject1", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "human_account_name": null + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_already_exists(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + test_utils::oidc_provider_params("provider1"), + ) + .await + .unwrap(); + + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + String::from("subject1"), + None, + ) + .await + .unwrap(); + + repo.upstream_oauth_link() + .associate_to_user(&link, &alice) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/upstream-oauth-links") + .bearer(&token) + .json(serde_json::json!({ + "user_id": alice.id, + "provider_id": provider.id, + "subject": "subject1" + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CONFLICT); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "User ID 01FSHN9AG0MZAA6S4AF7CTV32E already has an upstream link for Upstream Oauth 2.0 Provider ID 01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + ] + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + test_utils::oidc_provider_params("provider1"), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/upstream-oauth-links") + .bearer(&token) + .json(serde_json::json!({ + "user_id": Ulid::nil(), + "provider_id": provider.id, + "subject": "subject1" + })); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_provider_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::post("/api/admin/v1/upstream-oauth-links") + .bearer(&token) + .json(serde_json::json!({ + "user_id": alice.id, + "provider_id": Ulid::nil(), + "subject": "subject1" + })); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_json_snapshot!(body, @r###" + { + "errors": [ + { + "title": "Upstream OAuth 2.0 Provider ID 00000000000000000000000000 not found" + } + ] + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs index e6dcdd22d..09203c3d1 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -3,10 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +mod add; mod get; mod list; pub use self::{ + add::{doc as add_doc, handler as add}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/docs/api/spec.json b/docs/api/spec.json index d14b2c3f8..eb52d07ba 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2289,6 +2289,88 @@ } } } + }, + "post": { + "tags": [ + "upstream-oauth-link" + ], + "summary": "Add an upstream OAuth 2.0 link", + "operationId": "addUpstreamOAuthLink", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUpstreamOauthLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Upstream OAuth 2.0 link was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UpstreamOAuthLink" + }, + "example": { + "data": { + "type": "upstream-oauth-link", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "provider_id": "02081040G2081040G2081040G2", + "subject": "john-42", + "user_id": "030C1G60R30C1G60R30C1G60R3", + "human_account_name": "john.doe@example.com" + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/upstream-oauth-links/01040G2081040G2081040G2081" + } + } + } + } + }, + "409": { + "description": "User already has an upstream link for this provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 01040G2081040G2081040G2081 already has an upstream link for Upstream Oauth 2.0 Provider ID 01040G2081040G2081040G2081" + } + ] + } + } + } + }, + "404": { + "description": "User or provider was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } } }, "/api/admin/v1/upstream-oauth-links/{id}": { @@ -3504,6 +3586,34 @@ } } }, + "AddUpstreamOauthLinkRequest": { + "title": "JSON payload for the `POST /api/admin/v1/upstream-oauth-links`", + "type": "object", + "required": [ + "provider_id", + "subject", + "user_id" + ], + "properties": { + "user_id": { + "description": "The ID of the user to which the link should be added.", + "$ref": "#/components/schemas/ULID" + }, + "provider_id": { + "description": "The ID of the upstream provider to which the link is for.", + "$ref": "#/components/schemas/ULID" + }, + "subject": { + "description": "The subject (sub) claim of the user on the provider.", + "type": "string" + }, + "human_account_name": { + "description": "A human readable account name.", + "type": "string", + "nullable": true + } + } + }, "SingleResponse_for_UpstreamOAuthLink": { "description": "A top-level response with a single resource", "type": "object",