Skip to content

Commit 1788814

Browse files
committed
Add DELETE /api/v1/trusted_publishing/github_configs/{id} API endpoint
1 parent 1c93aba commit 1788814

File tree

7 files changed

+381
-0
lines changed

7 files changed

+381
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
use crate::app::AppState;
2+
use crate::auth::AuthCheck;
3+
use crate::controllers::trustpub::github_configs::emails::ConfigDeletedEmail;
4+
use crate::util::errors::{AppResult, bad_request, not_found};
5+
use axum::extract::Path;
6+
use crates_io_database::models::OwnerKind;
7+
use crates_io_database::models::trustpub::GitHubConfig;
8+
use crates_io_database::schema::{crate_owners, crates, emails, trustpub_configs_github, users};
9+
use diesel::prelude::*;
10+
use diesel_async::RunQueryDsl;
11+
use http::StatusCode;
12+
use http::request::Parts;
13+
14+
#[cfg(test)]
15+
mod tests;
16+
17+
/// Delete Trusted Publishing configuration for GitHub Actions.
18+
#[utoipa::path(
19+
delete,
20+
path = "/api/v1/trusted_publishing/github_configs/{id}",
21+
params(
22+
("id" = i32, Path, description = "ID of the Trusted Publishing configuration"),
23+
),
24+
security(("cookie" = [])),
25+
tag = "trusted_publishing",
26+
responses((status = 204, description = "Successful Response")),
27+
)]
28+
pub async fn delete_trustpub_github_config(
29+
state: AppState,
30+
Path(id): Path<i32>,
31+
parts: Parts,
32+
) -> AppResult<StatusCode> {
33+
let mut conn = state.db_write().await?;
34+
35+
let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?;
36+
let auth_user = auth.user();
37+
38+
// Check that a trusted publishing config with the given ID exists,
39+
// and fetch the corresponding crate ID and name.
40+
let (config, crate_name) = trustpub_configs_github::table
41+
.inner_join(crates::table)
42+
.filter(trustpub_configs_github::id.eq(id))
43+
.select((GitHubConfig::as_select(), crates::name))
44+
.first::<(GitHubConfig, String)>(&mut conn)
45+
.await
46+
.optional()?
47+
.ok_or_else(not_found)?;
48+
49+
// Load all crate owners for the given crate ID
50+
let user_owners = crate_owners::table
51+
.filter(crate_owners::crate_id.eq(config.crate_id))
52+
.filter(crate_owners::deleted.eq(false))
53+
.filter(crate_owners::owner_kind.eq(OwnerKind::User))
54+
.inner_join(users::table)
55+
.inner_join(emails::table.on(users::id.eq(emails::user_id)))
56+
.select((users::id, users::gh_login, emails::email, emails::verified))
57+
.load::<(i32, String, String, bool)>(&mut conn)
58+
.await?;
59+
60+
// Check if the authenticated user is an owner of the crate
61+
if !user_owners.iter().any(|owner| owner.0 == auth_user.id) {
62+
return Err(bad_request("You are not an owner of this crate"));
63+
}
64+
65+
// Delete the configuration from the database
66+
diesel::delete(trustpub_configs_github::table.filter(trustpub_configs_github::id.eq(id)))
67+
.execute(&mut conn)
68+
.await?;
69+
70+
// Send notification emails to crate owners
71+
72+
let recipients = user_owners
73+
.into_iter()
74+
.filter(|(_, _, _, verified)| *verified)
75+
.map(|(_, login, email, _)| (login, email))
76+
.collect::<Vec<_>>();
77+
78+
for (recipient, email_address) in &recipients {
79+
let email = ConfigDeletedEmail {
80+
recipient,
81+
user: &auth_user.gh_login,
82+
krate: &crate_name,
83+
repository_owner: &config.repository_owner,
84+
repository_name: &config.repository_name,
85+
workflow_filename: &config.workflow_filename,
86+
environment: config.environment.as_deref().unwrap_or("(not set)"),
87+
};
88+
89+
if let Err(err) = state.emails.send(email_address, email).await {
90+
warn!("Failed to send trusted publishing notification to {email_address}: {err}")
91+
}
92+
}
93+
94+
Ok(StatusCode::NO_CONTENT)
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/delete/tests.rs
3+
expression: app.emails_snapshot().await
4+
---
5+
To: foo@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Trusted Publishing configration removed from foo
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
Hello foo!
12+
13+
crates.io user foo has remove a "Trusted Publishing" configuration for GitH=
14+
ub Actions from a crate that you manage (foo).
15+
16+
Trusted Publishing configuration:
17+
18+
- Repository owner: rust-lang
19+
- Repository name: foo-rs
20+
- Workflow filename: publish.yml
21+
- Environment: (not set)
22+
23+
If you did not make this change and you think it was made maliciously, you =
24+
can email help@crates.io to communicate with the crates.io support team.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use crate::tests::builders::CrateBuilder;
2+
use crate::tests::util::{RequestHelper, TestApp};
3+
use crates_io_database::models::Crate;
4+
use crates_io_database::models::trustpub::{GitHubConfig, NewGitHubConfig};
5+
use crates_io_database::schema::trustpub_configs_github;
6+
use diesel::prelude::*;
7+
use diesel_async::{AsyncPgConnection, RunQueryDsl};
8+
use http::StatusCode;
9+
use insta::assert_snapshot;
10+
use serde_json::json;
11+
12+
const BASE_URL: &str = "/api/v1/trusted_publishing/github_configs";
13+
const CRATE_NAME: &str = "foo";
14+
15+
fn delete_url(id: i32) -> String {
16+
format!("{BASE_URL}/{id}")
17+
}
18+
19+
async fn create_crate(conn: &mut AsyncPgConnection, author_id: i32) -> anyhow::Result<Crate> {
20+
CrateBuilder::new(CRATE_NAME, author_id).build(conn).await
21+
}
22+
23+
async fn create_config(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult<GitHubConfig> {
24+
let config = NewGitHubConfig {
25+
crate_id,
26+
repository_owner: "rust-lang",
27+
repository_owner_id: 42,
28+
repository_name: "foo-rs",
29+
workflow_filename: "publish.yml",
30+
environment: None,
31+
};
32+
33+
config.insert(conn).await
34+
}
35+
36+
async fn get_all_configs(conn: &mut AsyncPgConnection) -> QueryResult<Vec<GitHubConfig>> {
37+
trustpub_configs_github::table
38+
.select(GitHubConfig::as_select())
39+
.load::<GitHubConfig>(conn)
40+
.await
41+
}
42+
43+
/// Delete the config with a valid user that is an owner of the crate.
44+
#[tokio::test(flavor = "multi_thread")]
45+
async fn test_happy_path() -> anyhow::Result<()> {
46+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
47+
let mut conn = app.db_conn().await;
48+
49+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
50+
let config = create_config(&mut conn, krate.id).await?;
51+
52+
let response = cookie_client.delete::<()>(&delete_url(config.id)).await;
53+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
54+
assert_eq!(response.text(), "");
55+
56+
// Verify the config was deleted from the database
57+
let configs = get_all_configs(&mut conn).await?;
58+
assert_eq!(configs.len(), 0);
59+
60+
// Verify emails were sent to crate owners
61+
assert_snapshot!(app.emails_snapshot().await);
62+
63+
Ok(())
64+
}
65+
66+
/// Try to delete the config with an unauthenticated client.
67+
#[tokio::test(flavor = "multi_thread")]
68+
async fn test_unauthenticated() -> anyhow::Result<()> {
69+
let (app, client, cookie_client) = TestApp::full().with_user().await;
70+
let mut conn = app.db_conn().await;
71+
72+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
73+
let config = create_config(&mut conn, krate.id).await?;
74+
75+
let response = client.delete::<()>(&delete_url(config.id)).await;
76+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
77+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
78+
79+
// Verify the config was not deleted
80+
let configs = get_all_configs(&mut conn).await?;
81+
assert_eq!(configs.len(), 1);
82+
83+
// Verify no emails were sent to crate owners
84+
assert_eq!(app.emails().await.len(), 0);
85+
86+
Ok(())
87+
}
88+
89+
/// Try to delete the config with API token authentication.
90+
#[tokio::test(flavor = "multi_thread")]
91+
async fn test_token_auth() -> anyhow::Result<()> {
92+
let (app, _client, cookie_client, token_client) = TestApp::full().with_token().await;
93+
let mut conn = app.db_conn().await;
94+
95+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
96+
let config = create_config(&mut conn, krate.id).await?;
97+
98+
let response = token_client.delete::<()>(&delete_url(config.id)).await;
99+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
100+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#);
101+
102+
// Verify the config was not deleted
103+
let configs = get_all_configs(&mut conn).await?;
104+
assert_eq!(configs.len(), 1);
105+
106+
// Verify no emails were sent to crate owners
107+
assert_eq!(app.emails().await.len(), 0);
108+
109+
Ok(())
110+
}
111+
112+
/// Try to delete a config that does not exist.
113+
#[tokio::test(flavor = "multi_thread")]
114+
async fn test_config_not_found() -> anyhow::Result<()> {
115+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
116+
117+
let response = cookie_client.delete::<()>(&delete_url(42)).await;
118+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
119+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#);
120+
121+
// Verify no emails were sent to crate owners
122+
assert_eq!(app.emails().await.len(), 0);
123+
124+
Ok(())
125+
}
126+
127+
/// Try to delete the config with a user who is not an owner of the crate.
128+
#[tokio::test(flavor = "multi_thread")]
129+
async fn test_non_owner() -> anyhow::Result<()> {
130+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
131+
let mut conn = app.db_conn().await;
132+
133+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
134+
let config = create_config(&mut conn, krate.id).await?;
135+
136+
// Create another user who is not an owner of the crate
137+
let other_client = app.db_new_user("other_user").await;
138+
139+
let response = other_client.delete::<()>(&delete_url(config.id)).await;
140+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
141+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#);
142+
143+
// Verify the config was not deleted
144+
let configs = get_all_configs(&mut conn).await?;
145+
assert_eq!(configs.len(), 1);
146+
147+
// Verify no emails were sent to crate owners
148+
assert_eq!(app.emails().await.len(), 0);
149+
150+
Ok(())
151+
}
152+
153+
/// Try to delete the config with a user that is part of a team that owns
154+
/// the crate.
155+
#[tokio::test(flavor = "multi_thread")]
156+
async fn test_team_owner() -> anyhow::Result<()> {
157+
let (app, _client) = TestApp::full().empty().await;
158+
let mut conn = app.db_conn().await;
159+
160+
let user = app.db_new_user("user-org-owner").await;
161+
let user2 = app.db_new_user("user-one-team").await;
162+
163+
let krate = create_crate(&mut conn, user.as_model().id).await?;
164+
let config = create_config(&mut conn, krate.id).await?;
165+
166+
let body = json!({ "owners": ["github:test-org:all"] }).to_string();
167+
let response = user.put::<()>("/api/v1/crates/foo/owners", body).await;
168+
assert_eq!(response.status(), StatusCode::OK);
169+
170+
let response = user2.delete::<()>(&delete_url(config.id)).await;
171+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
172+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#);
173+
174+
// Verify the config was not deleted
175+
let configs = get_all_configs(&mut conn).await?;
176+
assert_eq!(configs.len(), 1);
177+
178+
// Verify no emails were sent to crate owners
179+
assert_eq!(app.emails().await.len(), 0);
180+
181+
Ok(())
182+
}

src/controllers/trustpub/github_configs/emails.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,50 @@ If you are unable to revert the change and need to do so, you can email help@cra
4848
)
4949
}
5050
}
51+
52+
/// Email template for notifying crate owners about a Trusted Publishing
53+
/// configuration being deleted.
54+
#[derive(Debug, Clone)]
55+
pub struct ConfigDeletedEmail<'a> {
56+
pub recipient: &'a str,
57+
pub user: &'a str,
58+
pub krate: &'a str,
59+
pub repository_owner: &'a str,
60+
pub repository_name: &'a str,
61+
pub workflow_filename: &'a str,
62+
pub environment: &'a str,
63+
}
64+
65+
impl Email for ConfigDeletedEmail<'_> {
66+
fn subject(&self) -> String {
67+
let Self { krate, .. } = self;
68+
format!("crates.io: Trusted Publishing configration removed from {krate}")
69+
}
70+
71+
fn body(&self) -> String {
72+
let Self {
73+
recipient,
74+
user,
75+
krate,
76+
repository_owner,
77+
repository_name,
78+
workflow_filename,
79+
environment,
80+
} = self;
81+
82+
format!(
83+
"Hello {recipient}!
84+
85+
crates.io user {user} has remove a \"Trusted Publishing\" configuration for GitHub Actions from a crate that you manage ({krate}).
86+
87+
Trusted Publishing configuration:
88+
89+
- Repository owner: {repository_owner}
90+
- Repository name: {repository_name}
91+
- Workflow filename: {workflow_filename}
92+
- Environment: {environment}
93+
94+
If you did not make this change and you think it was made maliciously, you can email [email protected] to communicate with the crates.io support team."
95+
)
96+
}
97+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod create;
2+
pub mod delete;
23
pub mod emails;
34
pub mod json;

src/router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
9191
// OIDC / Trusted Publishing
9292
.routes(routes!(
9393
trustpub::github_configs::create::create_trustpub_github_config,
94+
trustpub::github_configs::delete::delete_trustpub_github_config,
9495
))
9596
.split_for_parts();
9697

0 commit comments

Comments
 (0)