Skip to content

Commit f6a812f

Browse files
committed
Add PATCH /api/v1/crates/{name} endpoint for updating the trustpub_only flag
1 parent c12a5c6 commit f6a812f

15 files changed

+732
-0
lines changed

crates/crates_io_test_utils/src/builders/krate.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub struct CrateBuilder<'a> {
1616
krate: NewCrate<'a>,
1717
owner_id: i32,
1818
recent_downloads: Option<i32>,
19+
trustpub_only: bool,
1920
updated_at: Option<DateTime<Utc>>,
2021
versions: Vec<VersionBuilder>,
2122
}
@@ -34,6 +35,7 @@ impl<'a> CrateBuilder<'a> {
3435
},
3536
owner_id,
3637
recent_downloads: None,
38+
trustpub_only: false,
3739
updated_at: None,
3840
versions: Vec::new(),
3941
}
@@ -113,6 +115,12 @@ impl<'a> CrateBuilder<'a> {
113115
self
114116
}
115117

118+
/// Sets the crate's `trustpub_only` flag.
119+
pub fn trustpub_only(mut self, trustpub_only: bool) -> Self {
120+
self.trustpub_only = trustpub_only;
121+
self
122+
}
123+
116124
pub async fn build(mut self, connection: &mut AsyncPgConnection) -> anyhow::Result<Crate> {
117125
use diesel::{insert_into, select, update};
118126

@@ -171,6 +179,14 @@ impl<'a> CrateBuilder<'a> {
171179
.await?;
172180
}
173181

182+
if self.trustpub_only {
183+
krate = update(&krate)
184+
.set(crates::trustpub_only.eq(true))
185+
.returning(Crate::as_returning())
186+
.get_result(connection)
187+
.await?;
188+
}
189+
174190
update_default_version(krate.id, connection).await?;
175191

176192
Ok(krate)

src/controllers/krate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod owners;
1515
pub mod publish;
1616
pub mod rev_deps;
1717
pub mod search;
18+
pub mod update;
1819
pub mod versions;
1920

2021
#[derive(Deserialize, FromRequestParts, IntoParams)]

src/controllers/krate/update.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use crate::app::AppState;
2+
use crate::auth::AuthCheck;
3+
use crate::controllers::krate::CratePath;
4+
use crate::email::EmailMessage;
5+
use crate::middleware::real_ip::RealIp;
6+
use crate::models::{Crate, User};
7+
use crate::schema::*;
8+
use crate::util::errors::{AppResult, crate_not_found, custom};
9+
use crate::views::EncodableCrate;
10+
use anyhow::Context;
11+
use axum::{Extension, Json};
12+
use diesel::prelude::*;
13+
use diesel_async::scoped_futures::ScopedFutureExt;
14+
use diesel_async::{AsyncConnection, RunQueryDsl};
15+
use http::{StatusCode, request::Parts};
16+
use serde::{Deserialize, Serialize};
17+
use tracing::{info, warn};
18+
19+
#[derive(Debug, Deserialize, utoipa::ToSchema)]
20+
pub struct PatchRequest {
21+
/// Whether this crate can only be published via Trusted Publishing.
22+
#[serde(default, skip_serializing_if = "Option::is_none")]
23+
pub trustpub_only: Option<bool>,
24+
}
25+
26+
#[derive(Debug, Serialize, utoipa::ToSchema)]
27+
pub struct PatchResponse {
28+
/// The updated crate metadata.
29+
#[serde(rename = "crate")]
30+
krate: EncodableCrate,
31+
}
32+
33+
/// Update crate settings.
34+
#[utoipa::path(
35+
patch,
36+
path = "/api/v1/crates/{name}",
37+
params(CratePath),
38+
request_body = PatchRequest,
39+
security(
40+
("api_token" = []),
41+
("cookie" = []),
42+
),
43+
tag = "crates",
44+
responses((status = 200, description = "Successful Response", body = inline(PatchResponse))),
45+
)]
46+
pub async fn update_crate(
47+
app: AppState,
48+
path: CratePath,
49+
req: Parts,
50+
Extension(real_ip): Extension<RealIp>,
51+
Json(body): Json<PatchRequest>,
52+
) -> AppResult<Json<PatchResponse>> {
53+
let mut conn = app.db_write().await?;
54+
55+
// Check that the user is authenticated
56+
let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?;
57+
58+
// Check that the crate exists
59+
let krate = path.load_crate(&mut conn).await?;
60+
61+
// Update crate settings in a transaction
62+
conn.transaction(|conn| {
63+
update_inner(conn, &app, &krate, auth.user(), &real_ip, body).scope_boxed()
64+
})
65+
.await
66+
}
67+
68+
async fn update_inner(
69+
conn: &mut diesel_async::AsyncPgConnection,
70+
app: &AppState,
71+
krate: &Crate,
72+
user: &User,
73+
real_ip: &RealIp,
74+
body: PatchRequest,
75+
) -> AppResult<Json<PatchResponse>> {
76+
// Query user owners to check permissions and send emails
77+
let user_owners = crate_owners::table
78+
.inner_join(users::table)
79+
.inner_join(emails::table.on(users::id.eq(emails::user_id)))
80+
.filter(crate_owners::crate_id.eq(krate.id))
81+
.filter(crate_owners::deleted.eq(false))
82+
.filter(crate_owners::owner_kind.eq(crate::models::OwnerKind::User))
83+
.select((users::id, users::gh_login, emails::email, emails::verified))
84+
.load::<(i32, String, String, bool)>(conn)
85+
.await?;
86+
87+
// Check that the authenticated user is an owner
88+
if !user_owners.iter().any(|(id, _, _, _)| *id == user.id) {
89+
let msg = "only owners have permission to modify crate settings";
90+
return Err(custom(StatusCode::FORBIDDEN, msg));
91+
}
92+
93+
// Update trustpub_only if provided
94+
if let Some(trustpub_only) = body.trustpub_only {
95+
diesel::update(crates::table)
96+
.filter(crates::id.eq(krate.id))
97+
.set(crates::trustpub_only.eq(trustpub_only))
98+
.execute(conn)
99+
.await?;
100+
101+
// Audit log the setting change
102+
info!(
103+
target: "audit",
104+
action = "trustpub_only_change",
105+
krate.name = %krate.name,
106+
network.client.ip = %**real_ip,
107+
usr.id = user.id,
108+
usr.name = %user.gh_login,
109+
"User {} set trustpub_only={trustpub_only} for crate {}",
110+
user.gh_login,
111+
krate.name
112+
);
113+
114+
// Send email notifications to all crate owners
115+
for (_, gh_login, email_address, email_verified) in &user_owners {
116+
if *email_verified {
117+
let email = TrustpubOnlyChangedEmail {
118+
recipient: gh_login,
119+
auth_user: user,
120+
krate,
121+
trustpub_only,
122+
};
123+
124+
if let Err(err) = email.send(app, email_address).await {
125+
warn!("Failed to send trustpub_only notification to {email_address}: {err}");
126+
}
127+
}
128+
}
129+
}
130+
131+
// Reload the crate to get updated data
132+
let (krate, downloads, recent_downloads, default_version, yanked, num_versions): (
133+
Crate,
134+
i64,
135+
Option<i64>,
136+
Option<String>,
137+
Option<bool>,
138+
Option<i32>,
139+
) = Crate::by_name(&krate.name)
140+
.inner_join(crate_downloads::table)
141+
.left_join(recent_crate_downloads::table)
142+
.left_join(default_versions::table)
143+
.left_join(versions::table.on(default_versions::version_id.eq(versions::id)))
144+
.select((
145+
Crate::as_select(),
146+
crate_downloads::downloads,
147+
recent_crate_downloads::downloads.nullable(),
148+
versions::num.nullable(),
149+
versions::yanked.nullable(),
150+
default_versions::num_versions.nullable(),
151+
))
152+
.first(conn)
153+
.await
154+
.optional()?
155+
.ok_or_else(|| crate_not_found(&krate.name))?;
156+
157+
let encodable_crate = EncodableCrate::from(
158+
krate,
159+
default_version.as_deref(),
160+
num_versions.unwrap_or_default(),
161+
yanked,
162+
None,
163+
None,
164+
None,
165+
None,
166+
false,
167+
downloads,
168+
recent_downloads,
169+
);
170+
171+
Ok(Json(PatchResponse {
172+
krate: encodable_crate,
173+
}))
174+
}
175+
176+
#[derive(Serialize)]
177+
struct TrustpubOnlyChangedEmail<'a> {
178+
/// The GitHub login of the email recipient.
179+
recipient: &'a str,
180+
/// The user who changed the setting.
181+
auth_user: &'a User,
182+
/// The crate for which the setting was changed.
183+
krate: &'a Crate,
184+
/// The new value of the trustpub_only flag.
185+
trustpub_only: bool,
186+
}
187+
188+
impl TrustpubOnlyChangedEmail<'_> {
189+
async fn send(&self, state: &AppState, email_address: &str) -> anyhow::Result<()> {
190+
let email = EmailMessage::from_template("trustpub_only_changed", self);
191+
let email = email.context("Failed to render email template")?;
192+
193+
state
194+
.emails
195+
.send(email_address, email)
196+
.await
197+
.context("Failed to send email")
198+
}
199+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{% extends "base.txt.j2" %}
2+
3+
{% block content %}
4+
Hello {{ recipient }}!
5+
6+
{% if recipient == auth_user.gh_login -%}
7+
You changed the publishing method restriction for your crate "{{ krate.name }}".
8+
{%- else -%}
9+
crates.io user {{ auth_user.gh_login }} changed the publishing method restriction for a crate that you manage ("{{ krate.name }}").
10+
{%- endif %}
11+
12+
{% if trustpub_only -%}
13+
This crate can now ONLY be published via Trusted Publishing. Publishing with API tokens has been disabled.
14+
15+
This means that only trusted publishers (like GitHub Actions or GitLab CI workflows) that you have configured will be able to publish new versions of this crate. API tokens will no longer work for publishing.
16+
{%- else -%}
17+
This crate can now be published via both Trusted Publishing and API tokens.
18+
19+
This means that both trusted publishers (like GitHub Actions or GitLab CI workflows) and users with API tokens will be able to publish new versions of this crate.
20+
{%- endif %}
21+
22+
If you did not make this change and you think it was made maliciously, you can revert the setting from the "Settings" tab on the crate's page.
23+
24+
If you are unable to revert the change and need to do so, you can email [email protected] for assistance.
25+
{% endblock %}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
crates.io: Publishing method restriction changed for {{ krate.name }}

