Skip to content

Commit 2d84763

Browse files
committed
Implement PUT /api/v1/trusted_publishing/github_configs API endpoint
1 parent acf84ff commit 2d84763

12 files changed

+804
-0
lines changed

src/controllers.rs

+1
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ pub mod site_metadata;
1313
pub mod summary;
1414
pub mod team;
1515
pub mod token;
16+
pub mod trustpub;
1617
pub mod user;
1718
pub mod version;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use crate::app::AppState;
2+
use crate::auth::AuthCheck;
3+
use crate::controllers::krate::load_crate;
4+
use crate::controllers::trustpub::github_configs::emails::ConfigCreatedEmail;
5+
use crate::controllers::trustpub::github_configs::json;
6+
use crate::util::errors::{AppResult, bad_request};
7+
use axum::Json;
8+
use crates_io_database::models::OwnerKind;
9+
use crates_io_database::models::trusted_publishing::NewGitHubConfig;
10+
use crates_io_database::schema::{crate_owners, emails, users};
11+
use crates_io_github::GitHubError;
12+
use crates_io_trustpub::github::validation::{
13+
validate_environment, validate_owner, validate_repo, validate_workflow_filename,
14+
};
15+
use diesel::prelude::*;
16+
use diesel_async::RunQueryDsl;
17+
use http::request::Parts;
18+
use oauth2::AccessToken;
19+
use secrecy::ExposeSecret;
20+
21+
#[cfg(test)]
22+
mod tests;
23+
24+
/// Create a new Trusted Publishing configuration for GitHub Actions.
25+
#[utoipa::path(
26+
put,
27+
path = "/api/v1/trusted_publishing/github_configs",
28+
security(("cookie" = [])),
29+
request_body = inline(json::CreateRequest),
30+
tag = "trusted_publishing",
31+
responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))),
32+
)]
33+
pub async fn trustpub_create_github_config(
34+
state: AppState,
35+
parts: Parts,
36+
Json(json): Json<json::CreateRequest>,
37+
) -> AppResult<Json<json::CreateResponse>> {
38+
let json_config = json.github_config;
39+
40+
validate_owner(&json_config.repository_owner)?;
41+
validate_repo(&json_config.repository_name)?;
42+
validate_workflow_filename(&json_config.workflow_filename)?;
43+
if let Some(env) = &json_config.environment {
44+
validate_environment(env)?;
45+
}
46+
47+
let mut conn = state.db_write().await?;
48+
49+
let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?;
50+
let auth_user = auth.user();
51+
52+
let krate = load_crate(&mut conn, &json_config.krate).await?;
53+
54+
let user_owners = crate_owners::table
55+
.filter(crate_owners::crate_id.eq(krate.id))
56+
.filter(crate_owners::deleted.eq(false))
57+
.filter(crate_owners::owner_kind.eq(OwnerKind::User))
58+
.inner_join(users::table)
59+
.inner_join(emails::table.on(users::id.eq(emails::user_id)))
60+
.select((users::id, users::gh_login, emails::email, emails::verified))
61+
.load::<(i32, String, String, bool)>(&mut conn)
62+
.await?;
63+
64+
if !user_owners.iter().any(|owner| owner.0 == auth_user.id) {
65+
return Err(bad_request("You are not an owner of this crate"));
66+
}
67+
68+
// Lookup `repository_owner_id` via GitHub API
69+
70+
let owner = &json_config.repository_owner;
71+
let gh_auth = &auth_user.gh_access_token;
72+
let gh_auth = AccessToken::new(gh_auth.expose_secret().to_string());
73+
let github_user = match state.github.get_user(owner, &gh_auth).await {
74+
Ok(user) => user,
75+
Err(GitHubError::NotFound(_)) => Err(bad_request("Unknown GitHub user or organization"))?,
76+
Err(err) => Err(err)?,
77+
};
78+
79+
// Save the new GitHub OIDC config to the database
80+
81+
let new_config = NewGitHubConfig {
82+
crate_id: krate.id,
83+
// Use the normalized owner name as provided by GitHub.
84+
repository_owner: &github_user.login,
85+
repository_owner_id: github_user.id,
86+
repository_name: &json_config.repository_name,
87+
workflow_filename: &json_config.workflow_filename,
88+
environment: json_config.environment.as_deref(),
89+
};
90+
91+
let saved_config = new_config.insert(&mut conn).await?;
92+
93+
// Send notification emails to crate owners
94+
95+
let recipients = user_owners
96+
.into_iter()
97+
.filter(|(_, _, _, verified)| *verified)
98+
.map(|(_, login, email, _)| (login, email))
99+
.collect::<Vec<_>>();
100+
101+
for (recipient, email_address) in &recipients {
102+
let email = ConfigCreatedEmail {
103+
recipient,
104+
user: &auth_user.gh_login,
105+
krate: &krate.name,
106+
repository_owner: &saved_config.repository_owner,
107+
repository_name: &saved_config.repository_name,
108+
workflow_filename: &saved_config.workflow_filename,
109+
environment: saved_config.environment.as_deref().unwrap_or("(not set)"),
110+
};
111+
112+
if let Err(err) = state.emails.send(email_address, email).await {
113+
warn!("Failed to send trusted publishing notification to {email_address}: {err}")
114+
}
115+
}
116+
117+
let github_config = json::GitHubConfig {
118+
id: saved_config.id,
119+
krate: krate.name,
120+
repository_owner: saved_config.repository_owner,
121+
repository_owner_id: saved_config.repository_owner_id,
122+
repository_name: saved_config.repository_name,
123+
workflow_filename: saved_config.workflow_filename,
124+
environment: saved_config.environment,
125+
created_at: saved_config.created_at,
126+
};
127+
128+
Ok(Json(json::CreateResponse { github_config }))
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/create/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 added to 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 added a new "Trusted Publishing" configuration for G=
14+
itHub Actions to a crate that you manage (foo). Trusted publishers act as t=
15+
rusted users and can publish new versions of the crate automatically.
16+
17+
Trusted Publishing configuration:
18+
19+
- Repository owner: rust-lang
20+
- Repository name: foo-rs
21+
- Workflow filename: publish.yml
22+
- Environment: (not set)
23+
24+
If you did not make this change and you think it was made maliciously, you =
25+
can remove the configuration from the crate via the "Settings" tab on the c=
26+
rate's page.
27+
28+
If you are unable to revert the change and need to do so, you can email hel=
29+
p@crates.io to communicate with the crates.io support team.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/create/tests.rs
3+
expression: response.json()
4+
---
5+
{
6+
"github_config": {
7+
"crate": "foo",
8+
"created_at": "[datetime]",
9+
"environment": null,
10+
"id": 1,
11+
"repository_name": "foo-rs",
12+
"repository_owner": "rust-lang",
13+
"repository_owner_id": 42,
14+
"workflow_filename": "publish.yml"
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
source: src/controllers/trustpub/github_configs/create/tests.rs
3+
expression: response.json()
4+
---
5+
{
6+
"github_config": {
7+
"crate": "foo",
8+
"created_at": "[datetime]",
9+
"environment": "production",
10+
"id": 1,
11+
"repository_name": "foo-rs",
12+
"repository_owner": "rust-lang",
13+
"repository_owner_id": 42,
14+
"workflow_filename": "publish.yml"
15+
}
16+
}

0 commit comments

Comments
 (0)