Skip to content

Commit 8d89c7e

Browse files
authored
Merge pull request #11294 from Turbo87/trustpub-publish
controllers/krate/publish: Add support for Trusted Publishing access tokens
2 parents 5d19d83 + a0348f9 commit 8d89c7e

10 files changed

+682
-54
lines changed

crates/crates_io_database/src/models/version.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ pub struct NewVersion<'a> {
9090
license: Option<&'a str>,
9191
#[builder(default, name = "size")]
9292
crate_size: i32,
93-
published_by: i32,
93+
published_by: Option<i32>,
9494
checksum: &'a str,
9595
links: Option<&'a str>,
9696
rust_version: Option<&'a str>,

src/controllers/krate/publish.rs

Lines changed: 132 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Functionality related to publishing a new crate or version of a crate.
22
33
use crate::app::AppState;
4-
use crate::auth::AuthCheck;
4+
use crate::auth::{AuthCheck, Authentication};
55
use crate::worker::jobs::{
66
self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion,
77
};
@@ -11,16 +11,16 @@ use cargo_manifest::{Dependency, DepsSet, TargetDepsSet};
1111
use chrono::{DateTime, SecondsFormat, Utc};
1212
use crates_io_tarball::{TarballError, process_tarball};
1313
use crates_io_worker::{BackgroundJob, EnqueueError};
14-
use diesel::dsl::{exists, select};
14+
use diesel::dsl::{exists, now, select};
1515
use diesel::prelude::*;
1616
use diesel::sql_types::Timestamptz;
1717
use diesel_async::scoped_futures::ScopedFutureExt;
1818
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1919
use futures_util::TryFutureExt;
2020
use futures_util::TryStreamExt;
2121
use hex::ToHex;
22-
use http::StatusCode;
2322
use http::request::Parts;
23+
use http::{StatusCode, header};
2424
use sha2::{Digest, Sha256};
2525
use std::collections::HashMap;
2626
use tokio::io::{AsyncRead, AsyncReadExt};
@@ -38,12 +38,13 @@ use crate::middleware::log_request::RequestLogExt;
3838
use crate::models::token::EndpointScope;
3939
use crate::rate_limiter::LimitedAction;
4040
use crate::schema::*;
41-
use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, internal};
41+
use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, forbidden, internal};
4242
use crate::views::{
4343
EncodableCrate, EncodableCrateDependency, GoodCrate, PublishMetadata, PublishWarnings,
4444
};
45-
use crates_io_database::models::versions_published_by;
45+
use crates_io_database::models::{User, versions_published_by};
4646
use crates_io_diesel_helpers::canon_crate_name;
47+
use crates_io_trustpub::access_token::AccessToken;
4748

4849
const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \
4950
If you believe this is a mistake, perhaps you need \
@@ -52,6 +53,24 @@ const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem
5253

5354
const MAX_DESCRIPTION_LENGTH: usize = 1000;
5455

56+
enum AuthType {
57+
Regular(Box<Authentication>),
58+
TrustPub,
59+
}
60+
61+
impl AuthType {
62+
fn user(&self) -> Option<&User> {
63+
match self {
64+
AuthType::Regular(auth) => Some(auth.user()),
65+
AuthType::TrustPub => None,
66+
}
67+
}
68+
69+
fn user_id(&self) -> Option<i32> {
70+
self.user().map(|u| u.id)
71+
}
72+
}
73+
5574
/// Publish a new crate/version.
5675
///
5776
/// Used by `cargo publish` to publish a new crate or to publish a new version of an
@@ -61,6 +80,7 @@ const MAX_DESCRIPTION_LENGTH: usize = 1000;
6180
path = "/api/v1/crates/new",
6281
security(
6382
("api_token" = []),
83+
("trustpub_token" = []),
6484
("cookie" = []),
6585
),
6686
tag = "publish",
@@ -126,35 +146,79 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
126146
.await
127147
.optional()?;
128148