src/router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
3131
// Routes used by the frontend
3232
.routes(routes!(
3333
krate::metadata::find_crate,
34+
krate::update::update_crate,
3435
krate::delete::delete_crate
3536
))
3637
.routes(routes!(

src/tests/routes/crates/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ mod new;
77
pub mod owners;
88
mod read;
99
mod reverse_dependencies;
10+
mod update;
1011
pub mod versions;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
source: src/tests/routes/crates/update.rs
3+
expression: response.json()
4+
---
5+
{
6+
"crate": {
7+
"badges": [],
8+
"categories": null,
9+
"created_at": "[datetime]",
10+
"default_version": "0.99.0",
11+
"description": null,
12+
"documentation": null,
13+
"downloads": 0,
14+
"exact_match": false,
15+
"homepage": null,
16+
"id": "foo",
17+
"keywords": null,
18+
"links": {
19+
"owner_team": "/api/v1/crates/foo/owner_team",
20+
"owner_user": "/api/v1/crates/foo/owner_user",
21+
"owners": "/api/v1/crates/foo/owners",
22+
"reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies",
23+
"version_downloads": "/api/v1/crates/foo/downloads",
24+
"versions": "/api/v1/crates/foo/versions"
25+
},
26+
"max_stable_version": null,
27+
"max_version": "0.0.0",
28+
"name": "foo",
29+
"newest_version": "0.0.0",
30+
"num_versions": 1,
31+
"recent_downloads": null,
32+
"repository": null,
33+
"trustpub_only": false,
34+
"updated_at": "[datetime]",
35+
"versions": null,
36+
"yanked": false
37+
}
38+
}

0 commit comments

Comments
 (0)