Skip to content

Commit 2a036e5

Browse files
committed
Auto merge of #1772 - smarnach:fix-owner-checks, r=sgrif
Fix checks and consistency in the API endpoints to manage ownership. Fixes #1583. The API endpoints to add and remove owners for a crate has multiple issues. * The database interactions are not wrapped in a transaction, making them prone to race conditions. * The lack of a transaction can also result in partially applied operations, e.g. when adding or removing multiple owners at once. * The check whether the last owner was removed only triggers if the _original_ list of owners had a length of exactly one. This is wrong in at least two cases: * Teams do not have permission to manage ownership, so they should not be counted. * If multiple users are removed at once, we can end up with no owners even when there originally was more than one user. This change fixes all mentioned problems, and adds tests for all bullet points except for the first one (which can't be tested with reasonable effort).
2 parents e1baf64 + 794cc87 commit 2a036e5

File tree

5 files changed

+176
-81
lines changed

5 files changed

+176
-81
lines changed

src/controllers/krate/owners.rs

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -68,70 +68,83 @@ pub fn remove_owners(req: &mut dyn Request) -> CargoResult<Response> {
6868
modify_owners(req, false)
6969
}
7070

71-
fn modify_owners(req: &mut dyn Request, add: bool) -> CargoResult<Response> {
71+
/// Parse the JSON request body of requests to modify the owners of a crate.
72+
/// The format is
73+
///
74+
/// {"owners": ["username", "github:org:team", ...]}
75+
fn parse_owners_request(req: &mut dyn Request) -> CargoResult<Vec<String>> {
7276
let mut body = String::new();
7377
req.body().read_to_string(&mut body)?;
74-
75-
let user = req.user()?;
76-
let conn = req.db_conn()?;
77-
let krate = Crate::by_name(&req.params()["crate_id"]).first::<Crate>(&*conn)?;
78-
let owners = krate.owners(&conn)?;
79-
80-
match user.rights(req.app(), &owners)? {
81-
Rights::Full => {}
82-
// Yes!
83-
Rights::Publish => {
84-
return Err(human("team members don't have permission to modify owners"));
85-
}
86-
Rights::None => {
87-
return Err(human("only owners have permission to modify owners"));
88-
}
89-
}
90-
9178
#[derive(Deserialize)]
9279
struct Request {
9380
// identical, for back-compat (owners preferred)
9481
users: Option<Vec<String>>,
9582
owners: Option<Vec<String>>,
9683
}
97-
9884
let request: Request =
9985
serde_json::from_str(&body).map_err(|_| human("invalid json request"))?;
100-
101-
let logins = request
86+
request
10287
.owners
10388
.or(request.users)
104-
.ok_or_else(|| human("invalid json request"))?;
89+
.ok_or_else(|| human("invalid json request"))
90+
}
91+
92+
fn modify_owners(req: &mut dyn Request, add: bool) -> CargoResult<Response> {
93+
let logins = parse_owners_request(req)?;
94+
let app = req.app();
95+
let user = req.user()?;
96+
let crate_name = &req.params()["crate_id"];
97+
let conn = req.db_conn()?;
10598

106-
let mut msgs = Vec::new();
99+
conn.transaction(|| {
100+
let krate = Crate::by_name(crate_name).first::<Crate>(&*conn)?;
101+
let owners = krate.owners(&conn)?;
107102

108-
for login in &logins {
109-
if add {
110-
let login_test = |owner: &Owner| owner.login().to_lowercase() == *login.to_lowercase();
111-
if owners.iter().any(login_test) {
112-
return Err(human(&format_args!("`{}` is already an owner", login)));
103+
match user.rights(app, &owners)? {
104+
Rights::Full => {}
105+
// Yes!
106+
Rights::Publish => {
107+
return Err(human("team members don't have permission to modify owners"));
113108
}
114-
let msg = krate.owner_add(req.app(), &conn, user, login)?;
115-
msgs.push(msg);
116-
} else {
117-
// Removing the team that gives you rights is prevented because
118-
// team members only have Rights::Publish
119-
if owners.len() == 1 {
120-
return Err(human("cannot remove the sole owner of a crate"));
109+
Rights::None => {
110+
return Err(human("only owners have permission to modify owners"));
121111
}
122-
krate.owner_remove(req.app(), &conn, user, login)?;
123112
}
124-
}
125113

126-
let comma_sep_msg = msgs.join(",");
114+
let comma_sep_msg = if add {
115+
let mut msgs = Vec::new();
116+
for login in &logins {
117+
let login_test =
118+
|owner: &Owner| owner.login().to_lowercase() == *login.to_lowercase();
119+
if owners.iter().any(login_test) {
120+
return Err(human(&format_args!("`{}` is already an owner", login)));
121+
}
122+
let msg = krate.owner_add(app, &conn, user, login)?;
123+
msgs.push(msg);
124+
}
125+
msgs.join(",")
126+
} else {
127+
for login in &logins {
128+
krate.owner_remove(app, &conn, user, login)?;
129+
}
130+
if User::owning(&krate, &conn)?.is_empty() {
131+
return Err(human(
132+
"cannot remove all individual owners of a crate. \
133+
Team member don't have permission to modify owners, so \
134+
at least one individual owner is required.",
135+
));
136+
}
137+
"owners successfully removed".to_owned()
138+
};
127139

128-
#[derive(Serialize)]
129-
struct R {
130-
ok: bool,
131-
msg: String,
132-
}
133-
Ok(req.json(&R {
134-
ok: true,
135-
msg: comma_sep_msg,
136-
}))
140+
#[derive(Serialize)]
141+
struct R {
142+
ok: bool,
143+
msg: String,
144+
}
145+
Ok(req.json(&R {
146+
ok: true,
147+
msg: comma_sep_msg,
148+
}))
149+
})
137150
}

src/tests/owners.rs

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::{
22
add_team_to_crate,
33
builders::{CrateBuilder, PublishBuilder},
44
new_team,
5-
util::RequestHelper,
5+
util::{MockCookieUser, MockTokenUser, RequestHelper},
66
TestApp,
77
};
88
use cargo_registry::{
@@ -26,7 +26,7 @@ struct InvitationListResponse {
2626
}
2727

2828
// Implementing locally for now, unless these are needed elsewhere
29-
impl crate::util::MockCookieUser {
29+
impl MockCookieUser {
3030
/// As the currently logged in user, accept an invitation to become an owner of the named
3131
/// crate.
3232
fn accept_ownership_invitation(&self, krate_name: &str, krate_id: i32) {
@@ -111,43 +111,98 @@ fn new_crate_owner() {
111111
.good();
112112
}
113113

114+
fn create_and_add_owner(
115+
app: &TestApp,
116+
token: &MockTokenUser,
117+
username: &str,
118+
krate: &Crate,
119+
) -> MockCookieUser {
120+
let user = app.db_new_user(username);
121+
token.add_user_owner(&krate.name, user.as_model());
122+
user.accept_ownership_invitation(&krate.name, krate.id);
123+
user
124+
}
125+
114126
// Ensures that so long as at least one owner remains associated with the crate,
115127
// a user can still remove their own login as an owner
116128
#[test]
117129
fn owners_can_remove_self() {
118130
let (app, _, user, token) = TestApp::init().with_token();
131+
let username = &user.as_model().gh_login;
119132

120133
let krate = app
121134
.db(|conn| CrateBuilder::new("owners_selfremove", user.as_model().id).expect_build(conn));
122135

123136
// Deleting yourself when you're the only owner isn't allowed.
124137
let json = token
125-
.remove_named_owner("owners_selfremove", &user.as_model().gh_login)
138+
.remove_named_owner("owners_selfremove", username)
126139
.bad_with_status(200);
127140
assert!(json.errors[0]
128141
.detail
129-
.contains("cannot remove the sole owner of a crate"));
142+
.contains("cannot remove all individual owners of a crate"));
130143

131-
let user2 = app.db_new_user("secondowner");
132-
token.add_user_owner("owners_selfremove", user2.as_model());
133-
user2.accept_ownership_invitation("owners_selfremove", krate.id);
144+
create_and_add_owner(&app, &token, "secondowner", &krate);
134145

135146
// Deleting yourself when there are other owners is allowed.
136147
let json = token
137-
.remove_named_owner("owners_selfremove", &user.as_model().gh_login)
148+
.remove_named_owner("owners_selfremove", username)
138149
.good();
139150
assert!(json.ok);
140151

141152
// After you delete yourself, you no longer have permisions to manage the crate.
142153
let json = token
143-
.remove_named_owner("owners_selfremove", &user2.as_model().gh_login)
154+
.remove_named_owner("owners_selfremove", username)
144155
.bad_with_status(200);
145-
146156
assert!(json.errors[0]
147157
.detail
148158
.contains("only owners have permission to modify owners",));
149159
}
150160

161+
// Verify consistency when adidng or removing multiple owners in a single request.
162+
#[test]
163+
fn modify_multiple_owners() {
164+
let (app, _, user, token) = TestApp::init().with_token();
165+
let username = &user.as_model().gh_login;
166+
167+
let krate =
168+
app.db(|conn| CrateBuilder::new("owners_multiple", user.as_model().id).expect_build(conn));
169+
170+
let user2 = create_and_add_owner(&app, &token, "user2", &krate);
171+
let user3 = create_and_add_owner(&app, &token, "user3", &krate);
172+
173+
// Deleting all owners is not allowed.
174+
let json = token
175+
.remove_named_owners("owners_multiple", &[username, "user2", "user3"])
176+
.bad_with_status(200);
177+
assert!(&json.errors[0]
178+
.detail
179+
.contains("cannot remove all individual owners of a crate"));
180+
assert_eq!(app.db(|conn| krate.owners(&conn).unwrap()).len(), 3);
181+
182+
// Deleting two owners at once is allowed.
183+
let json = token
184+
.remove_named_owners("owners_multiple", &["user2", "user3"])
185+
.good();
186+
assert!(json.ok);
187+
assert_eq!(app.db(|conn| krate.owners(&conn).unwrap()).len(), 1);
188+
189+
// Adding multiple users fails if one of them already is an owner.
190+
let json = token
191+
.add_named_owners("owners_multiple", &["user2", username])
192+
.bad_with_status(200);
193+
assert!(&json.errors[0].detail.contains("is already an owner"));
194+
assert_eq!(app.db(|conn| krate.owners(&conn).unwrap()).len(), 1);
195+
196+
// Adding multiple users at once succeeds.
197+
let json = token
198+
.add_named_owners("owners_multiple", &["user2", "user3"])
199+
.good();
200+
assert!(json.ok);
201+
user2.accept_ownership_invitation(&krate.name, krate.id);
202+
user3.accept_ownership_invitation(&krate.name, krate.id);
203+
assert_eq!(app.db(|conn| krate.owners(&conn).unwrap()).len(), 3);
204+
}
205+
151206
/* Testing the crate ownership between two crates and one team.
152207
Given two crates, one crate owned by both a team and a user,
153208
one only owned by a user, check that the CrateList returned

src/tests/team.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ fn add_team_as_non_member() {
184184
#[test]
185185
fn remove_team_as_named_owner() {
186186
let (app, _) = TestApp::with_proxy().empty();
187-
let user_on_both_teams = app.db_new_user(mock_user_on_both_teams().gh_login);
187+
let username = mock_user_on_both_teams().gh_login;
188+
let user_on_both_teams = app.db_new_user(username);
188189
let token_on_both_teams = user_on_both_teams.db_new_token("arbitrary token name");
189190

190191
app.db(|conn| {
@@ -195,6 +196,15 @@ fn remove_team_as_named_owner() {
195196
.add_named_owner("foo_remove_team", "github:crates-test-org:core")
196197
.good();
197198

199+
// Removing the individual owner is not allowed, since team members don't
200+
// have permission to manage ownership
201+
let json = token_on_both_teams
202+
.remove_named_owner("foo_remove_team", username)
203+
.bad_with_status(200);
204+
assert!(json.errors[0]
205+
.detail
206+
.contains("cannot remove all individual owners of a crate"));
207+
198208
token_on_both_teams
199209
.remove_named_owner("foo_remove_team", "github:crates-test-org:core")
200210
.good();

src/tests/user.rs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,19 @@ impl crate::util::MockCookieUser {
4646
// TODO: I don't like the name of this method or the one above; this is starting to look like
4747
// a builder might help? I want to explore alternative abstractions in any case
4848
fn update_email_more_control(&self, user_id: i32, email: Option<&str>) -> Response<OkBool> {
49-
let email_json = match email {
50-
Some(val) => format!("\"{}\"", val),
51-
None => String::from("null"),
52-
};
53-
5449
// When updating your email in crates.io, the request goes to the user route with PUT.
5550
// Ember sends all the user attributes. We check to make sure the ID in the URL matches
5651
// the ID of the currently logged in user, then we ignore everything but the email address.
57-
let body = format!("{{\"user\":{{\"email\":{},\"name\":\"Arbitrary Name\",\"login\":\"arbitrary_login\",\"avatar\":\"https://arbitrary.com/img.jpg\",\"url\":\"https://arbitrary.com\",\"kind\":null}}}}", email_json);
52+
let body = json!({"user": {
53+
"email": email,
54+
"name": "Arbitrary Name",
55+
"login": "arbitrary_login",
56+
"avatar": "https://arbitrary.com/img.jpg",
57+
"url": "https://arbitrary.com",
58+
"kind": null
59+
}});
5860
let url = format!("/api/v1/users/{}", user_id);
59-
60-
self.put(&url, body.as_bytes())
61+
self.put(&url, body.to_string().as_bytes())
6162
}
6263

6364
fn confirm_email(&self, email_token: &str) -> OkBool {
@@ -70,18 +71,19 @@ impl crate::util::MockAnonymousUser {
7071
// TODO: Refactor to get rid of this duplication with the same method implemented on
7172
// MockCookieUser
7273
fn update_email_more_control(&self, user_id: i32, email: Option<&str>) -> Response<OkBool> {
73-
let email_json = match email {
74-
Some(val) => format!("\"{}\"", val),
75-
None => String::from("null"),
76-
};
77-
7874
// When updating your email in crates.io, the request goes to the user route with PUT.
7975
// Ember sends all the user attributes. We check to make sure the ID in the URL matches
8076
// the ID of the currently logged in user, then we ignore everything but the email address.
81-
let body = format!("{{\"user\":{{\"email\":{},\"name\":\"Arbitrary Name\",\"login\":\"arbitrary_login\",\"avatar\":\"https://arbitrary.com/img.jpg\",\"url\":\"https://arbitrary.com\",\"kind\":null}}}}", email_json);
77+
let body = json!({"user": {
78+
"email": email,
79+
"name": "Arbitrary Name",
80+
"login": "arbitrary_login",
81+
"avatar": "https://arbitrary.com/img.jpg",
82+
"url": "https://arbitrary.com",
83+
"kind": null
84+
}});
8285
let url = format!("/api/v1/users/{}", user_id);
83-
84-
self.put(&url, body.as_bytes())
86+
self.put(&url, body.to_string().as_bytes())
8587
}
8688
}
8789

src/tests/util.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -497,18 +497,33 @@ impl MockTokenUser {
497497
&self.token
498498
}
499499

500-
/// Add to the specified crate the specified owner.
500+
/// Add to the specified crate the specified owners.
501+
pub fn add_named_owners(&self, krate_name: &str, owners: &[&str]) -> Response<OkBool> {
502+
self.modify_owners(krate_name, owners, Self::put)
503+
}
504+
505+
/// Add a single owner to the specified crate.
501506
pub fn add_named_owner(&self, krate_name: &str, owner: &str) -> Response<OkBool> {
502-
let url = format!("/api/v1/crates/{}/owners", krate_name);
503-
let body = format!("{{\"users\":[\"{}\"]}}", owner);
504-
self.put(&url, body.as_bytes())
507+
self.add_named_owners(krate_name, &[owner])
508+
}
509+
510+
/// Remove from the specified crate the specified owners.
511+
pub fn remove_named_owners(&self, krate_name: &str, owners: &[&str]) -> Response<OkBool> {
512+
self.modify_owners(krate_name, owners, Self::delete_with_body)
505513
}
506514

507-
/// Remove from the specified crate the specified owner.
515+
/// Remove a single owner to the specified crate.
508516
pub fn remove_named_owner(&self, krate_name: &str, owner: &str) -> Response<OkBool> {
517+
self.remove_named_owners(krate_name, &[owner])
518+
}
519+
520+
fn modify_owners<F>(&self, krate_name: &str, owners: &[&str], method: F) -> Response<OkBool>
521+
where
522+
F: Fn(&MockTokenUser, &str, &[u8]) -> Response<OkBool>,
523+
{
509524
let url = format!("/api/v1/crates/{}/owners", krate_name);
510-
let body = format!("{{\"users\":[\"{}\"]}}", owner);
511-
self.delete_with_body(&url, body.as_bytes())
525+
let body = json!({ "owners": owners }).to_string();
526+
method(&self, &url, body.to_string().as_bytes())
512527
}
513528

514529
/// Add a user as an owner for a crate.

0 commit comments

Comments
 (0)