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',