Skip to content

Commit 2dccaa7

Browse files
committed
Make an API for increasing a user's rate limit
1 parent 41ec48c commit 2dccaa7

File tree

7 files changed

+208
-1
lines changed

7 files changed

+208
-1
lines changed

src/controllers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ mod prelude {
7676
pub mod helpers;
7777
mod util;
7878

79+
pub mod admin;
7980
pub mod category;
8081
pub mod crate_owner_invitation;
8182
pub mod keyword;

src/controllers/admin.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use super::frontend_prelude::*;
2+
use crate::{
3+
models::{AdminUser, User},
4+
schema::{publish_limit_buckets, publish_rate_overrides},
5+
};
6+
use diesel::dsl::*;
7+
8+
#[derive(Deserialize)]
9+
struct RateLimitIncrease {
10+
email: String,
11+
rate_limit: i32,
12+
}
13+
14+
/// Increases the rate limit for the user with the specified verified email address.
15+
pub fn publish_rate_override(req: &mut dyn RequestExt) -> EndpointResult {
16+
let admin = req.authenticate()?.forbid_api_token_auth()?.admin_user()?;
17+
increase_rate_limit(admin, req)
18+
}
19+
20+
/// Increasing the rate limit requires that you are an admin user, but no information from the
21+
/// admin user is currently needed. Someday having an audit log of which admin user took the action
22+
/// would be nice.
23+
fn increase_rate_limit(_admin: AdminUser, req: &mut dyn RequestExt) -> EndpointResult {
24+
let mut body = String::new();
25+
req.body().read_to_string(&mut body)?;
26+
27+
let rate_limit_increase: RateLimitIncrease = serde_json::from_str(&body)
28+
.map_err(|e| bad_request(&format!("invalid json request: {e}")))?;
29+
30+
let conn = req.db_write()?;
31+
let user = User::find_by_verified_email(&conn, &rate_limit_increase.email)?;
32+
33+
conn.transaction(|| {
34+
diesel::insert_into(publish_rate_overrides::table)
35+
.values((
36+
publish_rate_overrides::user_id.eq(user.id),
37+
publish_rate_overrides::burst.eq(rate_limit_increase.rate_limit),
38+
publish_rate_overrides::expires_at.eq((now + 30.days()).nullable()),
39+
))
40+
.execute(&*conn)?;
41+
42+
diesel::delete(publish_limit_buckets::table)
43+
.filter(publish_limit_buckets::user_id.eq(user.id))
44+
.execute(&*conn)
45+
})?;
46+
47+
ok_true()
48+
}

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ pub mod email;
4646
pub mod github;
4747
pub mod metrics;
4848
pub mod middleware;
49-
mod publish_rate_limit;
49+
pub mod publish_rate_limit;
5050
pub mod schema;
5151
pub mod sql;
5252
mod test_util;

src/models/user.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ impl User {
121121
Ok(Self::find(conn, api_token.user_id)?)
122122
}
123123

124+
/// Queries the database for a user with the specified verified email address.
125+
pub fn find_by_verified_email(conn: &PgConnection, email: &str) -> AppResult<User> {
126+
let email: Email = emails::table
127+
.filter(emails::email.eq(email))
128+
.filter(emails::verified.eq(true))
129+
.first(conn)?;
130+
131+
Ok(Self::find(conn, email.user_id)?)
132+
}
133+
124134
pub fn owning(krate: &Crate, conn: &PgConnection) -> QueryResult<Vec<Owner>> {
125135
let users = CrateOwner::by_owner_kind(OwnerKind::User)
126136
.inner_join(users::table)

src/router.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ pub fn build_router(app: &App) -> RouteBuilder {
157157
C(crate_owner_invitation::private_list),
158158
);
159159

160+
// Admin actions
161+
router.put(
162+
"/api/private/admin/rate-limits",
163+
C(admin::publish_rate_override),
164+
);
165+
160166
// Only serve the local checkout of the git index in development mode.
161167
// In production, for crates.io, cargo gets the index from
162168
// https://github.com/rust-lang/crates.io-index directly.

src/tests/admin_actions.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
use crate::{util::RequestHelper, TestApp};
2+
use chrono::{NaiveDateTime, Utc};
3+
use conduit::StatusCode;
4+
use diesel::prelude::*;
5+
6+
mod rate_limits {
7+
use super::*;
8+
9+
const RATE_LIMITS_URL: &str = "/api/private/admin/rate-limits";
10+
11+
#[test]
12+
fn anon_sending_rate_limit_changes_returns_unauthorized() {
13+
let (_app, anon) = TestApp::init().empty();
14+
anon.put(RATE_LIMITS_URL, &[]).assert_forbidden();
15+
}
16+
17+
#[test]
18+
fn non_admin_sending_rate_limit_changes_returns_unauthorized() {
19+
let (app, _anon) = TestApp::init().empty();
20+
let user = app.db_new_user("foo");
21+
user.put(RATE_LIMITS_URL, &[]).assert_forbidden();
22+
}
23+
24+
#[test]
25+
fn no_body_content_returns_400() {
26+
let (app, _anon) = TestApp::init().empty();
27+
let admin_user = app.db_new_user("carols10cents");
28+
let response = admin_user.put::<()>(RATE_LIMITS_URL, &[]);
29+
30+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
31+
assert_eq!(
32+
response.into_json(),
33+
json!({ "errors": [{
34+
"detail": "invalid json request: EOF while parsing a value at line 1 column 0"
35+
}] })
36+
);
37+
}
38+
39+
#[test]
40+
fn rate_limit_nan_returns_400() {
41+
let (app, _anon) = TestApp::init().empty();
42+
let admin_user = app.db_new_user("carols10cents");
43+
44+
let body = json!({
45+
"email": "foo@example.com",
46+
"rate_limit": "-34g",
47+
});
48+
49+
let response = admin_user.put::<()>(RATE_LIMITS_URL, body.to_string().as_bytes());
50+
51+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
52+
assert_eq!(
53+
response.into_json(),
54+
json!({ "errors": [{
55+
"detail": "invalid json request: invalid type: string \"-34g\", expected i32 at \
56+
line 1 column 46"
57+
}] })
58+
);
59+
}
60+
61+
#[test]
62+
fn email_address_lookup_failure_returns_not_found() {
63+
let (app, _anon) = TestApp::init().empty();
64+
let admin_user = app.db_new_user("carols10cents");
65+
66+
let body = json!({
67+
"email": "foo@example.com",
68+
"rate_limit": 88,
69+
});
70+
71+
let response = admin_user.put::<()>(RATE_LIMITS_URL, body.to_string().as_bytes());
72+
73+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
74+
assert_eq!(
75+
response.into_json(),
76+
json!({ "errors": [{
77+
"detail": "Not Found"
78+
}] })
79+
);
80+
}
81+
82+
#[test]
83+
fn email_address_lookup_success_updates_rate_limit() {
84+
use cargo_registry::{
85+
publish_rate_limit::PublishRateLimit,
86+
schema::{publish_limit_buckets, publish_rate_overrides},
87+
};
88+
use std::time::Duration;
89+
90+
let (app, _, user) = TestApp::init().with_user();
91+
let user_model = user.as_model();
92+
93+
// Check the rate limit for a user, which inserts a record for the user into the
94+
// `publish_limit_buckets` table so that we can test it gets deleted by the rate limit
95+
// override.
96+
app.db(|conn| {
97+
let rate = PublishRateLimit {
98+
rate: Duration::from_secs(1),
99+
burst: 10,
100+
};
101+
rate.check_rate_limit(user_model.id, conn).unwrap();
102+
});
103+
104+
let admin_user = app.db_new_user("carols10cents");
105+
106+
let email = app.db(|conn| user_model.email(conn).unwrap());
107+
let new_rate_limit = 88;
108+
let body = json!({
109+
"email": email,
110+
"rate_limit": new_rate_limit,
111+
});
112+
let response = admin_user.put::<()>(RATE_LIMITS_URL, body.to_string().as_bytes());
113+
114+
assert_eq!(response.status(), StatusCode::OK);
115+
116+
let (rate_limit, expires_at): (i32, Option<NaiveDateTime>) = app
117+
.db(|conn| {
118+
publish_rate_overrides::table
119+
.select((
120+
publish_rate_overrides::burst,
121+
publish_rate_overrides::expires_at,
122+
))
123+
.filter(publish_rate_overrides::user_id.eq(user_model.id))
124+
.first(conn)
125+
})
126+
.unwrap();
127+
assert_eq!(rate_limit, new_rate_limit);
128+
assert_eq!(
129+
expires_at.unwrap().date(),
130+
(Utc::now() + chrono::Duration::days(30)).naive_utc().date()
131+
);
132+
app.db(|conn| {
133+
assert!(!diesel::select(diesel::dsl::exists(
134+
publish_limit_buckets::table
135+
.filter(publish_limit_buckets::user_id.eq(user_model.id))
136+
))
137+
.get_result::<bool>(conn)
138+
.unwrap());
139+
});
140+
}
141+
}

src/tests/all.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use std::{
2828
use diesel::prelude::*;
2929

3030
mod account_lock;
31+
mod admin_actions;
3132
mod authentication;
3233
mod badge;
3334
mod blocked_routes;

0 commit comments

Comments
 (0)