Skip to content

Commit bc5d6a4

Browse files
committed
controllers/user/admin: add a lock route
1 parent 64ee2c4 commit bc5d6a4

14 files changed

+348
-4
lines changed

src/controllers/user/admin.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
use axum::{extract::Path, Json};
2+
use chrono::NaiveDateTime;
23
use crates_io_database::schema::{emails, users};
34
use diesel::{pg::Pg, prelude::*};
4-
use diesel_async::{AsyncConnection, RunQueryDsl};
5+
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection, RunQueryDsl};
56
use http::request::Parts;
7+
use utoipa::ToSchema;
68

79
use crate::{
810
app::AppState, auth::AuthCheck, models::User, sql::lower, util::errors::AppResult,
9-
views::EncodableAdminUser,
11+
util::rfc3339, views::EncodableAdminUser,
1012
};
1113

1214
/// Find user by login, returning the admin's view of the user.
@@ -40,6 +42,66 @@ pub async fn get(
4042
.map(Json)
4143
}
4244

45+
#[derive(Deserialize, ToSchema)]
46+
pub struct LockRequest {
47+
/// The reason for locking the account. This is visible to the user.
48+
reason: String,
49+
50+
/// When to lock the account until. If omitted, the lock will be indefinite.
51+
#[serde(default, with = "rfc3339::option")]
52+
until: Option<NaiveDateTime>,
53+
}
54+
55+
/// Lock the given user.
56+
///
57+
/// Only site admins can use this endpoint.
58+
#[utoipa::path(
59+
put,
60+
path = "/api/v1/users/{user}/lock",
61+
params(
62+
("user" = String, Path, description = "Login name of the user"),
63+
),
64+
request_body = LockRequest,
65+
tags = ["admin", "users"],
66+
responses((status = 200, description = "Successful Response")),
67+
)]
68+
pub async fn lock(
69+
state: AppState,
70+
Path(user_name): Path<String>,
71+
req: Parts,
72+
Json(LockRequest { reason, until }): Json<LockRequest>,
73+
) -> AppResult<Json<EncodableAdminUser>> {
74+
let mut conn = state.db_read_prefer_primary().await?;
75+
AuthCheck::only_cookie()
76+
.require_admin()
77+
.check(&req, &mut conn)
78+
.await?;
79+
80+
// In theory, we could cook up a complicated update query that returns
81+
// everything we need to build an `EncodableAdminUser`, but that feels hard.
82+
// Instead, let's use a small transaction to get the same effect.
83+
let user = conn
84+
.transaction(|conn| {
85+
async move {
86+
let id = diesel::update(users::table)
87+
.filter(lower(users::gh_login).eq(lower(user_name)))
88+
.set((
89+
users::account_lock_reason.eq(reason),
90+
users::account_lock_until.eq(until),
91+
))
92+
.returning(users::id)
93+
.get_result::<i32>(conn)
94+
.await?;
95+
96+
get_user(|query| query.filter(users::id.eq(id)), conn).await
97+
}
98+
.scope_boxed()
99+
})
100+
.await?;
101+
102+
Ok(Json(user))
103+
}
104+
43105
/// A helper to get an [`EncodableAdminUser`] based on whatever filter predicate
44106
/// is provided in the callback.
45107
///

