diff --git a/app/components/version-list/row.hbs b/app/components/version-list/row.hbs
index 9e513daab80..d2f8b43b259 100644
--- a/app/components/version-list/row.hbs
+++ b/app/components/version-list/row.hbs
@@ -107,7 +107,7 @@
{{/if}}
- {{#if this.isOwner}}
+ {{#if this.canYank}}
{{/if}}
diff --git a/app/components/version-list/row.js b/app/components/version-list/row.js
index e94e17aafa1..cfef78683a2 100644
--- a/app/components/version-list/row.js
+++ b/app/components/version-list/row.js
@@ -51,6 +51,10 @@ export default class VersionRow extends Component {
return this.args.version.crate?.owner_user?.findBy('id', this.session.currentUser?.id);
}
+ get canYank() {
+ return this.isOwner || this.session.currentUser?.is_admin;
+ }
+
@action setFocused(value) {
this.focused = value;
}
diff --git a/app/models/user.js b/app/models/user.js
index 3f0e5089155..ff909f2feac 100644
--- a/app/models/user.js
+++ b/app/models/user.js
@@ -11,6 +11,7 @@ export default class User extends Model {
@attr email_verified;
@attr email_verification_sent;
@attr name;
+ @attr is_admin;
@attr login;
@attr avatar;
@attr url;
diff --git a/migrations/2023-12-31-153126_add_admin_column/down.sql b/migrations/2023-12-31-153126_add_admin_column/down.sql
new file mode 100644
index 00000000000..64027288139
--- /dev/null
+++ b/migrations/2023-12-31-153126_add_admin_column/down.sql
@@ -0,0 +1 @@
+ALTER TABLE users DROP COLUMN is_admin;
diff --git a/migrations/2023-12-31-153126_add_admin_column/up.sql b/migrations/2023-12-31-153126_add_admin_column/up.sql
new file mode 100644
index 00000000000..cf88a16b73f
--- /dev/null
+++ b/migrations/2023-12-31-153126_add_admin_column/up.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN is_admin BOOL DEFAULT false NOT NULL;
diff --git a/mirage/factories/user.js b/mirage/factories/user.js
index 18394e502b9..dfabbf777e3 100644
--- a/mirage/factories/user.js
+++ b/mirage/factories/user.js
@@ -21,6 +21,7 @@ export default Factory.extend({
emailVerified: null,
emailVerificationToken: null,
+ isAdmin: false,
afterCreate(model) {
if (model.emailVerified === null) {
diff --git a/mirage/serializers/user.js b/mirage/serializers/user.js
index 00788bbf512..2414528ac38 100644
--- a/mirage/serializers/user.js
+++ b/mirage/serializers/user.js
@@ -23,6 +23,7 @@ export default BaseSerializer.extend({
if (removePrivateData) {
delete hash.email;
delete hash.email_verified;
+ delete hash.is_admin;
} else {
hash.email_verification_sent = hash.email_verified || Boolean(hash.email_verification_token);
}
diff --git a/src/controllers/version/yank.rs b/src/controllers/version/yank.rs
index 809ea1a56bc..f6afcfe6172 100644
--- a/src/controllers/version/yank.rs
+++ b/src/controllers/version/yank.rs
@@ -70,7 +70,12 @@ fn modify_yank(
let owners = krate.owners(conn)?;
if Handle::current().block_on(user.rights(state, &owners))? < Rights::Publish {
- return Err(cargo_err("must already be an owner to yank or unyank"));
+ if user.is_admin {
+ let action = if yanked { "yanking" } else { "unyanking" };
+ warn!("Admin {} is {action} crate {}", user.gh_login, krate.name);
+ } else {
+ return Err(cargo_err("must already be an owner to yank or unyank"));
+ }
}
if version.yanked == yanked {
diff --git a/src/models/user.rs b/src/models/user.rs
index 9b0c6112005..542ffa373fd 100644
--- a/src/models/user.rs
+++ b/src/models/user.rs
@@ -22,6 +22,7 @@ pub struct User {
pub gh_id: i32,
pub account_lock_reason: Option,
pub account_lock_until: Option,
+ pub is_admin: bool,
}
/// Represents a new user record insertable to the `users` table
diff --git a/src/schema.rs b/src/schema.rs
index 45adec96480..ed67ad1f409 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -807,6 +807,12 @@ diesel::table! {
///
/// (Automatically generated by Diesel.)
account_lock_until -> Nullable,
+ /// The `is_admin` column of the `users` table.
+ ///
+ /// Its SQL type is `Bool`.
+ ///
+ /// (Automatically generated by Diesel.)
+ is_admin -> Bool,
}
}
diff --git a/src/tests/routes/crates/versions/yank_unyank.rs b/src/tests/routes/crates/versions/yank_unyank.rs
index 892e64a26ee..d59d4f73523 100644
--- a/src/tests/routes/crates/versions/yank_unyank.rs
+++ b/src/tests/routes/crates/versions/yank_unyank.rs
@@ -97,6 +97,8 @@ mod auth {
use crate::util::{MockAnonymousUser, MockCookieUser};
use chrono::{Duration, Utc};
use crates_io::models::token::{CrateScope, EndpointScope};
+ use crates_io::schema::{crates, users, versions};
+ use diesel::prelude::*;
const CRATE_NAME: &str = "fyk";
const CRATE_VERSION: &str = "1.0.0";
@@ -110,9 +112,21 @@ mod auth {
(app, anon, cookie)
}
+ fn is_yanked(app: &TestApp) -> bool {
+ app.db(|conn| {
+ versions::table
+ .inner_join(crates::table)
+ .select(versions::yanked)
+ .filter(crates::name.eq(CRATE_NAME))
+ .filter(versions::num.eq(CRATE_VERSION))
+ .get_result(conn)
+ .unwrap()
+ })
+ }
+
#[test]
fn unauthenticated() {
- let (_, client, _) = prepare();
+ let (app, client, _) = prepare();
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -120,6 +134,7 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -127,57 +142,64 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
}
#[test]
fn cookie_user() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client = client.db_new_token("test-token");
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_not_expired() {
let expired_at = Utc::now() + Duration::days(7);
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client =
client.db_new_scoped_token("test-token", None, None, Some(expired_at.naive_utc()));
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_expired() {
let expired_at = Utc::now() - Duration::days(7);
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client =
client.db_new_scoped_token("test-token", None, None, Some(expired_at.naive_utc()));
@@ -187,6 +209,7 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -194,26 +217,29 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_with_correct_endpoint_scope() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client =
client.db_new_scoped_token("test-token", None, Some(vec![EndpointScope::Yank]), None);
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_with_incorrect_endpoint_scope() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client = client.db_new_scoped_token(
"test-token",
None,
@@ -227,6 +253,7 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -234,11 +261,12 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_with_correct_crate_scope() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client = client.db_new_scoped_token(
"test-token",
Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]),
@@ -249,15 +277,17 @@ mod auth {
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_with_correct_wildcard_crate_scope() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let wildcard = format!("{}*", CRATE_NAME.chars().next().unwrap());
let client = client.db_new_scoped_token(
"test-token",
@@ -269,15 +299,17 @@ mod auth {
let response = client.yank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_with_incorrect_crate_scope() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client = client.db_new_scoped_token(
"test-token",
Some(vec![CrateScope::try_from("foo").unwrap()]),
@@ -291,6 +323,7 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -298,11 +331,12 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
}
#[test]
fn token_user_with_incorrect_wildcard_crate_scope() {
- let (_, _, client) = prepare();
+ let (app, _, client) = prepare();
let client = client.db_new_scoped_token(
"test-token",
Some(vec![CrateScope::try_from("foo*").unwrap()]),
@@ -316,6 +350,7 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
let response = client.unyank(CRATE_NAME, CRATE_VERSION);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -323,5 +358,30 @@ mod auth {
response.into_json(),
json!({ "errors": [{ "detail": "must be logged in to perform that action" }] })
);
+ assert!(!is_yanked(&app));
+ }
+
+ #[test]
+ fn admin() {
+ let (app, _, _) = prepare();
+
+ let admin = app.db_new_user("admin");
+
+ app.db(|conn| {
+ diesel::update(admin.as_model())
+ .set(users::is_admin.eq(true))
+ .execute(conn)
+ .unwrap();
+ });
+
+ let response = admin.yank(CRATE_NAME, CRATE_VERSION);
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(is_yanked(&app));
+
+ let response = admin.unyank(CRATE_NAME, CRATE_VERSION);
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(response.into_json(), json!({ "ok": true }));
+ assert!(!is_yanked(&app));
}
}
diff --git a/src/tests/routes/me/get.rs b/src/tests/routes/me/get.rs
index 772bce83d12..e7a7c144652 100644
--- a/src/tests/routes/me/get.rs
+++ b/src/tests/routes/me/get.rs
@@ -1,6 +1,8 @@
use crate::builders::CrateBuilder;
use crate::util::{RequestHelper, TestApp};
use crates_io::views::{EncodablePrivateUser, OwnedCrate};
+use http::StatusCode;
+use insta::{assert_display_snapshot, assert_json_snapshot};
impl crate::util::MockCookieUser {
pub fn show_me(&self) -> UserShowPrivateResponse {
@@ -17,22 +19,23 @@ pub struct UserShowPrivateResponse {
#[test]
fn me() {
- let url = "/api/v1/me";
- let (app, anon) = TestApp::init().empty();
- anon.get(url).assert_forbidden();
+ let (app, anon, user) = TestApp::init().with_user();
- let user = app.db_new_user("foo");
- let json = user.show_me();
+ let response = anon.get::<()>("/api/v1/me");
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+ assert_display_snapshot!(response.into_text(), @r###"{"errors":[{"detail":"must be logged in to perform that action"}]}"###);
- assert_eq!(json.owned_crates.len(), 0);
+ let response = user.get::<()>("/api/v1/me");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_json_snapshot!(response.into_json());
app.db(|conn| {
CrateBuilder::new("foo_my_packages", user.as_model().id).expect_build(conn);
- assert_eq!(json.user.email, user.as_model().email(conn).unwrap());
});
- let updated_json = user.show_me();
- assert_eq!(updated_json.owned_crates.len(), 1);
+ let response = user.get::<()>("/api/v1/me");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_json_snapshot!(response.into_json());
}
#[test]
diff --git a/src/tests/routes/me/snapshots/all__routes__me__get__me-2.snap b/src/tests/routes/me/snapshots/all__routes__me__get__me-2.snap
new file mode 100644
index 00000000000..8d823b1d448
--- /dev/null
+++ b/src/tests/routes/me/snapshots/all__routes__me__get__me-2.snap
@@ -0,0 +1,18 @@
+---
+source: src/tests/routes/me/get.rs
+expression: response.into_json()
+---
+{
+ "owned_crates": [],
+ "user": {
+ "avatar": null,
+ "email": "something@example.com",
+ "email_verification_sent": true,
+ "email_verified": true,
+ "id": 1,
+ "is_admin": false,
+ "login": "foo",
+ "name": null,
+ "url": "https://github.com/foo"
+ }
+}
diff --git a/src/tests/routes/me/snapshots/all__routes__me__get__me-3.snap b/src/tests/routes/me/snapshots/all__routes__me__get__me-3.snap
new file mode 100644
index 00000000000..7159e45fc96
--- /dev/null
+++ b/src/tests/routes/me/snapshots/all__routes__me__get__me-3.snap
@@ -0,0 +1,24 @@
+---
+source: src/tests/routes/me/get.rs
+expression: response.into_json()
+---
+{
+ "owned_crates": [
+ {
+ "email_notifications": true,
+ "id": 1,
+ "name": "foo_my_packages"
+ }
+ ],
+ "user": {
+ "avatar": null,
+ "email": "something@example.com",
+ "email_verification_sent": true,
+ "email_verified": true,
+ "id": 1,
+ "is_admin": false,
+ "login": "foo",
+ "name": null,
+ "url": "https://github.com/foo"
+ }
+}
diff --git a/src/views.rs b/src/views.rs
index f3eda4b18fd..8c9574efb5c 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -466,6 +466,7 @@ pub struct EncodablePrivateUser {
pub email: Option,
pub avatar: Option,
pub url: Option,
+ pub is_admin: bool,
}
impl EncodablePrivateUser {
@@ -481,6 +482,7 @@ impl EncodablePrivateUser {
name,
gh_login,
gh_avatar,
+ is_admin,
..
} = user;
let url = format!("https://github.com/{gh_login}");
@@ -494,6 +496,7 @@ impl EncodablePrivateUser {
login: gh_login,
name,
url: Some(url),
+ is_admin,
}
}
}
diff --git a/src/worker/jobs/dump_db/dump-db.toml b/src/worker/jobs/dump_db/dump-db.toml
index 6dfa2ea546b..f2debac1746 100644
--- a/src/worker/jobs/dump_db/dump-db.toml
+++ b/src/worker/jobs/dump_db/dump-db.toml
@@ -185,6 +185,7 @@ gh_avatar = "public"
gh_id = "public"
account_lock_reason = "private"
account_lock_until = "private"
+is_admin = "private"
[users.column_defaults]
gh_access_token = "''"
diff --git a/tests/mirage/me/get-test.js b/tests/mirage/me/get-test.js
index 3574e8a8488..70bb9bd6eaf 100644
--- a/tests/mirage/me/get-test.js
+++ b/tests/mirage/me/get-test.js
@@ -22,6 +22,7 @@ module('Mirage | GET /api/v1/me', function (hooks) {
email: 'user-1@crates.io',
email_verification_sent: true,
email_verified: true,
+ is_admin: false,
login: 'user-1',
name: 'User 1',
url: 'https://github.com/user-1',