From 7660479232f155b300b90ceb55570abd8b1507fc Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 3 Nov 2025 15:12:49 +0100 Subject: [PATCH] trustpub: Implement `DELETE /api/v1/trusted_publishing/gitlab_configs` API endpoint --- .../trustpub/gitlab_configs/delete.rs | 112 +++++++ .../trustpub/gitlab_configs/mod.rs | 1 + src/router.rs | 1 + .../routes/trustpub/gitlab_configs/delete.rs | 289 ++++++++++++++++++ .../routes/trustpub/gitlab_configs/mod.rs | 1 + ..._gitlab_configs__delete__happy_path-2.snap | 21 ++ ..._configs__delete__legacy_token_auth-2.snap | 21 ++ ..._auth_with_trusted_publishing_scope-2.snap | 21 ++ ...oken_auth_with_wildcard_crate_scope-2.snap | 21 ++ ...egration__openapi__openapi_snapshot-2.snap | 34 +++ 10 files changed, 522 insertions(+) create mode 100644 src/controllers/trustpub/gitlab_configs/delete.rs create mode 100644 src/tests/routes/trustpub/gitlab_configs/delete.rs create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__happy_path-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__legacy_token_auth-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_trusted_publishing_scope-2.snap create mode 100644 src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_wildcard_crate_scope-2.snap diff --git a/src/controllers/trustpub/gitlab_configs/delete.rs b/src/controllers/trustpub/gitlab_configs/delete.rs new file mode 100644 index 00000000000..4e5635368ae --- /dev/null +++ b/src/controllers/trustpub/gitlab_configs/delete.rs @@ -0,0 +1,112 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::trustpub::emails::{ConfigDeletedEmail, ConfigType}; +use crate::util::errors::{AppResult, bad_request, not_found}; +use anyhow::Context; +use axum::extract::Path; +use crates_io_database::models::token::EndpointScope; +use crates_io_database::models::trustpub::GitLabConfig; +use crates_io_database::models::{Crate, OwnerKind}; +use crates_io_database::schema::{crate_owners, crates, emails, trustpub_configs_gitlab, users}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::StatusCode; +use http::request::Parts; +use tracing::warn; + +/// Delete Trusted Publishing configuration for GitLab CI/CD. +#[utoipa::path( + delete, + path = "/api/v1/trusted_publishing/gitlab_configs/{id}", + params( + ("id" = i32, Path, description = "ID of the Trusted Publishing configuration"), + ), + security(("cookie" = []), ("api_token" = [])), + tag = "trusted_publishing", + responses((status = 204, description = "Successful Response")), +)] +pub async fn delete_trustpub_gitlab_config( + state: AppState, + Path(id): Path, + parts: Parts, +) -> AppResult { + let mut conn = state.db_write().await?; + + // First, find the config and crate to get the crate name for scope validation + let (config, krate) = trustpub_configs_gitlab::table + .inner_join(crates::table) + .filter(trustpub_configs_gitlab::id.eq(id)) + .select((GitLabConfig::as_select(), Crate::as_select())) + .first::<(GitLabConfig, Crate)>(&mut conn) + .await + .optional()? + .ok_or_else(not_found)?; + + let auth = AuthCheck::default() + .with_endpoint_scope(EndpointScope::TrustedPublishing) + .for_crate(&krate.name) + .check(&parts, &mut conn) + .await?; + let auth_user = auth.user(); + + // 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_gitlab::table.filter(trustpub_configs_gitlab::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 config = ConfigType::GitLab(&config); + + let context = ConfigDeletedEmail { + recipient, + auth_user, + krate: &krate, + config, + }; + + if let Err(err) = send_notification_email(&state, email_address, context).await { + warn!("Failed to send trusted publishing notification to {email_address}: {err}"); + } + } + + Ok(StatusCode::NO_CONTENT) +} + +async fn send_notification_email( + state: &AppState, + email_address: &str, + context: ConfigDeletedEmail<'_>, +) -> anyhow::Result<()> { + let email = context.render(); + let email = email.context("Failed to render email template")?; + + state + .emails + .send(email_address, email) + .await + .context("Failed to send email") +} diff --git a/src/controllers/trustpub/gitlab_configs/mod.rs b/src/controllers/trustpub/gitlab_configs/mod.rs index 7b1b719cbed..3621f02d902 100644 --- a/src/controllers/trustpub/gitlab_configs/mod.rs +++ b/src/controllers/trustpub/gitlab_configs/mod.rs @@ -1,2 +1,3 @@ pub mod create; +pub mod delete; pub mod json; diff --git a/src/router.rs b/src/router.rs index fc63a87550f..6b7f5813bf0 100644 --- a/src/router.rs +++ b/src/router.rs @@ -100,6 +100,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { )) .routes(routes!( trustpub::gitlab_configs::create::create_trustpub_gitlab_config, + trustpub::gitlab_configs::delete::delete_trustpub_gitlab_config, )) .split_for_parts(); diff --git a/src/tests/routes/trustpub/gitlab_configs/delete.rs b/src/tests/routes/trustpub/gitlab_configs/delete.rs new file mode 100644 index 00000000000..cbef9d16ef0 --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/delete.rs @@ -0,0 +1,289 @@ +use crate::builders::CrateBuilder; +use crate::util::{RequestHelper, TestApp}; +use crates_io_database::models::Crate; +use crates_io_database::models::token::{CrateScope, EndpointScope}; +use crates_io_database::models::trustpub::{GitLabConfig, NewGitLabConfig}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use insta::assert_snapshot; +use serde_json::json; + +const BASE_URL: &str = "/api/v1/trusted_publishing/gitlab_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 = NewGitLabConfig { + crate_id, + namespace: "rust-lang", + project: "foo-rs", + workflow_filepath: ".gitlab-ci.yml", + environment: None, + }; + + config.insert(conn).await +} + +async fn get_all_configs(conn: &mut AsyncPgConnection) -> QueryResult> { + GitLabConfig::query().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_snapshot!(response.status(), @"204 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_snapshot!(response.status(), @"403 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(()) +} + +/// Delete the config with a legacy API token. +#[tokio::test(flavor = "multi_thread")] +async fn test_legacy_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_snapshot!(response.status(), @"204 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 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_snapshot!(response.status(), @"404 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_snapshot!(response.status(), @"400 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_snapshot!(response.status(), @"200 OK"); + + let response = user2.delete::<()>(&delete_url(config.id)).await; + assert_snapshot!(response.status(), @"400 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(()) +} + +/// Delete the config with an API token that has the correct scopes. +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_with_trusted_publishing_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from("foo").unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + ) + .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_snapshot!(response.status(), @"204 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 API token that does not have the required endpoint scope. +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_without_trusted_publishing_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from("foo").unwrap()]), + Some(vec![EndpointScope::PublishUpdate]), + ) + .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_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#); + + // 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 an API token that has the correct endpoint scope but wrong crate scope. +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_with_wrong_crate_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from("other-crate").unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + ) + .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_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#); + + // 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(()) +} + +/// Delete the config with an API token that has a wildcard crate scope. +#[tokio::test(flavor = "multi_thread")] +async fn test_token_auth_with_wildcard_crate_scope() -> anyhow::Result<()> { + let (app, _client, cookie_client, token_client) = TestApp::full() + .with_scoped_token( + Some(vec![CrateScope::try_from("*").unwrap()]), + Some(vec![EndpointScope::TrustedPublishing]), + ) + .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_snapshot!(response.status(), @"204 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(()) +} diff --git a/src/tests/routes/trustpub/gitlab_configs/mod.rs b/src/tests/routes/trustpub/gitlab_configs/mod.rs index 0f562a43b2b..aeaae9dddf0 100644 --- a/src/tests/routes/trustpub/gitlab_configs/mod.rs +++ b/src/tests/routes/trustpub/gitlab_configs/mod.rs @@ -1 +1,2 @@ mod create; +mod delete; diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__happy_path-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__happy_path-2.snap new file mode 100644 index 00000000000..e1ab328926a --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__happy_path-2.snap @@ -0,0 +1,21 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/delete.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configuration removed from foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You removed a "Trusted Publishing" configuration for GitLab CI from your crate "foo". + +The removed configuration was for the workflow file at https://gitlab.com/rust-lang/foo-rs/-/blob/HEAD/.gitlab-ci.yml. + +If you did not make this change and you think it was made maliciously, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__legacy_token_auth-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__legacy_token_auth-2.snap new file mode 100644 index 00000000000..e1ab328926a --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__legacy_token_auth-2.snap @@ -0,0 +1,21 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/delete.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configuration removed from foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You removed a "Trusted Publishing" configuration for GitLab CI from your crate "foo". + +The removed configuration was for the workflow file at https://gitlab.com/rust-lang/foo-rs/-/blob/HEAD/.gitlab-ci.yml. + +If you did not make this change and you think it was made maliciously, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_trusted_publishing_scope-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_trusted_publishing_scope-2.snap new file mode 100644 index 00000000000..e1ab328926a --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_trusted_publishing_scope-2.snap @@ -0,0 +1,21 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/delete.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configuration removed from foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You removed a "Trusted Publishing" configuration for GitLab CI from your crate "foo". + +The removed configuration was for the workflow file at https://gitlab.com/rust-lang/foo-rs/-/blob/HEAD/.gitlab-ci.yml. + +If you did not make this change and you think it was made maliciously, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_wildcard_crate_scope-2.snap b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_wildcard_crate_scope-2.snap new file mode 100644 index 00000000000..e1ab328926a --- /dev/null +++ b/src/tests/routes/trustpub/gitlab_configs/snapshots/integration__routes__trustpub__gitlab_configs__delete__token_auth_with_wildcard_crate_scope-2.snap @@ -0,0 +1,21 @@ +--- +source: src/tests/routes/trustpub/gitlab_configs/delete.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configuration removed from foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + +Hello foo! + +You removed a "Trusted Publishing" configuration for GitLab CI from your crate "foo". + +The removed configuration was for the workflow file at https://gitlab.com/rust-lang/foo-rs/-/blob/HEAD/.gitlab-ci.yml. + +If you did not make this change and you think it was made maliciously, you can email help@crates.io for assistance. + +-- +The crates.io Team diff --git a/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap b/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap index 4b013cd2570..94b8a1bd1a4 100644 --- a/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap +++ b/src/tests/snapshots/integration__openapi__openapi_snapshot-2.snap @@ -4532,6 +4532,40 @@ expression: response.json() ] } }, + "/api/v1/trusted_publishing/gitlab_configs/{id}": { + "delete": { + "operationId": "delete_trustpub_gitlab_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": [] + }, + { + "api_token": [] + } + ], + "summary": "Delete Trusted Publishing configuration for GitLab CI/CD.", + "tags": [ + "trusted_publishing" + ] + } + }, "/api/v1/trusted_publishing/tokens": { "delete": { "description": "The access token is expected to be passed in the `Authorization` header\nas a `Bearer` token, similar to how it is used in the publish endpoint.",