From 17888142d33ab2c7ad080f4eff8bb65916642b45 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Tue, 20 May 2025 14:12:16 +0200 Subject: [PATCH] Add `DELETE /api/v1/trusted_publishing/github_configs/{id}` API endpoint --- .../trustpub/github_configs/delete/mod.rs | 95 +++++++++ ...ub_configs__delete__tests__happy_path.snap | 24 +++ .../trustpub/github_configs/delete/tests.rs | 182 ++++++++++++++++++ .../trustpub/github_configs/emails.rs | 47 +++++ .../trustpub/github_configs/mod.rs | 1 + src/router.rs | 1 + ..._io__openapi__tests__openapi_snapshot.snap | 31 +++ 7 files changed, 381 insertions(+) create mode 100644 src/controllers/trustpub/github_configs/delete/mod.rs create mode 100644 src/controllers/trustpub/github_configs/delete/snapshots/crates_io__controllers__trustpub__github_configs__delete__tests__happy_path.snap create mode 100644 src/controllers/trustpub/github_configs/delete/tests.rs diff --git a/src/controllers/trustpub/github_configs/delete/mod.rs b/src/controllers/trustpub/github_configs/delete/mod.rs new file mode 100644 index 00000000000..51b0e533529 --- /dev/null +++ b/src/controllers/trustpub/github_configs/delete/mod.rs @@ -0,0 +1,95 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::trustpub::github_configs::emails::ConfigDeletedEmail; +use crate::util::errors::{AppResult, bad_request, not_found}; +use axum::extract::Path; +use crates_io_database::models::OwnerKind; +use crates_io_database::models::trustpub::GitHubConfig; +use crates_io_database::schema::{crate_owners, crates, emails, trustpub_configs_github, users}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::StatusCode; +use http::request::Parts; + +#[cfg(test)] +mod tests; + +/// Delete Trusted Publishing configuration for GitHub Actions. +#[utoipa::path( + delete, + path = "/api/v1/trusted_publishing/github_configs/{id}", + params( + ("id" = i32, Path, description = "ID of the Trusted Publishing configuration"), + ), + security(("cookie" = [])), + tag = "trusted_publishing", + responses((status = 204, description = "Successful Response")), +)] +pub async fn delete_trustpub_github_config( + state: AppState, + Path(id): Path, + parts: Parts, +) -> AppResult { + let mut conn = state.db_write().await?; + + let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; + let auth_user = auth.user(); + + // Check that a trusted publishing config with the given ID exists, + // and fetch the corresponding crate ID and name. + let (config, crate_name) = trustpub_configs_github::table + .inner_join(crates::table) + .filter(trustpub_configs_github::id.eq(id)) + .select((GitHubConfig::as_select(), crates::name)) + .first::<(GitHubConfig, String)>(&mut conn) + .await + .optional()? + .ok_or_else(not_found)?; + + // Load all crate owners for the given crate ID + let user_owners = crate_owners::table + .filter(crate_owners::crate_id.eq(config.crate_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?; + + // Check if the authenticated user is an owner of the crate + if !user_owners.iter().any(|owner| owner.0 == auth_user.id) { + return Err(bad_request("You are not an owner of this crate")); + } + + // Delete the configuration from the database + diesel::delete(trustpub_configs_github::table.filter(trustpub_configs_github::id.eq(id))) + .execute(&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 = ConfigDeletedEmail { + recipient, + user: &auth_user.gh_login, + krate: &crate_name, + repository_owner: &config.repository_owner, + repository_name: &config.repository_name, + workflow_filename: &config.workflow_filename, + environment: 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}") + } + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/controllers/trustpub/github_configs/delete/snapshots/crates_io__controllers__trustpub__github_configs__delete__tests__happy_path.snap b/src/controllers/trustpub/github_configs/delete/snapshots/crates_io__controllers__trustpub__github_configs__delete__tests__happy_path.snap new file mode 100644 index 00000000000..257b4241ae6 --- /dev/null +++ b/src/controllers/trustpub/github_configs/delete/snapshots/crates_io__controllers__trustpub__github_configs__delete__tests__happy_path.snap @@ -0,0 +1,24 @@ +--- +source: src/controllers/trustpub/github_configs/delete/tests.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configration removed from foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Hello foo! + +crates.io user foo has remove a "Trusted Publishing" configuration for GitH= +ub Actions from a crate that you manage (foo). + +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 email help@crates.io to communicate with the crates.io support team. diff --git a/src/controllers/trustpub/github_configs/delete/tests.rs b/src/controllers/trustpub/github_configs/delete/tests.rs new file mode 100644 index 00000000000..a5b6d14322e --- /dev/null +++ b/src/controllers/trustpub/github_configs/delete/tests.rs @@ -0,0 +1,182 @@ +use crate::tests::builders::CrateBuilder; +use crate::tests::util::{RequestHelper, TestApp}; +use crates_io_database::models::Crate; +use crates_io_database::models::trustpub::{GitHubConfig, NewGitHubConfig}; +use crates_io_database::schema::trustpub_configs_github; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use http::StatusCode; +use insta::assert_snapshot; +use serde_json::json; + +const BASE_URL: &str = "/api/v1/trusted_publishing/github_configs"; +const CRATE_NAME: &str = "foo"; + +fn delete_url(id: i32) -> String { + format!("{BASE_URL}/{id}") +} + +async fn create_crate(conn: &mut AsyncPgConnection, author_id: i32) -> anyhow::Result { + CrateBuilder::new(CRATE_NAME, author_id).build(conn).await +} + +async fn create_config(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult { + let config = NewGitHubConfig { + crate_id, + repository_owner: "rust-lang", + repository_owner_id: 42, + repository_name: "foo-rs", + workflow_filename: "publish.yml", + environment: None, + }; + + config.insert(conn).await +} + +async fn get_all_configs(conn: &mut AsyncPgConnection) -> QueryResult> { + trustpub_configs_github::table + .select(GitHubConfig::as_select()) + .load::(conn) + .await +} + +/// Delete the config with a valid user that is an owner of the crate. +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + let krate = create_crate(&mut conn, cookie_client.as_model().id).await?; + let config = create_config(&mut conn, krate.id).await?; + + let response = cookie_client.delete::<()>(&delete_url(config.id)).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(response.text(), ""); + + // Verify the config was deleted from the database + let configs = get_all_configs(&mut conn).await?; + assert_eq!(configs.len(), 0); + + // Verify emails were sent to crate owners + assert_snapshot!(app.emails_snapshot().await); + + Ok(()) +} + +/// Try to delete the config with an unauthenticated client. +#[tokio::test(flavor = "multi_thread")] +async fn test_unauthenticated() -> anyhow::Result<()> { + let (app, client, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + let krate = create_crate(&mut conn, cookie_client.as_model().id).await?; + let config = create_config(&mut conn, krate.id).await?; + + let response = client.delete::<()>(&delete_url(config.id)).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + + // Verify the config was not deleted + let configs = get_all_configs(&mut conn).await?; + assert_eq!(configs.len(), 1); + + // Verify no emails were sent to crate owners + assert_eq!(app.emails().await.len(), 0); + + Ok(()) +} + +/// Try to delete the config with API token authentication. +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full().with_token().await; + let mut conn = app.db_conn().await; + + let krate = create_crate(&mut conn, cookie_client.as_model().id).await?; + let config = create_config(&mut conn, krate.id).await?; + + let response = token_client.delete::<()>(&delete_url(config.id)).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"}]}"#); + + // Verify the config was not deleted + let configs = get_all_configs(&mut conn).await?; + assert_eq!(configs.len(), 1); + + // Verify no emails were sent to crate owners + assert_eq!(app.emails().await.len(), 0); + + Ok(()) +} + +/// Try to delete a config that does not exist. +#[tokio::test(flavor = "multi_thread")] +async fn test_config_not_found() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let response = cookie_client.delete::<()>(&delete_url(42)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#); + + // Verify no emails were sent to crate owners + assert_eq!(app.emails().await.len(), 0); + + Ok(()) +} + +/// Try to delete the config with a user who is not an owner of the crate. +#[tokio::test(flavor = "multi_thread")] +async fn test_non_owner() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + let krate = create_crate(&mut conn, cookie_client.as_model().id).await?; + let config = create_config(&mut conn, krate.id).await?; + + // Create another user who is not an owner of the crate + let other_client = app.db_new_user("other_user").await; + + let response = other_client.delete::<()>(&delete_url(config.id)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#); + + // Verify the config was not deleted + let configs = get_all_configs(&mut conn).await?; + assert_eq!(configs.len(), 1); + + // Verify no emails were sent to crate owners + assert_eq!(app.emails().await.len(), 0); + + Ok(()) +} + +/// Try to delete the config with a user that is part of a team that owns +/// the crate. +#[tokio::test(flavor = "multi_thread")] +async fn test_team_owner() -> anyhow::Result<()> { + let (app, _client) = TestApp::full().empty().await; + let mut conn = app.db_conn().await; + + let user = app.db_new_user("user-org-owner").await; + let user2 = app.db_new_user("user-one-team").await; + + let krate = create_crate(&mut conn, user.as_model().id).await?; + let config = create_config(&mut conn, krate.id).await?; + + let body = json!({ "owners": ["github:test-org:all"] }).to_string(); + let response = user.put::<()>("/api/v1/crates/foo/owners", body).await; + assert_eq!(response.status(), StatusCode::OK); + + let response = user2.delete::<()>(&delete_url(config.id)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#); + + // Verify the config was not deleted + let configs = get_all_configs(&mut conn).await?; + assert_eq!(configs.len(), 1); + + // Verify no emails were sent to crate owners + assert_eq!(app.emails().await.len(), 0); + + Ok(()) +} diff --git a/src/controllers/trustpub/github_configs/emails.rs b/src/controllers/trustpub/github_configs/emails.rs index 026266110d4..84af545c159 100644 --- a/src/controllers/trustpub/github_configs/emails.rs +++ b/src/controllers/trustpub/github_configs/emails.rs @@ -48,3 +48,50 @@ If you are unable to revert the change and need to do so, you can email help@cra ) } } + +/// Email template for notifying crate owners about a Trusted Publishing +/// configuration being deleted. +#[derive(Debug, Clone)] +pub struct ConfigDeletedEmail<'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 ConfigDeletedEmail<'_> { + fn subject(&self) -> String { + let Self { krate, .. } = self; + format!("crates.io: Trusted Publishing configration removed from {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 remove a \"Trusted Publishing\" configuration for GitHub Actions from a crate that you manage ({krate}). + +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 email help@crates.io to communicate with the crates.io support team." + ) + } +} diff --git a/src/controllers/trustpub/github_configs/mod.rs b/src/controllers/trustpub/github_configs/mod.rs index 9ebca9977f5..b74d456a4b5 100644 --- a/src/controllers/trustpub/github_configs/mod.rs +++ b/src/controllers/trustpub/github_configs/mod.rs @@ -1,3 +1,4 @@ pub mod create; +pub mod delete; pub mod emails; pub mod json; diff --git a/src/router.rs b/src/router.rs index d1f926b78aa..aacaffbbcbb 100644 --- a/src/router.rs +++ b/src/router.rs @@ -91,6 +91,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { // OIDC / Trusted Publishing .routes(routes!( trustpub::github_configs::create::create_trustpub_github_config, + trustpub::github_configs::delete::delete_trustpub_github_config, )) .split_for_parts(); diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index c030b92ae17..9729e1cac0d 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -4224,6 +4224,37 @@ expression: response.json() ] } }, + "/api/v1/trusted_publishing/github_configs/{id}": { + "delete": { + "operationId": "delete_trustpub_github_config", + "parameters": [ + { + "description": "ID of the Trusted Publishing configuration", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Delete Trusted Publishing configuration for GitHub Actions.", + "tags": [ + "trusted_publishing" + ] + } + }, "/api/v1/users/{id}/resend": { "put": { "operationId": "resend_email_verification",