129-
let endpoint_scope = match existing_crate {
130-
Some(_) => EndpointScope::PublishUpdate,
131-
None => EndpointScope::PublishNew,
132-
};
149+
// Trusted publishing tokens are distinguished from regular crates.io API
150+
// tokens because they use the `Bearer` auth scheme, so we look for that
151+
// specific prefix.
152+
let trustpub_token = req
153+
.headers
154+
.get(header::AUTHORIZATION)
155+
.and_then(|h| {
156+
let mut split = h.as_bytes().splitn(2, |b| *b == b' ');
157+
Some((split.next()?, split.next()?))
158+
})
159+
.filter(|(scheme, _token)| scheme.eq_ignore_ascii_case(b"Bearer"))
160+
.map(|(_scheme, token)| token.trim_ascii())
161+
.map(AccessToken::from_byte_str)
162+
.transpose()
163+
.map_err(|_| forbidden("Invalid authentication token"))?;
164+
165+
let auth = if let Some(trustpub_token) = trustpub_token {
166+
let Some(existing_crate) = &existing_crate else {
167+
let error = forbidden("Trusted Publishing tokens do not support creating new crates");
168+
return Err(error);
169+
};
133170

134-
let auth = AuthCheck::default()
135-
.with_endpoint_scope(endpoint_scope)
136-
.for_crate(&metadata.name)
137-
.check(&req, &mut conn)
138-
.await?;
171+
let hashed_token = trustpub_token.sha256();
139172

140-
let verified_email_address = auth.user().verified_email(&mut conn).await?;
141-
let verified_email_address = verified_email_address.ok_or_else(|| {
142-
bad_request(format!(
143-
"A verified email address is required to publish crates to crates.io. \
144-
Visit https://{}/settings/profile to set and verify your email address.",
145-
app.config.domain_name,
146-
))
147-
})?;
173+
let crate_ids: Vec<Option<i32>> = trustpub_tokens::table
174+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
175+
.filter(trustpub_tokens::expires_at.gt(now))
176+
.select(trustpub_tokens::crate_ids)
177+
.get_result(&mut conn)
178+
.await
179+
.optional()?
180+
.ok_or_else(|| forbidden("Invalid authentication token"))?;
181+
182+
if !crate_ids.contains(&Some(existing_crate.id)) {
183+
let name = &existing_crate.name;
184+
let error = format!("The provided access token is not valid for crate `{name}`");
185+
return Err(forbidden(error));
186+
}
187+
188+
AuthType::TrustPub
189+
} else {
190+
let endpoint_scope = match existing_crate {
191+
Some(_) => EndpointScope::PublishUpdate,
192+
None => EndpointScope::PublishNew,
193+
};
194+
195+
let auth = AuthCheck::default()
196+
.with_endpoint_scope(endpoint_scope)
197+
.for_crate(&metadata.name)
198+
.check(&req, &mut conn)
199+
.await?;
148200

149-
// Use a different rate limit whether this is a new or an existing crate.
150-
let rate_limit_action = match existing_crate {
151-
Some(_) => LimitedAction::PublishUpdate,
152-
None => LimitedAction::PublishNew,
201+
AuthType::Regular(Box::new(auth))
153202
};
154203

155-
app.rate_limiter
156-
.check_rate_limit(auth.user().id, rate_limit_action, &mut conn)
157-
.await?;
204+
let verified_email_address = if let Some(user) = auth.user() {
205+
let verified_email_address = user.verified_email(&mut conn).await?;
206+
Some(verified_email_address.ok_or_else(|| verified_email_error(&app.config.domain_name))?)
207+
} else {
208+
None
209+
};
210+
211+
if let Some(user_id) = auth.user_id() {
212+
// Use a different rate limit whether this is a new or an existing crate.
213+
let rate_limit_action = match existing_crate {
214+
Some(_) => LimitedAction::PublishUpdate,
215+
None => LimitedAction::PublishNew,
216+
};
217+
218+
app.rate_limiter
219+
.check_rate_limit(user_id, rate_limit_action, &mut conn)
220+
.await?;
221+
}
158222

159223
let max_upload_size = existing_crate
160224
.as_ref()
@@ -343,9 +407,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
343407
validate_dependency(dep)?;
344408
}
345409

346-
let api_token_id = auth.api_token_id();
347-
let user = auth.user();
348-
349410
// Create a transaction on the database, if there are no errors,
350411
// commit the transactions to record a new or updated crate.
351412
conn.transaction(|conn| async move {
@@ -369,17 +430,24 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
369430
return Err(bad_request("cannot upload a crate with a reserved name"));
370431
}
371432

372-
// To avoid race conditions, we try to insert
373-
// first so we know whether to add an owner
374-
let krate = match persist.create(conn, user.id).await.optional()? {
375-
Some(krate) => krate,
376-
None => persist.update(conn).await?,
377-
};
433+
let krate = if let Some(user) = auth.user() {
434+
// To avoid race conditions, we try to insert
435+
// first so we know whether to add an owner
436+
let krate = match persist.create(conn, user.id).await.optional()? {
437+
Some(krate) => krate,
438+
None => persist.update(conn).await?,
439+
};
378440

379-
let owners = krate.owners(conn).await?;
380-
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
381-
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
382-
}
441+
let owners = krate.owners(conn).await?;
442+
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
443+
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
444+
}
445+
446+
krate
447+
} else {
448+
// Trusted Publishing does not support creating new crates
449+
persist.update(conn).await?
450+
};
383451

