diff --git a/Cargo.lock b/Cargo.lock index 128742a8cd6..d8ce163842c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1198,6 +1198,7 @@ dependencies = [ "crates_io_tarball", "crates_io_team_repo", "crates_io_test_db", + "crates_io_trustpub", "crates_io_worker", "csv", "deadpool-diesel", @@ -1493,6 +1494,16 @@ dependencies = [ "url", ] +[[package]] +name = "crates_io_trustpub" +version = "0.0.0" +dependencies = [ + "claims", + "insta", + "regex", + "thiserror 2.0.12", +] + [[package]] name = "crates_io_worker" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index f92991385ee..97444ec9574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ crates_io_pagerduty = { path = "crates/crates_io_pagerduty" } crates_io_session = { path = "crates/crates_io_session" } crates_io_tarball = { path = "crates/crates_io_tarball" } crates_io_team_repo = { path = "crates/crates_io_team_repo" } +crates_io_trustpub = { path = "crates/crates_io_trustpub" } crates_io_worker = { path = "crates/crates_io_worker" } csv = "=1.3.1" chrono = { version = "=0.4.41", default-features = false, features = ["serde"] } diff --git a/crates/crates_io_database/src/models/mod.rs b/crates/crates_io_database/src/models/mod.rs index 2b6f2698adc..383d3b97443 100644 --- a/crates/crates_io_database/src/models/mod.rs +++ b/crates/crates_io_database/src/models/mod.rs @@ -33,5 +33,6 @@ pub mod krate; mod owner; pub mod team; pub mod token; +pub mod trustpub; pub mod user; pub mod version; diff --git a/crates/crates_io_database/src/models/trustpub/github_config.rs b/crates/crates_io_database/src/models/trustpub/github_config.rs new file mode 100644 index 00000000000..c6291daca20 --- /dev/null +++ b/crates/crates_io_database/src/models/trustpub/github_config.rs @@ -0,0 +1,37 @@ +use crate::schema::trustpub_configs_github; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; + +#[derive(Debug, Identifiable, Queryable, Selectable)] +#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))] +pub struct GitHubConfig { + pub id: i32, + pub created_at: DateTime, + pub crate_id: i32, + pub repository_owner: String, + pub repository_owner_id: i32, + pub repository_name: String, + pub workflow_filename: String, + pub environment: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))] +pub struct NewGitHubConfig<'a> { + pub crate_id: i32, + pub repository_owner: &'a str, + pub repository_owner_id: i32, + pub repository_name: &'a str, + pub workflow_filename: &'a str, + pub environment: Option<&'a str>, +} + +impl NewGitHubConfig<'_> { + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult { + self.insert_into(trustpub_configs_github::table) + .returning(GitHubConfig::as_returning()) + .get_result(conn) + .await + } +} diff --git a/crates/crates_io_database/src/models/trustpub/mod.rs b/crates/crates_io_database/src/models/trustpub/mod.rs new file mode 100644 index 00000000000..634f500f46d --- /dev/null +++ b/crates/crates_io_database/src/models/trustpub/mod.rs @@ -0,0 +1,3 @@ +mod github_config; + +pub use self::github_config::{GitHubConfig, NewGitHubConfig}; diff --git a/crates/crates_io_github/examples/test_github_client.rs b/crates/crates_io_github/examples/test_github_client.rs index a00f1d1689b..f8c259d495d 100644 --- a/crates/crates_io_github/examples/test_github_client.rs +++ b/crates/crates_io_github/examples/test_github_client.rs @@ -12,6 +12,11 @@ enum Request { #[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)] access_token: SecretString, }, + GetUser { + name: String, + #[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)] + access_token: SecretString, + }, OrgByName { org_name: String, #[clap(long, env = "GITHUB_ACCESS_TOKEN", hide_env_values = true)] @@ -58,6 +63,11 @@ async fn main() -> Result<()> { let response = github_client.current_user(&access_token).await?; println!("{response:#?}"); } + Request::GetUser { name, access_token } => { + let access_token = AccessToken::new(access_token.expose_secret().into()); + let response = github_client.get_user(&name, &access_token).await?; + println!("{response:#?}"); + } Request::OrgByName { org_name, access_token, diff --git a/crates/crates_io_github/src/lib.rs b/crates/crates_io_github/src/lib.rs index a5463f78f0a..511faad6cc8 100644 --- a/crates/crates_io_github/src/lib.rs +++ b/crates/crates_io_github/src/lib.rs @@ -20,6 +20,7 @@ type Result = std::result::Result; #[async_trait] pub trait GitHubClient: Send + Sync { async fn current_user(&self, auth: &AccessToken) -> Result; + async fn get_user(&self, name: &str, auth: &AccessToken) -> Result; async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result; async fn team_by_name( &self, @@ -102,6 +103,11 @@ impl GitHubClient for RealGitHubClient { self.request("/user", auth).await } + async fn get_user(&self, name: &str, auth: &AccessToken) -> Result { + let url = format!("/users/{name}"); + self.request(&url, auth).await + } + async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result { let url = format!("/orgs/{org_name}"); self.request(&url, auth).await diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml new file mode 100644 index 00000000000..dbf9c1a5e36 --- /dev/null +++ b/crates/crates_io_trustpub/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "crates_io_trustpub" +version = "0.0.0" +license = "MIT OR Apache-2.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +regex = "=1.11.1" +thiserror = "=2.0.12" + +[dev-dependencies] +claims = "=0.8.0" +insta = "=1.43.1" diff --git a/crates/crates_io_trustpub/README.md b/crates/crates_io_trustpub/README.md new file mode 100644 index 00000000000..26029b63740 --- /dev/null +++ b/crates/crates_io_trustpub/README.md @@ -0,0 +1,3 @@ +# crates_io_trustpub + +This crate contains code related to the "[Trusted Publishing](https://github.com/rust-lang/rfcs/pull/3691)" feature of crates.io. diff --git a/crates/crates_io_trustpub/src/github/mod.rs b/crates/crates_io_trustpub/src/github/mod.rs new file mode 100644 index 00000000000..8695201df0e --- /dev/null +++ b/crates/crates_io_trustpub/src/github/mod.rs @@ -0,0 +1 @@ +pub mod validation; diff --git a/crates/crates_io_trustpub/src/github/validation.rs b/crates/crates_io_trustpub/src/github/validation.rs new file mode 100644 index 00000000000..fdf708fca0f --- /dev/null +++ b/crates/crates_io_trustpub/src/github/validation.rs @@ -0,0 +1,154 @@ +use std::sync::LazyLock; + +const MAX_FIELD_LENGTH: usize = 255; + +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + #[error("GitHub repository owner name may not be empty")] + OwnerEmpty, + #[error("GitHub repository owner name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + OwnerTooLong, + #[error("Invalid GitHub repository owner name")] + OwnerInvalid, + + #[error("GitHub repository name may not be empty")] + RepoEmpty, + #[error("GitHub repository name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + RepoTooLong, + #[error("Invalid GitHub repository name")] + RepoInvalid, + + #[error("Workflow filename may not be empty")] + WorkflowFilenameEmpty, + #[error("Workflow filename is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + WorkflowFilenameTooLong, + #[error("Workflow filename must end with `.yml` or `.yaml`")] + WorkflowFilenameMissingSuffix, + #[error("Workflow filename must be a filename only, without directories")] + WorkflowFilenameContainsSlash, + + #[error("Environment name may not be empty (use `null` to omit)")] + EnvironmentEmptyString, + #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] + EnvironmentTooLong, + #[error("Environment name may not start with whitespace")] + EnvironmentStartsWithWhitespace, + #[error("Environment name may not end with whitespace")] + EnvironmentEndsWithWhitespace, + #[error(r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#)] + EnvironmentInvalidChars, +} + +pub fn validate_owner(owner: &str) -> Result<(), ValidationError> { + static RE_VALID_GITHUB_OWNER: LazyLock = + LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$").unwrap()); + + if owner.is_empty() { + Err(ValidationError::OwnerEmpty) + } else if owner.len() > MAX_FIELD_LENGTH { + Err(ValidationError::OwnerTooLong) + } else if !RE_VALID_GITHUB_OWNER.is_match(owner) { + Err(ValidationError::OwnerInvalid) + } else { + Ok(()) + } +} + +pub fn validate_repo(repo: &str) -> Result<(), ValidationError> { + static RE_VALID_GITHUB_REPO: LazyLock = + LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9-_.]+$").unwrap()); + + if repo.is_empty() { + Err(ValidationError::RepoEmpty) + } else if repo.len() > MAX_FIELD_LENGTH { + Err(ValidationError::RepoTooLong) + } else if !RE_VALID_GITHUB_REPO.is_match(repo) { + Err(ValidationError::RepoInvalid) + } else { + Ok(()) + } +} + +pub fn validate_workflow_filename(filename: &str) -> Result<(), ValidationError> { + if filename.is_empty() { + Err(ValidationError::WorkflowFilenameEmpty) + } else if filename.len() > MAX_FIELD_LENGTH { + Err(ValidationError::WorkflowFilenameTooLong) + } else if !filename.ends_with(".yml") && !filename.ends_with(".yaml") { + Err(ValidationError::WorkflowFilenameMissingSuffix) + } else if filename.contains('/') { + Err(ValidationError::WorkflowFilenameContainsSlash) + } else { + Ok(()) + } +} + +pub fn validate_environment(env: &str) -> Result<(), ValidationError> { + static RE_INVALID_ENVIRONMENT_CHARS: LazyLock = + LazyLock::new(|| regex::Regex::new(r#"[\x00-\x1F\x7F'"`,;\\]"#).unwrap()); + + if env.is_empty() { + Err(ValidationError::EnvironmentEmptyString) + } else if env.len() > MAX_FIELD_LENGTH { + Err(ValidationError::EnvironmentTooLong) + } else if env.starts_with(" ") { + Err(ValidationError::EnvironmentStartsWithWhitespace) + } else if env.ends_with(" ") { + Err(ValidationError::EnvironmentEndsWithWhitespace) + } else if RE_INVALID_ENVIRONMENT_CHARS.is_match(env) { + Err(ValidationError::EnvironmentInvalidChars) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::assert_err; + use insta::assert_snapshot; + + #[test] + fn test_validate_owner() { + assert_snapshot!(assert_err!(validate_owner("")), @"GitHub repository owner name may not be empty"); + assert_snapshot!(assert_err!(validate_owner(&"x".repeat(256))), @"GitHub repository owner name is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_owner("invalid_characters@")), @"Invalid GitHub repository owner name"); + } + + #[test] + fn test_validate_repo() { + assert_snapshot!(assert_err!(validate_repo("")), @"GitHub repository name may not be empty"); + assert_snapshot!(assert_err!(validate_repo(&"x".repeat(256))), @"GitHub repository name is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_repo("$invalid#characters")), @"Invalid GitHub repository name"); + } + + #[test] + fn test_validate_workflow_filename() { + assert_snapshot!(assert_err!(validate_workflow_filename("")), @"Workflow filename may not be empty"); + assert_snapshot!(assert_err!(validate_workflow_filename(&"x".repeat(256))), @"Workflow filename is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_workflow_filename("missing_suffix")), @"Workflow filename must end with `.yml` or `.yaml`"); + assert_snapshot!(assert_err!(validate_workflow_filename("/slash")), @"Workflow filename must end with `.yml` or `.yaml`"); + assert_snapshot!(assert_err!(validate_workflow_filename("/many/slashes")), @"Workflow filename must end with `.yml` or `.yaml`"); + assert_snapshot!(assert_err!(validate_workflow_filename("/slash.yml")), @"Workflow filename must be a filename only, without directories"); + } + + #[test] + fn test_validate_environment() { + assert_snapshot!(assert_err!(validate_environment("")), @"Environment name may not be empty (use `null` to omit)"); + assert_snapshot!(assert_err!(validate_environment(&"x".repeat(256))), @"Environment name is too long (maximum is 255 characters)"); + assert_snapshot!(assert_err!(validate_environment(" foo")), @"Environment name may not start with whitespace"); + assert_snapshot!(assert_err!(validate_environment("foo ")), @"Environment name may not end with whitespace"); + assert_snapshot!(assert_err!(validate_environment("'")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\"")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("`")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment(",")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment(";")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\\")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\x00")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\x1f")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\x7f")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\t")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\r")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + assert_snapshot!(assert_err!(validate_environment("\n")), @r#"Environment name must not contain non-printable characters or the characters "'", """, "`", ",", ";", "\""#); + } +} diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs new file mode 100644 index 00000000000..da38d1f6d42 --- /dev/null +++ b/crates/crates_io_trustpub/src/lib.rs @@ -0,0 +1,3 @@ +#![doc = include_str!("../README.md")] + +pub mod github; diff --git a/src/controllers.rs b/src/controllers.rs index 5563b2eaced..cd055a67c18 100644 --- a/src/controllers.rs +++ b/src/controllers.rs @@ -13,5 +13,6 @@ pub mod site_metadata; pub mod summary; pub mod team; pub mod token; +pub mod trustpub; pub mod user; pub mod version; diff --git a/src/controllers/trustpub/github_configs/create/mod.rs b/src/controllers/trustpub/github_configs/create/mod.rs new file mode 100644 index 00000000000..8aff9f0f062 --- /dev/null +++ b/src/controllers/trustpub/github_configs/create/mod.rs @@ -0,0 +1,129 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::krate::load_crate; +use crate::controllers::trustpub::github_configs::emails::ConfigCreatedEmail; +use crate::controllers::trustpub::github_configs::json; +use crate::util::errors::{AppResult, bad_request}; +use axum::Json; +use crates_io_database::models::OwnerKind; +use crates_io_database::models::trustpub::NewGitHubConfig; +use crates_io_database::schema::{crate_owners, emails, users}; +use crates_io_github::GitHubError; +use crates_io_trustpub::github::validation::{ + validate_environment, validate_owner, validate_repo, validate_workflow_filename, +}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::request::Parts; +use oauth2::AccessToken; +use secrecy::ExposeSecret; + +#[cfg(test)] +mod tests; + +/// Create a new Trusted Publishing configuration for GitHub Actions. +#[utoipa::path( + put, + path = "/api/v1/trusted_publishing/github_configs", + security(("cookie" = [])), + request_body = inline(json::CreateRequest), + tag = "trusted_publishing", + responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))), +)] +pub async fn create_trustpub_github_config( + state: AppState, + parts: Parts, + json: json::CreateRequest, +) -> AppResult> { + let json_config = json.github_config; + + validate_owner(&json_config.repository_owner)?; + validate_repo(&json_config.repository_name)?; + validate_workflow_filename(&json_config.workflow_filename)?; + if let Some(env) = &json_config.environment { + validate_environment(env)?; + } + + let mut conn = state.db_write().await?; + + let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; + let auth_user = auth.user(); + + let krate = load_crate(&mut conn, &json_config.krate).await?; + + let user_owners = crate_owners::table + .filter(crate_owners::crate_id.eq(krate.id)) + .filter(crate_owners::deleted.eq(false)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) + .inner_join(users::table) + .inner_join(emails::table.on(users::id.eq(emails::user_id))) + .select((users::id, users::gh_login, emails::email, emails::verified)) + .load::<(i32, String, String, bool)>(&mut conn) + .await?; + + if !user_owners.iter().any(|owner| owner.0 == auth_user.id) { + return Err(bad_request("You are not an owner of this crate")); + } + + // Lookup `repository_owner_id` via GitHub API + + let owner = &json_config.repository_owner; + let gh_auth = &auth_user.gh_access_token; + let gh_auth = AccessToken::new(gh_auth.expose_secret().to_string()); + let github_user = match state.github.get_user(owner, &gh_auth).await { + Ok(user) => user, + Err(GitHubError::NotFound(_)) => Err(bad_request("Unknown GitHub user or organization"))?, + Err(err) => Err(err)?, + }; + + // Save the new GitHub OIDC config to the database + + let new_config = NewGitHubConfig { + crate_id: krate.id, + // Use the normalized owner name as provided by GitHub. + repository_owner: &github_user.login, + repository_owner_id: github_user.id, + repository_name: &json_config.repository_name, + workflow_filename: &json_config.workflow_filename, + environment: json_config.environment.as_deref(), + }; + + let saved_config = new_config.insert(&mut conn).await?; + + // Send notification emails to crate owners + + let recipients = user_owners + .into_iter() + .filter(|(_, _, _, verified)| *verified) + .map(|(_, login, email, _)| (login, email)) + .collect::>(); + + for (recipient, email_address) in &recipients { + let email = ConfigCreatedEmail { + recipient, + user: &auth_user.gh_login, + krate: &krate.name, + repository_owner: &saved_config.repository_owner, + repository_name: &saved_config.repository_name, + workflow_filename: &saved_config.workflow_filename, + environment: saved_config.environment.as_deref().unwrap_or("(not set)"), + }; + + if let Err(err) = state.emails.send(email_address, email).await { + warn!("Failed to send trusted publishing notification to {email_address}: {err}") + } + } + + let github_config = json::GitHubConfig { + id: saved_config.id, + krate: krate.name, + repository_owner: saved_config.repository_owner, + repository_owner_id: saved_config.repository_owner_id, + repository_name: saved_config.repository_name, + workflow_filename: saved_config.workflow_filename, + environment: saved_config.environment, + created_at: saved_config.created_at, + }; + + Ok(Json(json::CreateResponse { github_config })) +} diff --git a/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path-2.snap b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path-2.snap new file mode 100644 index 00000000000..ee0e0e731c9 --- /dev/null +++ b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path-2.snap @@ -0,0 +1,29 @@ +--- +source: src/controllers/trustpub/github_configs/create/tests.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configration added to foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Hello foo! + +crates.io user foo has added a new "Trusted Publishing" configuration for G= +itHub Actions to a crate that you manage (foo). Trusted publishers act as t= +rusted users and can publish new versions of the crate automatically. + +Trusted Publishing configuration: + +- Repository owner: rust-lang +- Repository name: foo-rs +- Workflow filename: publish.yml +- Environment: (not set) + +If you did not make this change and you think it was made maliciously, you = +can remove the configuration from the crate via the "Settings" tab on the c= +rate's page. + +If you are unable to revert the change and need to do so, you can email hel= +p@crates.io to communicate with the crates.io support team. diff --git a/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path.snap b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path.snap new file mode 100644 index 00000000000..9b76d1fe41b --- /dev/null +++ b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path.snap @@ -0,0 +1,16 @@ +--- +source: src/controllers/trustpub/github_configs/create/tests.rs +expression: response.json() +--- +{ + "github_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "repository_name": "foo-rs", + "repository_owner": "rust-lang", + "repository_owner_id": 42, + "workflow_filename": "publish.yml" + } +} diff --git a/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path_with_environment.snap b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path_with_environment.snap new file mode 100644 index 00000000000..e9f65c508ab --- /dev/null +++ b/src/controllers/trustpub/github_configs/create/snapshots/crates_io__controllers__trustpub__github_configs__create__tests__happy_path_with_environment.snap @@ -0,0 +1,16 @@ +--- +source: src/controllers/trustpub/github_configs/create/tests.rs +expression: response.json() +--- +{ + "github_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": "production", + "id": 1, + "repository_name": "foo-rs", + "repository_owner": "rust-lang", + "repository_owner_id": 42, + "workflow_filename": "publish.yml" + } +} diff --git a/src/controllers/trustpub/github_configs/create/tests.rs b/src/controllers/trustpub/github_configs/create/tests.rs new file mode 100644 index 00000000000..9905d03375c --- /dev/null +++ b/src/controllers/trustpub/github_configs/create/tests.rs @@ -0,0 +1,372 @@ +use crate::tests::builders::CrateBuilder; +use crate::tests::util::{RequestHelper, Response, TestApp}; +use anyhow::anyhow; +use bytes::Bytes; +use crates_io_database::schema::trustpub_configs_github; +use crates_io_github::{GitHubError, GitHubUser, MockGitHubClient}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::StatusCode; +use insta::{assert_json_snapshot, assert_snapshot}; +use serde_json::json; + +const URL: &str = "/api/v1/trusted_publishing/github_configs"; + +const CRATE_NAME: &str = "foo"; + +fn simple_github_mock() -> MockGitHubClient { + let mut github_mock = MockGitHubClient::new(); + github_mock.expect_get_user().returning(|login, _| { + Ok(GitHubUser { + avatar_url: None, + email: None, + id: 42, + login: login.into(), + name: None, + }) + }); + github_mock +} + +async fn run_test(payload: impl Into) -> (TestApp, Response<()>) { + async fn inner(payload: Bytes) -> (TestApp, Response<()>) { + let (app, _client, cookie_client) = TestApp::full() + .with_github(simple_github_mock()) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await + .unwrap(); + + (app, cookie_client.put::<()>(URL, payload).await) + } + + inner(payload.into()).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let (app, response) = run_test(body).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" }); + + assert_snapshot!(app.emails_snapshot().await); + + let mut conn = app.db_conn().await; + let config_ids = trustpub_configs_github::table + .select(trustpub_configs_github::id) + .get_results::(&mut conn) + .await?; + + assert_eq!(config_ids.len(), 1); + assert_eq!(config_ids[0], 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_environment() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": "production", + } + }))?; + + let (_app, response) = run_test(body).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_empty_body() -> anyhow::Result<()> { + let (_app, response) = run_test("").await; + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Expected request with `Content-Type: application/json`"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_empty_json_object() -> anyhow::Result<()> { + let (_app, response) = run_test("{}").await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize the JSON body into the target type: missing field `github_config` at line 1 column 2"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_owner() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "ยง$%&", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let (_app, response) = run_test(body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid GitHub repository owner name"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_repo() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "@foo", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let (_app, response) = run_test(body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid GitHub repository name"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_workflow() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "workflows/ci.json", + "environment": null, + } + }))?; + + let (_app, response) = run_test(body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Workflow filename must end with `.yml` or `.yaml`"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_environment() -> anyhow::Result<()> { + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": "", + } + }))?; + + let (_app, response) = run_test(body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Environment name may not be empty (use `null` to omit)"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unauthenticated() -> anyhow::Result<()> { + let (app, client, cookie_client) = TestApp::full() + .with_github(simple_github_mock()) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_github(simple_github_mock()) + .with_token() + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let response = token_client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_crate() -> anyhow::Result<()> { + let (_app, _client, cookie_client) = TestApp::full() + .with_github(simple_github_mock()) + .with_user() + .await; + + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let response = cookie_client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"crate `foo` does not exist"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_non_owner() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full() + .with_github(simple_github_mock()) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let other_client = app.db_new_user("other_user").await; + + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let response = other_client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unknown_github_user() -> anyhow::Result<()> { + let mut github_mock = MockGitHubClient::new(); + github_mock + .expect_get_user() + .returning(|login, _| Err(GitHubError::NotFound(anyhow!("User {} not found", login)))); + + let (app, _client, cookie_client) = TestApp::full().with_github(github_mock).with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let response = cookie_client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Unknown GitHub user or organization"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_github_error() -> anyhow::Result<()> { + let mut github_mock = MockGitHubClient::new(); + github_mock + .expect_get_user() + .returning(|_, _| Err(GitHubError::Other(anyhow!("Internal Server Error")))); + + let (app, _client, cookie_client) = TestApp::full().with_github(github_mock).with_user().await; + + let mut conn = app.db_conn().await; + + CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id) + .build(&mut conn) + .await?; + + let body = serde_json::to_vec(&json!({ + "github_config": { + "crate": CRATE_NAME, + "repository_owner": "rust-lang", + "repository_name": "foo-rs", + "workflow_filename": "publish.yml", + "environment": null, + } + }))?; + + let response = cookie_client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Internal Server Error"}]}"#); + + Ok(()) +} diff --git a/src/controllers/trustpub/github_configs/emails.rs b/src/controllers/trustpub/github_configs/emails.rs new file mode 100644 index 00000000000..026266110d4 --- /dev/null +++ b/src/controllers/trustpub/github_configs/emails.rs @@ -0,0 +1,50 @@ +use crate::email::Email; + +/// Email template for notifying crate owners about a new crate version +/// being published. +#[derive(Debug, Clone)] +pub struct ConfigCreatedEmail<'a> { + pub recipient: &'a str, + pub user: &'a str, + pub krate: &'a str, + pub repository_owner: &'a str, + pub repository_name: &'a str, + pub workflow_filename: &'a str, + pub environment: &'a str, +} + +impl Email for ConfigCreatedEmail<'_> { + fn subject(&self) -> String { + let Self { krate, .. } = self; + format!("crates.io: Trusted Publishing configration added to {krate}") + } + + fn body(&self) -> String { + let Self { + recipient, + user, + krate, + repository_owner, + repository_name, + workflow_filename, + environment, + } = self; + + format!( + "Hello {recipient}! + +crates.io user {user} has added a new \"Trusted Publishing\" configuration for GitHub Actions to a crate that you manage ({krate}). Trusted publishers act as trusted users and can publish new versions of the crate automatically. + +Trusted Publishing configuration: + +- Repository owner: {repository_owner} +- Repository name: {repository_name} +- Workflow filename: {workflow_filename} +- Environment: {environment} + +If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the \"Settings\" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io to communicate with the crates.io support team." + ) + } +} diff --git a/src/controllers/trustpub/github_configs/json.rs b/src/controllers/trustpub/github_configs/json.rs new file mode 100644 index 00000000000..4d44e303bc0 --- /dev/null +++ b/src/controllers/trustpub/github_configs/json.rs @@ -0,0 +1,49 @@ +use axum::Json; +use axum::extract::FromRequest; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GitHubConfig { + #[schema(example = 42)] + pub id: i32, + #[schema(example = "regex")] + #[serde(rename = "crate")] + pub krate: String, + #[schema(example = "rust-lang")] + pub repository_owner: String, + #[schema(example = 5430905)] + pub repository_owner_id: i32, + #[schema(example = "regex")] + pub repository_name: String, + #[schema(example = "ci.yml")] + pub workflow_filename: String, + #[schema(example = json!(null))] + pub environment: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct NewGitHubConfig { + #[schema(example = "regex")] + #[serde(rename = "crate")] + pub krate: String, + #[schema(example = "rust-lang")] + pub repository_owner: String, + #[schema(example = "regex")] + pub repository_name: String, + #[schema(example = "ci.yml")] + pub workflow_filename: String, + #[schema(example = json!(null))] + pub environment: Option, +} + +#[derive(Debug, Deserialize, FromRequest, utoipa::ToSchema)] +#[from_request(via(Json))] +pub struct CreateRequest { + pub github_config: NewGitHubConfig, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CreateResponse { + pub github_config: GitHubConfig, +} diff --git a/src/controllers/trustpub/github_configs/mod.rs b/src/controllers/trustpub/github_configs/mod.rs new file mode 100644 index 00000000000..9ebca9977f5 --- /dev/null +++ b/src/controllers/trustpub/github_configs/mod.rs @@ -0,0 +1,3 @@ +pub mod create; +pub mod emails; +pub mod json; diff --git a/src/controllers/trustpub/mod.rs b/src/controllers/trustpub/mod.rs new file mode 100644 index 00000000000..6903a27236a --- /dev/null +++ b/src/controllers/trustpub/mod.rs @@ -0,0 +1 @@ +pub mod github_configs; diff --git a/src/router.rs b/src/router.rs index 88ce4c62beb..b589892cf96 100644 --- a/src/router.rs +++ b/src/router.rs @@ -87,6 +87,10 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(session::begin_session)) .routes(routes!(session::authorize_session)) .routes(routes!(session::end_session)) + // OIDC / Trusted Publishing + .routes(routes!( + trustpub::github_configs::create::create_trustpub_github_config, + )) .split_for_parts(); let mut router = router diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index cb7a074036a..2f1b4601c27 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -598,6 +598,57 @@ expression: response.json() ], "type": "string" }, + "GitHubConfig": { + "properties": { + "crate": { + "example": "regex", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "environment": { + "example": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "example": 42, + "format": "int32", + "type": "integer" + }, + "repository_name": { + "example": "regex", + "type": "string" + }, + "repository_owner": { + "example": "rust-lang", + "type": "string" + }, + "repository_owner_id": { + "example": 5430905, + "format": "int32", + "type": "integer" + }, + "workflow_filename": { + "example": "ci.yml", + "type": "string" + } + }, + "required": [ + "id", + "crate", + "repository_owner", + "repository_owner_id", + "repository_name", + "workflow_filename", + "created_at" + ], + "type": "object" + }, "Keyword": { "properties": { "crates_cnt": { @@ -685,6 +736,40 @@ expression: response.json() ], "type": "object" }, + "NewGitHubConfig": { + "properties": { + "crate": { + "example": "regex", + "type": "string" + }, + "environment": { + "example": null, + "type": [ + "string", + "null" + ] + }, + "repository_name": { + "example": "regex", + "type": "string" + }, + "repository_owner": { + "example": "rust-lang", + "type": "string" + }, + "workflow_filename": { + "example": "ci.yml", + "type": "string" + } + }, + "required": [ + "crate", + "repository_owner", + "repository_name", + "workflow_filename" + ], + "type": "object" + }, "Owner": { "properties": { "avatar": { @@ -4047,6 +4132,58 @@ expression: response.json() ] } }, + "/api/v1/trusted_publishing/github_configs": { + "put": { + "operationId": "create_trustpub_github_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "github_config": { + "$ref": "#/components/schemas/NewGitHubConfig" + } + }, + "required": [ + "github_config" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "github_config": { + "$ref": "#/components/schemas/GitHubConfig" + } + }, + "required": [ + "github_config" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Create a new Trusted Publishing configuration for GitHub Actions.", + "tags": [ + "trusted_publishing" + ] + } + }, "/api/v1/users/{id}/resend": { "put": { "operationId": "resend_email_verification", diff --git a/src/util/errors.rs b/src/util/errors.rs index fd4f9ea9794..cdc357c18bc 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -232,6 +232,12 @@ impl From for BoxedAppError { } } +impl From for BoxedAppError { + fn from(error: crates_io_trustpub::github::validation::ValidationError) -> Self { + bad_request(error) + } +} + // ============================================================================= // Internal error for use with `chain_error`