diff --git a/.env.sample b/.env.sample index 38bdda11fbe..db28c809ce9 100644 --- a/.env.sample +++ b/.env.sample @@ -86,3 +86,6 @@ export SENTRY_ENV_API=local # export TEST_S3_INDEX_REGION=http://127.0.0.1:19000 # export TEST_AWS_ACCESS_KEY=minio # export TEST_AWS_SECRET_KEY=miniominio + +# IDs of GitHub users that are admins on this instance, separated by commas. +export ADMIN_USER_GH_IDS= diff --git a/src/auth.rs b/src/auth.rs index c19d4c281c1..a72d0aa5ebb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,8 @@ +use std::collections::HashSet; + use crate::controllers; use crate::controllers::util::RequestPartsExt; +use crate::middleware::app::RequestApp; use crate::middleware::log_request::RequestLogExt; use crate::middleware::session::RequestSession; use crate::models::token::{CrateScope, EndpointScope}; @@ -16,6 +19,7 @@ pub struct AuthCheck { allow_token: bool, endpoint_scope: Option, crate_name: Option, + require_admin: bool, } impl AuthCheck { @@ -27,6 +31,7 @@ impl AuthCheck { allow_token: true, endpoint_scope: None, crate_name: None, + require_admin: false, } } @@ -36,6 +41,7 @@ impl AuthCheck { allow_token: false, endpoint_scope: None, crate_name: None, + require_admin: false, } } @@ -44,6 +50,7 @@ impl AuthCheck { allow_token: self.allow_token, endpoint_scope: Some(endpoint_scope), crate_name: self.crate_name.clone(), + require_admin: self.require_admin, } } @@ -52,6 +59,16 @@ impl AuthCheck { allow_token: self.allow_token, endpoint_scope: self.endpoint_scope, crate_name: Some(crate_name.to_string()), + require_admin: self.require_admin, + } + } + + pub fn require_admin(&self) -> Self { + Self { + allow_token: self.allow_token, + endpoint_scope: self.endpoint_scope, + crate_name: self.crate_name.clone(), + require_admin: true, } } @@ -62,7 +79,14 @@ impl AuthCheck { conn: &mut PgConnection, ) -> AppResult { let auth = authenticate(request, conn)?; + self.check_authentication(auth, &request.app().config.admin_user_github_ids) + } + fn check_authentication( + &self, + auth: Authentication, + gh_admin_user_ids: &HashSet, + ) -> AppResult { if let Some(token) = auth.api_token() { if !self.allow_token { let error_message = @@ -81,6 +105,11 @@ impl AuthCheck { } } + if self.require_admin && !gh_admin_user_ids.contains(&auth.user().gh_id) { + let error_message = "User is unauthorized"; + return Err(internal(error_message).chain(forbidden())); + } + Ok(auth) } @@ -347,4 +376,41 @@ mod tests { assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); } + + #[test] + fn require_admin() { + let auth_check = AuthCheck::default().require_admin(); + let gh_admin_user_ids = [42, 43].into_iter().collect(); + + assert_ok!(auth_check.check_authentication(mock_cookie(42), &gh_admin_user_ids)); + assert_err!(auth_check.check_authentication(mock_cookie(44), &gh_admin_user_ids)); + assert_ok!(auth_check.check_authentication(mock_token(43), &gh_admin_user_ids)); + assert_err!(auth_check.check_authentication(mock_token(45), &gh_admin_user_ids)); + } + + fn mock_user(gh_id: i32) -> User { + User { + id: 3, + gh_access_token: "arbitrary".into(), + gh_login: "literally_anything".into(), + name: None, + gh_avatar: None, + gh_id, + account_lock_reason: None, + account_lock_until: None, + } + } + + fn mock_cookie(gh_id: i32) -> Authentication { + Authentication::Cookie(CookieAuthentication { + user: mock_user(gh_id), + }) + } + + fn mock_token(gh_id: i32) -> Authentication { + Authentication::Token(TokenAuthentication { + token: ApiToken::mock_builder().user_id(gh_id).build().unwrap(), + user: mock_user(gh_id), + }) + } } diff --git a/src/config/server.rs b/src/config/server.rs index 9def1b19c52..c772e0f48fd 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -57,6 +57,7 @@ pub struct Server { pub version_id_cache_ttl: Duration, pub cdn_user_agent: String, pub balance_capacity: BalanceCapacityConfig, + pub admin_user_github_ids: HashSet, /// Instructs the `cargo_compat` middleware whether to adjust response /// status codes to `200 OK` for all endpoints that are relevant for cargo. @@ -104,6 +105,8 @@ impl Server { /// endpoint even with a healthy database pool. /// - `BLOCKED_ROUTES`: A comma separated list of HTTP route patterns that are manually blocked /// by an operator (e.g. `/crates/:crate_id/:version/download`). + /// - `ADMIN_USER_GH_IDS`: A comma separated list of GitHub user IDs that will be considered + /// admins. /// /// # Panics /// @@ -206,6 +209,10 @@ impl Server { balance_capacity: BalanceCapacityConfig::from_environment()?, cargo_compat_status_code_config: var_parsed("CARGO_COMPAT_STATUS_CODES")? .unwrap_or(StatusCodeConfig::AdjustAll), + admin_user_github_ids: HashSet::from_iter(list_parsed( + "ADMIN_USER_GH_IDS", + i32::from_str, + )?), serve_dist: true, serve_html: true, content_security_policy: Some(content_security_policy.parse()?), diff --git a/src/models/token.rs b/src/models/token.rs index deb67485ad8..b3cf3aa8db6 100644 --- a/src/models/token.rs +++ b/src/models/token.rs @@ -1,6 +1,7 @@ mod scopes; use chrono::NaiveDateTime; +use derive_builder::Builder; use diesel::prelude::*; pub use self::scopes::{CrateScope, EndpointScope}; @@ -11,24 +12,34 @@ use crate::util::rfc3339; use crate::util::token::{HashedToken, PlainToken}; /// The model representing a row in the `api_tokens` database table. -#[derive(Debug, Identifiable, Queryable, Selectable, Associations, Serialize)] +#[derive(Debug, Identifiable, Queryable, Selectable, Associations, Serialize, Builder)] #[diesel(belongs_to(User))] +#[builder(name = "MockApiTokenBuilder")] pub struct ApiToken { + #[builder(default)] pub id: i32, #[serde(skip)] + #[builder(default)] pub user_id: i32, + #[builder(default, setter(into))] pub name: String, #[serde(with = "rfc3339")] + #[builder(default, setter(strip_option))] pub created_at: NaiveDateTime, #[serde(with = "rfc3339::option")] + #[builder(default, setter(strip_option))] pub last_used_at: Option, #[serde(skip)] + #[builder(default = "false")] pub revoked: bool, /// `None` or a list of crate scope patterns (see RFC #2947) + #[builder(default, setter(strip_option))] pub crate_scopes: Option>, /// A list of endpoint scopes or `None` for the `legacy` endpoint scope (see RFC #2947) + #[builder(default, setter(strip_option))] pub endpoint_scopes: Option>, #[serde(with = "rfc3339::option")] + #[builder(default, setter(strip_option))] pub expired_at: Option, } @@ -95,6 +106,10 @@ impl ApiToken { .or_else(|_| tokens.select(ApiToken::as_select()).first(conn)) .map_err(Into::into) } + + pub fn mock_builder() -> MockApiTokenBuilder { + MockApiTokenBuilder::default() + } } #[derive(Debug)] @@ -110,22 +125,22 @@ mod tests { #[test] fn api_token_serializes_to_rfc3339() { - let tok = ApiToken { - id: 12345, - user_id: 23456, - revoked: false, - name: "".to_string(), - created_at: NaiveDate::from_ymd_opt(2017, 1, 6) - .unwrap() - .and_hms_opt(14, 23, 11) - .unwrap(), - last_used_at: NaiveDate::from_ymd_opt(2017, 1, 6) - .unwrap() - .and_hms_opt(14, 23, 12), - crate_scopes: None, - endpoint_scopes: None, - expired_at: None, - }; + let created_at = NaiveDate::from_ymd_opt(2017, 1, 6) + .unwrap() + .and_hms_opt(14, 23, 11) + .unwrap(); + + let last_used_at = NaiveDate::from_ymd_opt(2017, 1, 6) + .unwrap() + .and_hms_opt(14, 23, 12) + .unwrap(); + + let tok = ApiToken::mock_builder() + .created_at(created_at) + .last_used_at(last_used_at) + .build() + .unwrap(); + let json = serde_json::to_string(&tok).unwrap(); assert_some!(json .as_str() diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index daed04e0fcd..e89366aa5c7 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -428,6 +428,7 @@ fn simple_config() -> config::Server { version_id_cache_ttl: Duration::from_secs(5 * 60), cdn_user_agent: "Amazon CloudFront".to_string(), balance_capacity, + admin_user_github_ids: HashSet::new(), // The middleware has its own unit tests to verify its functionality. // Here, we can test what would happen if we toggled the status code