From b7c6320016a81331ae12ee31cb3fa83159916060 Mon Sep 17 00:00:00 2001 From: Tonkku Date: Mon, 17 Mar 2025 16:33:49 +0000 Subject: [PATCH] Admin API endpoint to remove upstream link --- crates/handlers/src/admin/v1/mod.rs | 4 + .../admin/v1/upstream_oauth_links/delete.rs | 185 ++++++++++++++++++ .../src/admin/v1/upstream_oauth_links/mod.rs | 2 + docs/api/spec.json | 41 ++++ 4 files changed, 232 insertions(+) create mode 100644 crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 31d28c155..789bb6759 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -135,6 +135,10 @@ where get_with( self::upstream_oauth_links::get, self::upstream_oauth_links::get_doc, + ) + .delete_with( + self::upstream_oauth_links::delete, + self::upstream_oauth_links::delete_doc, ), ) } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs new file mode 100644 index 000000000..51540de0f --- /dev/null +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs @@ -0,0 +1,185 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use ulid::Ulid; + +use crate::{ + admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse}, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Upstream OAuth 2.0 Link ID {0} not found")] + NotFound(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::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("deleteUpstreamOAuthLink") + .summary("Delete an upstream OAuth 2.0 link") + .tag("upstream-oauth-link") + .response_with::<204, (), _>(|t| t.description("Upstream OAuth 2.0 link was deleted")) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Upstream OAuth 2.0 link was not found") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.delete", skip_all, err)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result { + let link = repo + .upstream_oauth_link() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + repo.upstream_oauth_link().remove(&clock, link).await?; + + repo.save().await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_data_model::UpstreamOAuthAuthorizationSessionState; + 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_delete(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(); + + // Pretend it was linked by an authorization session + let session = repo + .upstream_oauth_session() + .add( + &mut rng, + &state.clock, + &provider, + String::new(), + None, + String::new(), + ) + .await + .unwrap(); + + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + String::from("subject1"), + None, + ) + .await + .unwrap(); + + let session = repo + .upstream_oauth_session() + .complete_with_link(&state.clock, session, &link, None, None, None) + .await + .unwrap(); + + repo.upstream_oauth_link() + .associate_to_user(&link, &alice) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{}", link.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NO_CONTENT); + + // Verify that the link was deleted + let request = Request::get(format!("/api/admin/v1/upstream-oauth-links/{}", link.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + + // Verify that the session was marked as unlinked + let mut repo = state.repository().await.unwrap(); + let session = repo + .upstream_oauth_session() + .lookup(session.id) + .await + .unwrap() + .unwrap(); + assert!(matches!( + session.state, + UpstreamOAuthAuthorizationSessionState::Unlinked { .. } + )); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_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 link_id = Ulid::nil(); + let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{link_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::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 09203c3d1..12792e3a6 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -4,11 +4,13 @@ // Please see LICENSE in the repository root for full details. mod add; +mod delete; mod get; mod list; pub use self::{ add::{doc as add_doc, handler as add}, + delete::{doc as delete_doc, handler as delete}, 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 eb52d07ba..4ea86d162 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2440,6 +2440,47 @@ } } } + }, + "delete": { + "tags": [ + "upstream-oauth-link" + ], + "summary": "Delete an upstream OAuth 2.0 link", + "operationId": "deleteUpstreamOAuthLink", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "Upstream OAuth 2.0 link was deleted" + }, + "404": { + "description": "Upstream OAuth 2.0 link was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Upstream OAuth 2.0 Link ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } } } },