Skip to content

Commit 2f06ce1

Browse files
committed
controllers/user/admin: add an unlock route
1 parent bc5d6a4 commit 2f06ce1

11 files changed

+223
-3
lines changed

src/controllers/user/admin.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use axum::{extract::Path, Json};
2-
use chrono::NaiveDateTime;
2+
use chrono::{NaiveDateTime, Utc};
33
use crates_io_database::schema::{emails, users};
44
use diesel::{pg::Pg, prelude::*};
55
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection, RunQueryDsl};
@@ -102,6 +102,54 @@ pub async fn lock(
102102
Ok(Json(user))
103103
}
104104

105+
/// Unlock the given user.
106+
///
107+
/// Only site admins can use this endpoint.
108+
#[utoipa::path(
109+
delete,
110+
path = "/api/v1/users/{user}/lock",
111+
params(
112+
("user" = String, Path, description = "Login name of the user"),
113+
),
114+
tags = ["admin", "users"],
115+
responses((status = 200, description = "Successful Response")),
116+
)]
117+
pub async fn unlock(
118+
state: AppState,
119+
Path(user_name): Path<String>,
120+
req: Parts,
121+
) -> AppResult<Json<EncodableAdminUser>> {
122+
let mut conn = state.db_read_prefer_primary().await?;
123+
AuthCheck::only_cookie()
124+
.require_admin()
125+
.check(&req, &mut conn)
126+
.await?;
127+
128+
// Again, let's do this in a transaction, even though we _technically_ don't
129+
// need to.
130+
let user = conn
131+
.transaction(|conn| {
132+
// Although this is called via the `DELETE` method, this is
133+
// implemented as a soft deletion by setting the lock until time to
134+
// now, thereby allowing us to have some sense of history of whether
135+
// an account has been locked in the past.
136+
async move {
137+
let id = diesel::update(users::table)
138+
.filter(lower(users::gh_login).eq(lower(user_name)))
139+
.set(users::account_lock_until.eq(Utc::now().naive_utc()))
140+
.returning(users::id)
141+
.get_result::<i32>(conn)
142+
.await?;
143+
144+
get_user(|query| query.filter(users::id.eq(id)), conn).await
145+
}
146+
.scope_boxed()
147+
})
148+
.await?;
149+
150+
Ok(Json(user))
151+
}
152+
105153
/// A helper to get an [`EncodableAdminUser`] based on whatever filter predicate
106154
/// is provided in the callback.
107155
///

src/router.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
6262
.routes(routes!(user::me::get_authenticated_user))
6363
.routes(routes!(user::me::get_authenticated_user_updates))
6464
.routes(routes!(user::admin::get))
65-
.routes(routes!(user::admin::lock))
65+
.routes(routes!(user::admin::lock, user::admin::unlock))
6666
.routes(routes!(token::list_api_tokens, token::create_api_token))
6767
.routes(routes!(token::find_api_token, token::revoke_api_token))
6868
.routes(routes!(token::revoke_current_api_token))

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1701,6 +1701,31 @@ expression: response.json()
17011701
}
17021702
},
17031703
"/api/v1/users/{user}/lock": {
1704+
"delete": {
1705+
"description": "Only site admins can use this endpoint.",
1706+
"operationId": "unlock",
1707+
"parameters": [
1708+
{
1709+
"description": "Login name of the user",
1710+
"in": "path",
1711+
"name": "user",
1712+
"required": true,
1713+
"schema": {
1714+
"type": "string"
1715+
}
1716+
}
1717+
],
1718+
"responses": {
1719+
"200": {
1720+
"description": "Successful Response"
1721+
}
1722+
},
1723+
"summary": "Unlock the given user.",
1724+
"tags": [
1725+
"admin",
1726+
"users"
1727+
]
1728+
},
17041729
"put": {
17051730
"description": "Only site admins can use this endpoint.",
17061731
"operationId": "lock",

src/tests/routes/users/admin.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use chrono::DateTime;
1+
use chrono::{DateTime, Utc};
2+
use crates_io_database::schema::users;
23
use http::StatusCode;
34
use insta::{assert_json_snapshot, assert_snapshot};
45
use serde_json::json;
@@ -155,6 +156,76 @@ mod lock {
155156
}
156157
}
157158

159+
mod unlock {
160+
use super::*;
161+
162+
#[tokio::test(flavor = "multi_thread")]
163+
async fn unlock() {
164+
let (app, anon, user) = TestApp::init().with_user().await;
165+
let admin = app.db_new_admin_user("admin").await;
166+
167+
use diesel::prelude::*;
168+
use diesel_async::RunQueryDsl;
169+
170+
// First up, let's lock the user.
171+
let mut conn = app.db_conn().await;
172+
diesel::update(user.as_model())
173+
.set((
174+
users::account_lock_reason.eq("naughty naughty"),
175+
users::account_lock_until.eq(DateTime::parse_from_rfc3339("2050-01-01T01:02:03Z")
176+
.unwrap()
177+
.naive_utc()),
178+
))
179+
.execute(&mut conn)
180+
.await
181+
.unwrap();
182+
183+
// Anonymous users should be forbidden.
184+
let response = anon.delete::<()>("/api/v1/users/foo/lock").await;
185+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
186+
assert_snapshot!("anonymous-found", response.text());
187+
188+
let response = anon.delete::<()>("/api/v1/users/bar/lock").await;
189+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
190+
assert_snapshot!("anonymous-not-found", response.text());
191+
192+
// Regular users should also be forbidden, even if they're locking
193+
// themself.
194+
let response = user.delete::<()>("/api/v1/users/foo/lock").await;
195+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
196+
assert_snapshot!("non-admin-found", response.text());
197+
198+
let response = user.delete::<()>("/api/v1/users/bar/lock").await;
199+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
200+
assert_snapshot!("non-admin-not-found", response.text());
201+
202+
// Admin users are allowed, but still can't manifest users who don't
203+
// exist.
204+
let response = admin.delete::<()>("/api/v1/users/bar/lock").await;
205+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
206+
assert_snapshot!("admin-not-found", response.text());
207+
208+
// Admin users are allowed, and should be able to unlock the user.
209+
let response = admin.delete::<()>("/api/v1/users/foo/lock").await;
210+
assert_eq!(response.status(), StatusCode::OK);
211+
assert_json_snapshot!("admin-found", response.json(), {
212+
".lock.until" => "[datetime]",
213+
});
214+
215+
// Get the user again and validate that they are now unlocked.
216+
let mut conn = app.db_conn().await;
217+
let unlocked_user = User::find(&mut conn, user.as_model().id).await.unwrap();
218+
assert_user_is_unlocked(&unlocked_user);
219+
220+
// Unlocking an unlocked user should succeed silently.
221+
let response = admin.delete::<()>("/api/v1/users/foo/lock").await;
222+
assert_eq!(response.status(), StatusCode::OK);
223+
assert_json_snapshot!("admin-reunlock", response.json(), {
224+
".lock.until" => "[datetime]",
225+
});
226+
}
227+
}
228+
158229
#[track_caller]
159230
fn assert_user_is_locked(user: &User, reason: &str, until: &str) {
160231
assert_eq!(user.account_lock_reason.as_deref(), Some(reason));
@@ -169,3 +240,14 @@ fn assert_user_is_locked_indefinitely(user: &User, reason: &str) {
169240
assert_eq!(user.account_lock_reason.as_deref(), Some(reason));
170241
assert_none!(user.account_lock_until);
171242
}
243+
244+
#[track_caller]
245+
fn assert_user_is_unlocked(user: &User) {
246+
if user.account_lock_reason.is_some() {
247+
if let Some(until) = user.account_lock_until {
248+
assert_lt!(until, Utc::now().naive_utc());
249+
} else {
250+
panic!("user account is locked indefinitely");
251+
}
252+
}
253+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.json()
4+
---
5+
{
6+
"avatar": null,
7+
"email": "[email protected]",
8+
"email_verification_sent": true,
9+
"email_verified": true,
10+
"id": 1,
11+
"is_admin": false,
12+
"lock": {
13+
"reason": "naughty naughty",
14+
"until": "[datetime]"
15+
},
16+
"login": "foo",
17+
"name": null,
18+
"publish_notifications": true,
19+
"url": "https://github.com/foo"
20+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"Not Found"}]}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.json()
4+
---
5+
{
6+
"avatar": null,
7+
"email": "[email protected]",
8+
"email_verification_sent": true,
9+
"email_verified": true,
10+
"id": 1,
11+
"is_admin": false,
12+
"lock": {
13+
"reason": "naughty naughty",
14+
"until": "[datetime]"
15+
},
16+
"login": "foo",
17+
"name": null,
18+
"publish_notifications": true,
19+
"url": "https://github.com/foo"
20+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"this action requires authentication"}]}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"this action requires authentication"}]}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: src/tests/routes/users/admin.rs
3+
expression: response.text()
4+
---
5+
{"errors":[{"detail":"This account is locked until 2050-01-01 at 01:02:03 UTC. Reason: naughty naughty"}]}

0 commit comments

Comments
 (0)