Skip to content

Commit 272a9b8

Browse files
committed
Admin API endpoint to remove upstream link
1 parent 850a9ed commit 272a9b8

File tree

4 files changed

+232
-0
lines changed

4 files changed

+232
-0
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ where
135135
get_with(
136136
self::upstream_oauth_links::get,
137137
self::upstream_oauth_links::get_doc,
138+
)
139+
.delete_with(
140+
self::upstream_oauth_links::delete,
141+
self::upstream_oauth_links::delete_doc,
138142
),
139143
)
140144
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
6+
use aide::{OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use hyper::StatusCode;
9+
use ulid::Ulid;
10+
11+
use crate::{
12+
admin::{call_context::CallContext, params::UlidPathParam, response::ErrorResponse},
13+
impl_from_error_for_route,
14+
};
15+
16+
#[derive(Debug, thiserror::Error, OperationIo)]
17+
#[aide(output_with = "Json<ErrorResponse>")]
18+
pub enum RouteError {
19+
#[error(transparent)]
20+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
21+
22+
#[error("Upstream OAuth 2.0 Link ID {0} not found")]
23+
NotFound(Ulid),
24+
}
25+
26+
impl_from_error_for_route!(mas_storage::RepositoryError);
27+
28+
impl IntoResponse for RouteError {
29+
fn into_response(self) -> axum::response::Response {
30+
let error = ErrorResponse::from_error(&self);
31+
let status = match self {
32+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
33+
Self::NotFound(_) => StatusCode::NOT_FOUND,
34+
};
35+
(status, Json(error)).into_response()
36+
}
37+
}
38+
39+
pub fn doc(operation: TransformOperation) -> TransformOperation {
40+
operation
41+
.id("deleteUpstreamOAuthLink")
42+
.summary("Delete an upstream OAuth 2.0 link")
43+
.tag("upstream-oauth-link")
44+
.response_with::<204, (), _>(|t| t.description("Upstream OAuth 2.0 link was deleted"))
45+
.response_with::<404, RouteError, _>(|t| {
46+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
47+
t.description("Upstream OAuth 2.0 link was not found")
48+
.example(response)
49+
})
50+
}
51+
52+
#[tracing::instrument(name = "handler.admin.v1.upstream_oauth_links.delete", skip_all, err)]
53+
pub async fn handler(
54+
CallContext {
55+
mut repo, clock, ..
56+
}: CallContext,
57+
id: UlidPathParam,
58+
) -> Result<StatusCode, RouteError> {
59+
let link = repo
60+
.upstream_oauth_link()
61+
.lookup(*id)
62+
.await?
63+
.ok_or(RouteError::NotFound(*id))?;
64+
65+
repo.upstream_oauth_link().remove(&clock, link).await?;
66+
67+
repo.save().await?;
68+
69+
Ok(StatusCode::NO_CONTENT)
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use hyper::{Request, StatusCode};
75+
use mas_data_model::UpstreamOAuthAuthorizationSessionState;
76+
use sqlx::PgPool;
77+
use ulid::Ulid;
78+
79+
use super::super::test_utils;
80+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
81+
82+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
83+
async fn test_delete(pool: PgPool) {
84+
setup();
85+
let mut state = TestState::from_pool(pool).await.unwrap();
86+
let token = state.token_with_scope("urn:mas:admin").await;
87+
let mut rng = state.rng();
88+
let mut repo = state.repository().await.unwrap();
89+
90+
let alice = repo
91+
.user()
92+
.add(&mut rng, &state.clock, "alice".to_owned())
93+
.await
94+
.unwrap();
95+
96+
let provider = repo
97+
.upstream_oauth_provider()
98+
.add(
99+
&mut rng,
100+
&state.clock,
101+
test_utils::oidc_provider_params("provider1"),
102+
)
103+
.await
104+
.unwrap();
105+
106+
// Pretend it was linked by an authorization session
107+
let session = repo
108+
.upstream_oauth_session()
109+
.add(
110+
&mut rng,
111+
&state.clock,
112+
&provider,
113+
String::new(),
114+
None,
115+
String::new(),
116+
)
117+
.await
118+
.unwrap();
119+
120+
let link = repo
121+
.upstream_oauth_link()
122+
.add(
123+
&mut rng,
124+
&state.clock,
125+
&provider,
126+
String::from("subject1"),
127+
None,
128+
)
129+
.await
130+
.unwrap();
131+
132+
let session = repo
133+
.upstream_oauth_session()
134+
.complete_with_link(&state.clock, session, &link, None, None, None)
135+
.await
136+
.unwrap();
137+
138+
repo.upstream_oauth_link()
139+
.associate_to_user(&link, &alice)
140+
.await
141+
.unwrap();
142+
143+
repo.save().await.unwrap();
144+
145+
let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{}", link.id))
146+
.bearer(&token)
147+
.empty();
148+
let response = state.request(request).await;
149+
response.assert_status(StatusCode::NO_CONTENT);
150+
151+
// Verify that the link was deleted
152+
let request = Request::get(format!("/api/admin/v1/upstream-oauth-links/{}", link.id))
153+
.bearer(&token)
154+
.empty();
155+
let response = state.request(request).await;
156+
response.assert_status(StatusCode::NOT_FOUND);
157+
158+
// Verify that the session was marked as unlinked
159+
let mut repo = state.repository().await.unwrap();
160+
let session = repo
161+
.upstream_oauth_session()
162+
.lookup(session.id)
163+
.await
164+
.unwrap()
165+
.unwrap();
166+
assert!(matches!(
167+
session.state,
168+
UpstreamOAuthAuthorizationSessionState::Unlinked { .. }
169+
));
170+
}
171+
172+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
173+
async fn test_not_found(pool: PgPool) {
174+
setup();
175+
let mut state = TestState::from_pool(pool).await.unwrap();
176+
let token = state.token_with_scope("urn:mas:admin").await;
177+
178+
let link_id = Ulid::nil();
179+
let request = Request::delete(format!("/api/admin/v1/upstream-oauth-links/{link_id}"))
180+
.bearer(&token)
181+
.empty();
182+
let response = state.request(request).await;
183+
response.assert_status(StatusCode::NOT_FOUND);
184+
}
185+
}

crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
// Please see LICENSE in the repository root for full details.
55

66
mod add;
7+
mod delete;
78
mod get;
89
mod list;
910

1011
pub use self::{
1112
add::{doc as add_doc, handler as add},
13+
delete::{doc as delete_doc, handler as delete},
1214
get::{doc as get_doc, handler as get},
1315
list::{doc as list_doc, handler as list},
1416
};

docs/api/spec.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2440,6 +2440,47 @@
24402440
}
24412441
}
24422442
}
2443+
},
2444+
"delete": {
2445+
"tags": [
2446+
"upstream-oauth-link"
2447+
],
2448+
"summary": "Delete an upstream OAuth 2.0 link",
2449+
"operationId": "deleteUpstreamOAuthLink",
2450+
"parameters": [
2451+
{
2452+
"in": "path",
2453+
"name": "id",
2454+
"required": true,
2455+
"schema": {
2456+
"title": "The ID of the resource",
2457+
"$ref": "#/components/schemas/ULID"
2458+
},
2459+
"style": "simple"
2460+
}
2461+
],
2462+
"responses": {
2463+
"204": {
2464+
"description": "Upstream OAuth 2.0 link was deleted"
2465+
},
2466+
"404": {
2467+
"description": "Upstream OAuth 2.0 link was not found",
2468+
"content": {
2469+
"application/json": {
2470+
"schema": {
2471+
"$ref": "#/components/schemas/ErrorResponse"
2472+
},
2473+
"example": {
2474+
"errors": [
2475+
{
2476+
"title": "Upstream OAuth 2.0 Link ID 00000000000000000000000000 not found"
2477+
}
2478+
]
2479+
}
2480+
}
2481+
}
2482+
}
2483+
}
24432484
}
24442485
}
24452486
},

0 commit comments

Comments
 (0)