src/router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +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))
6566
.routes(routes!(token::list_api_tokens, token::create_api_token))
6667
.routes(routes!(token::find_api_token, token::revoke_api_token))
6768
.routes(routes!(token::revoke_current_api_token))

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,30 @@ source: src/openapi.rs
33
expression: response.json()
44
---
55
{
6-
"components": {},
6+
"components": {
7+
"schemas": {
8+
"LockRequest": {
9+
"properties": {
10+
"reason": {
11+
"description": "The reason for locking the account. This is visible to the user.",
12+
"type": "string"
13+
},
14+
"until": {
15+
"description": "When to lock the account until. If omitted, the lock will be indefinite.",
16+
"format": "date-time",
17+
"type": [
18+
"string",
19+
"null"
20+
]
21+
}
22+
},
23+
"required": [
24+
"reason"
25+
],
26+
"type": "object"
27+
}
28+
}
29+
},
730
"info": {
831
"contact": {
932
"email": "[email protected]",
@@ -1676,6 +1699,43 @@ expression: response.json()
16761699
"users"
16771700
]
16781701
}
1702+
},
1703+
"/api/v1/users/{user}/lock": {
1704+
"put": {
1705+
"description": "Only site admins can use this endpoint.",
1706+
"operationId": "lock",
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+
"requestBody": {
1719+
"content": {
1720+
"application/json": {
1721+
"schema": {
1722+
"$ref": "#/components/schemas/LockRequest"
1723+
}
1724+
}
1725+
},
1726+
"required": true
1727+
},
1728+
"responses": {
1729+
"200": {
1730+
"description": "Successful Response"
1731+
}
1732+
},
1733+
"summary": "Lock the given user.",
1734+
"tags": [
1735+
"admin",
1736+
"users"
1737+
]
1738+
}
16791739
}
16801740
},
16811741
"servers": [

src/tests/routes/users/admin.rs

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
use chrono::DateTime;
12
use http::StatusCode;
23
use insta::{assert_json_snapshot, assert_snapshot};
4+
use serde_json::json;
35

4-
use crate::tests::util::{RequestHelper, TestApp};
6+
use crate::{
7+
models::User,
8+
tests::util::{RequestHelper, TestApp},
9+
};
510

611
mod get {
712
use super::*;
@@ -43,3 +48,124 @@ mod get {
4348
assert_json_snapshot!("admin-found", response.json());
4449
}
4550
}
51+
52+
mod lock {
53+
use super::*;
54+
55+
#[tokio::test(flavor = "multi_thread")]
56+
async fn lock() {
57+
let (app, anon, user) = TestApp::init().with_user().await;
58+
let admin = app.db_new_admin_user("admin").await;
59+
60+
// Because axum will validate and deserialise the body before any auth
61+
// check occurs, we actually need to provide a valid body for all the
62+
// auth related test cases.
63+
let body = serde_json::to_string(&json!({
64+
"reason": "l33t h4x0r",
65+
"until": "2045-01-01T01:02:03Z",
66+
}))
67+
.unwrap();
68+
69+
// Anonymous users should be forbidden.
70+
let response = anon.put::<()>("/api/v1/users/foo/lock", body.clone()).await;
71+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
72+
assert_snapshot!("anonymous-found", response.text());
73+
74+
let response = anon.put::<()>("/api/v1/users/bar/lock", body.clone()).await;
75+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
76+
assert_snapshot!("anonymous-not-found", response.text());
77+
78+
// Regular users should also be forbidden, even if they're locking
79+
// themself.
80+
let response = user.put::<()>("/api/v1/users/foo/lock", body.clone()).await;
81+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
82+
assert_snapshot!("non-admin-found", response.text());
83+
84+
let response = user.put::<()>("/api/v1/users/bar/lock", body.clone()).await;
85+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
86+
assert_snapshot!("non-admin-not-found", response.text());
87+
88+
// Admin users are allowed, but still can't manifest users who don't
89+
// exist.
90+
let response = admin
91+
.put::<()>("/api/v1/users/bar/lock", body.clone())
92+
.await;
93+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
94+
assert_snapshot!("admin-not-found", response.text());
95+
96+
// Admin users who provide invalid request bodies should be denied.
97+
let response = admin
98+
.put::<()>("/api/v1/users/bar/lock", b"invalid JSON" as &[u8])
99+
.await;
100+
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
101+
assert_snapshot!("admin-invalid-json", response.text());
102+
103+
let response = admin
104+
.put::<()>("/api/v1/users/bar/lock", br#"{"valid": "json"}"# as &[u8])
105+
.await;
106+
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
107+
assert_snapshot!("admin-malformed-json", response.text());
108+
109+
// Admin users are allowed, and should be able to lock the user.
110+
assert_none!(&user.as_model().account_lock_reason);
111+
assert_none!(&user.as_model().account_lock_until);
112+
113+
let response = admin.put::<()>("/api/v1/users/foo/lock", body).await;
114+
assert_eq!(response.status(), StatusCode::OK);
115+
assert_json_snapshot!("admin-found", response.json());
116+
117+
// Get the user again and validate that they are now locked.
118+
let mut conn = app.db_conn().await;
119+
let locked_user = User::find(&mut conn, user.as_model().id).await.unwrap();
120+
assert_user_is_locked(&locked_user, "l33t h4x0r", "2045-01-01T01:02:03Z");
121+
122+
// Re-locking a locked user should update their lock reason and
123+
// expiration time.
124+
let body = serde_json::to_string(&json!({
125+
"reason": "less l33t",
126+
"until": "2035-01-01T01:02:03Z",
127+
}))
128+
.unwrap();
129+
130+
let response = admin.put::<()>("/api/v1/users/foo/lock", body).await;
131+
assert_eq!(response.status(), StatusCode::OK);
132+
assert_json_snapshot!("admin-relock-shorter", response.json());
133+
134+
// Get the user again and validate that they are now locked for less
135+
// time.
136+
let mut conn = app.db_conn().await;
137+
let locked_user = User::find(&mut conn, user.as_model().id).await.unwrap();
138+
assert_user_is_locked(&locked_user, "less l33t", "2035-01-01T01:02:03Z");
139+
140+
// Finally, not including an until time at all should lock the account
141+
// forever. (Insert evil laughter here.)
142+
let body = serde_json::to_string(&json!({
143+
"reason": "less l33t",
144+
}))
145+
.unwrap();
146+
147+
let response = admin.put::<()>("/api/v1/users/foo/lock", body).await;
148+
assert_eq!(response.status(), StatusCode::OK);
149+
assert_json_snapshot!("admin-lock-forever", response.json());
150+
151+
// Get the user again and validate that they are now locked forever.
152+
let mut conn = app.db_conn().await;
153+
let locked_user = User::find(&mut conn, user.as_model().id).await.unwrap();
154+
assert_user_is_locked_indefinitely(&locked_user, "less l33t");
155+
}
156+
}
157+
158+
#[track_caller]
159+
fn assert_user_is_locked(user: &User, reason: &str, until: &str) {
160+
assert_eq!(user.account_lock_reason.as_deref(), Some(reason));
161+
assert_eq!(
162+
user.account_lock_until,
163+
Some(DateTime::parse_from_rfc3339(until).unwrap().naive_utc())
164+
);
165+
}
166+
167+
#[track_caller]
168+
fn assert_user_is_locked_indefinitely(user: &User, reason: &str) {
169+
assert_eq!(user.account_lock_reason.as_deref(), Some(reason));
170+
assert_none!(user.account_lock_until);
171+
}
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": "l33t h4x0r",
14+
"until": "2045-01-01T01:02:03+00:00"
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":"Expected request with `Content-Type: application/json`"}]}
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": "less l33t",
14+
"until": null
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":"Failed to deserialize the JSON body into the target type: missing field `reason` at line 1 column 17"}]}
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": "less l33t",
14+
"until": "2035-01-01T01:02:03+00:00"
15+
},
16+
"login": "foo",
17+
"name": null,
18+
"publish_notifications": true,
19+
"url": "https://github.com/foo"
20+
}

0 commit comments

Comments
 (0)