Skip to content

Commit 46cdfc0

Browse files
committed
Add DELETE /api/v1/trusted_publishing/github_configs/{id} API endpoint
1 parent f0c7895 commit 46cdfc0

File tree

7 files changed

+349
-0
lines changed

7 files changed

+349
-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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
11+
const BASE_URL: &str = "/api/v1/trusted_publishing/github_configs";
12+
const CRATE_NAME: &str = "foo";
13+
14+
fn delete_url(id: i32) -> String {
15+
format!("{BASE_URL}/{id}")
16+
}
17+
18+
async fn create_crate(conn: &mut AsyncPgConnection, author_id: i32) -> anyhow::Result<Crate> {
19+
CrateBuilder::new(CRATE_NAME, author_id).build(conn).await
20+
}
21+
22+
async fn create_config(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult<GitHubConfig> {
23+
let config = NewGitHubConfig {
24+
crate_id,
25+
repository_owner: "rust-lang",
26+
repository_owner_id: 42,
27+
repository_name: "foo-rs",
28+
workflow_filename: "publish.yml",
29+
environment: None,
30+
};
31+
32+
config.insert(conn).await
33+
}
34+
35+
async fn get_all_configs(conn: &mut AsyncPgConnection) -> QueryResult<Vec<GitHubConfig>> {
36+
trustpub_configs_github::table
37+
.select(GitHubConfig::as_select())
38+
.load::<GitHubConfig>(conn)
39+
.await
40+
}
41+
42+
/// Delete the config with a valid user that is an owner of the crate.
43+
#[tokio::test(flavor = "multi_thread")]
44+
async fn test_happy_path() -> anyhow::Result<()> {
45+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
46+
let mut conn = app.db_conn().await;
47+
48+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
49+
let config = create_config(&mut conn, krate.id).await?;
50+
51+
let response = cookie_client.delete::<()>(&delete_url(config.id)).await;
52+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
53+
assert_eq!(response.text(), "");
54+
55+
// Verify the config was deleted from the database
56+
let configs = get_all_configs(&mut conn).await?;
57+
assert_eq!(configs.len(), 0);
58+
59+
// Verify emails were sent to crate owners
60+
assert_snapshot!(app.emails_snapshot().await);
61+
62+
Ok(())
63+
}
64+
65+
/// Try to delete the config with an unauthenticated client.
66+
#[tokio::test(flavor = "multi_thread")]
67+
async fn test_unauthenticated() -> anyhow::Result<()> {
68+
let (app, client, cookie_client) = TestApp::full().with_user().await;
69+
let mut conn = app.db_conn().await;
70+
71+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
72+
let config = create_config(&mut conn, krate.id).await?;
73+
74+
let response = client.delete::<()>(&delete_url(config.id)).await;
75+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
76+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
77+
78+
// Verify the config was not deleted
79+
let configs = get_all_configs(&mut conn).await?;
80+
assert_eq!(configs.len(), 1);
81+
82+
// Verify no emails were sent to crate owners
83+
assert_eq!(app.emails().await.len(), 0);
84+
85+
Ok(())
86+
}
87+
88+
/// Try to delete the config with API token authentication.
89+
#[tokio::test(flavor = "multi_thread")]
90+
async fn test_token_auth() -> anyhow::Result<()> {
91+
let (app, _client, cookie_client, token_client) = TestApp::full().with_token().await;
92+
let mut conn = app.db_conn().await;
93+
94+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
95+
let config = create_config(&mut conn, krate.id).await?;
96+
97+
let response = token_client.delete::<()>(&delete_url(config.id)).await;
98+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
99+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#);
100+
101+
// Verify the config was not deleted
102+
let configs = get_all_configs(&mut conn).await?;
103+
assert_eq!(configs.len(), 1);
104+
105+
// Verify no emails were sent to crate owners
106+
assert_eq!(app.emails().await.len(), 0);
107+
108+
Ok(())
109+
}
110+
111+
/// Try to delete a config that does not exist.
112+
#[tokio::test(flavor = "multi_thread")]
113+
async fn test_config_not_found() -> anyhow::Result<()> {
114+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
115+
116+
let response = cookie_client.delete::<()>(&delete_url(42)).await;
117+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
118+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#);
119+
120+
// Verify no emails were sent to crate owners
121+
assert_eq!(app.emails().await.len(), 0);
122+
123+
Ok(())
124+
}
125+
126+
/// Try to delete the config with a user who is not an owner of the crate.
127+
#[tokio::test(flavor = "multi_thread")]
128+
async fn test_non_owner() -> anyhow::Result<()> {
129+
let (app, _client, cookie_client) = TestApp::full().with_user().await;
130+
let mut conn = app.db_conn().await;
131+
132+
let krate = create_crate(&mut conn, cookie_client.as_model().id).await?;
133+
let config = create_config(&mut conn, krate.id).await?;
134+
135+
// Create another user who is not an owner of the crate
136+
let other_client = app.db_new_user("other_user").await;
137+
138+
let response = other_client.delete::<()>(&delete_url(config.id)).await;
139+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
140+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"You are not an owner of this crate"}]}"#);
141+
142+
// Verify the config was not deleted
143+
let configs = get_all_configs(&mut conn).await?;
144+
assert_eq!(configs.len(), 1);
145+
146+
// Verify no emails were sent to crate owners
147+
assert_eq!(app.emails().await.len(), 0);
148+
149+
Ok(())
150+
}

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 help@crates.io 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

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4224,6 +4224,37 @@ expression: response.json()
42244224
]
42254225
}
42264226
},
4227+
"/api/v1/trusted_publishing/github_configs/{id}": {
4228+
"delete": {
4229+
"operationId": "delete_trustpub_github_config",
4230+
"parameters": [
4231+
{
4232+
"description": "ID of the Trusted Publishing configuration",
4233+
"in": "path",
4234+
"name": "id",
4235+
"required": true,
4236+
"schema": {
4237+
"format": "int32",
4238+
"type": "integer"
4239+
}
4240+
}
4241+
],
4242+
"responses": {
4243+
"204": {
4244+
"description": "Successful Response"
4245+
}
4246+
},
4247+
"security": [
4248+
{
4249+
"cookie": []
4250+
}
4251+
],
4252+
"summary": "Delete Trusted Publishing configuration for GitHub Actions.",
4253+
"tags": [
4254+
"trusted_publishing"
4255+
]
4256+
}
4257+
},
42274258
"/api/v1/users/{id}/resend": {
42284259
"put": {
42294260
"operationId": "resend_email_verification",

0 commit comments

Comments
 (0)