384452
if krate.name != *name {
385453
return Err(bad_request(format_args!(
@@ -418,7 +486,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
418486
// Downcast is okay because the file length must be less than the max upload size
419487
// to get here, and max upload sizes are way less than i32 max
420488
.size(content_length as i32)
421-
.published_by(user.id)
489+
.maybe_published_by(auth.user_id())
422490
.checksum(&hex_cksum)
423491
.maybe_links(package.links.as_deref())
424492
.maybe_rust_version(rust_version.as_deref())
@@ -442,16 +510,20 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
442510
}
443511
})?;
444512

445-
versions_published_by::insert(version.id, &verified_email_address, conn).await?;
513+
if let Some(email_address) = verified_email_address {
514+
versions_published_by::insert(version.id, &email_address, conn).await?;
515+
}
446516

447-
NewVersionOwnerAction::builder()
448-
.version_id(version.id)
449-
.user_id(user.id)
450-
.maybe_api_token_id(api_token_id)
451-
.action(VersionAction::Publish)
452-
.build()
453-
.insert(conn)
454-
.await?;
517+
if let AuthType::Regular(auth) = &auth {
518+
NewVersionOwnerAction::builder()
519+
.version_id(version.id)
520+
.user_id(auth.user().id)
521+
.maybe_api_token_id(auth.api_token_id())
522+
.action(VersionAction::Publish)
523+
.build()
524+
.insert(conn)
525+
.await?;
526+
}
455527

456528
// Link this new version to all dependencies
457529
add_dependencies(conn, &deps, version.id).await?;
@@ -464,7 +536,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
464536
.await
465537
.optional()?;
466538

467-
let num_versions = existing_default_version.as_ref().and_then(|t|t.1).unwrap_or_default();
539+
let num_versions = existing_default_version.as_ref().and_then(|t| t.1).unwrap_or_default();
468540
let mut default_version = None;
469541
// Upsert the `default_value` determined by the existing `default_value` and the
470542
// published version. Note that this could potentially write an outdated version
@@ -728,6 +800,13 @@ fn validate_rust_version(value: &str) -> AppResult<()> {
728800
}
729801
}
730802

803+
fn verified_email_error(domain: &str) -> BoxedAppError {
804+
bad_request(format!(
805+
"A verified email address is required to publish crates to crates.io. \
806+
Visit https://{domain}/settings/profile to set and verify your email address.",
807+
))
808+
}
809+
731810
fn convert_dependencies(
732811
normal_deps: Option<&DepsSet>,
733812
dev_deps: Option<&DepsSet>,

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,6 +2020,9 @@ expression: response.json()
20202020
{
20212021
"api_token": []
20222022
},
2023+
{
2024+
"trustpub_token": []
2025+
},
20232026
{
20242027
"cookie": []
20252028
}

src/tests/krate/publish/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ mod readme;
1919
mod similar_names;
2020
mod tarball;
2121
mod timestamps;
22+
mod trustpub;
2223
mod validation;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
source: src/tests/krate/publish/trustpub.rs
3+
expression: response.json()
4+
---
5+
{
6+
"crate": {
7+
"badges": [],
8+
"categories": null,
9+
"created_at": "[datetime]",
10+
"default_version": "1.1.0",
11+
"description": "description",
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": "1.1.0",
27+
"max_version": "1.1.0",
28+
"name": "foo",
29+
"newest_version": "1.1.0",
30+
"num_versions": 2,
31+
"recent_downloads": null,
32+
"repository": null,
33+
"updated_at": "[datetime]",
34+
"versions": null,
35+
"yanked": false
36+
},
37+
"warnings": {
38+
"invalid_badges": [],
39+
"invalid_categories": [],
40+
"other": []
41+
}
42+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
source: src/tests/krate/publish/trustpub.rs
3+
expression: response.json()
4+
---
5+
{
6+
"version": {
7+
"audit_actions": [],
8+
"bin_names": [],
9+
"checksum": "f057a5f8094591ca4faccdbcb3cddaf7299f0045c3076065956308eee13f99ac",
10+
"crate": "foo",
11+
"crate_size": 148,
12+
"created_at": "[datetime]",
13+
"description": "description",
14+
"dl_path": "/api/v1/crates/foo/1.1.0/download",
15+
"documentation": null,
16+
"downloads": 0,
17+
"edition": null,
18+
"features": {},
19+
"has_lib": false,
20+
"homepage": null,
21+
"id": 2,
22+
"lib_links": null,
23+
"license": "MIT",
24+
"links": {
25+
"authors": "/api/v1/crates/foo/1.1.0/authors",
26+
"dependencies": "/api/v1/crates/foo/1.1.0/dependencies",
27+
"version_downloads": "/api/v1/crates/foo/1.1.0/downloads"
28+
},
29+
"num": "1.1.0",
30+
"published_by": null,
31+
"readme_path": "/api/v1/crates/foo/1.1.0/readme",
32+
"repository": null,
33+
"rust_version": null,
34+
"updated_at": "[datetime]",
35+
"yank_message": null,
36+
"yanked": false
37+
}
38+
}

0 commit comments

Comments
 (0)