Skip to content

Commit ded9ab7

Browse files
committed
controllers/krate/publish: Add support for Trusted Publishing access tokens
1 parent ffcd842 commit ded9ab7

9 files changed

+678
-53
lines changed

src/controllers/krate/publish.rs

Lines changed: 129 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,76 @@ 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+
let trustpub_token = req
150+
.headers
151+
.get(header::AUTHORIZATION)
152+
.and_then(|h| {
153+
let mut split = h.as_bytes().splitn(2, |b| *b == b' ');
154+
Some((split.next()?, split.next()?))
155+
})
156+
.filter(|(scheme, _token)| scheme.eq_ignore_ascii_case(b"Bearer"))
157+
.map(|(_scheme, token)| token.trim_ascii())
158+
.map(AccessToken::from_byte_str)
159+
.transpose()
160+
.map_err(|_| forbidden("Invalid authentication token"))?;
161+
162+
let auth = if let Some(trustpub_token) = trustpub_token {
163+
let Some(existing_crate) = &existing_crate else {
164+
let error = forbidden("Trusted Publishing tokens do not support creating new crates");
165+
return Err(error);
166+
};
133167

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

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-
})?;
170+
let crate_ids: Vec<Option<i32>> = trustpub_tokens::table
171+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
172+
.filter(trustpub_tokens::expires_at.gt(now))
173+
.select(trustpub_tokens::crate_ids)
174+
.get_result(&mut conn)
175+
.await
176+
.optional()?
177+
.ok_or_else(|| forbidden("Invalid authentication token"))?;
178+
179+
if !crate_ids.contains(&Some(existing_crate.id)) {
180+
let name = &existing_crate.name;
181+
let error = format!("The provided access token is not valid for crate `{name}`");
182+
return Err(forbidden(error));
183+
}
184+
185+
AuthType::TrustPub
186+
} else {
187+
let endpoint_scope = match existing_crate {
188+
Some(_) => EndpointScope::PublishUpdate,
189+
None => EndpointScope::PublishNew,
190+
};
191+
192+
let auth = AuthCheck::default()
193+
.with_endpoint_scope(endpoint_scope)
194+
.for_crate(&metadata.name)
195+
.check(&req, &mut conn)
196+
.await?;
148197

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,
198+
AuthType::Regular(Box::new(auth))
153199
};
154200

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

159220
let max_upload_size = existing_crate
160221
.as_ref()
@@ -343,9 +404,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
343404
validate_dependency(dep)?;
344405
}
345406

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

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-
};
430+
let krate = if let Some(user) = auth.user() {
431+
// To avoid race conditions, we try to insert
432+
// first so we know whether to add an owner
433+
let krate = match persist.create(conn, user.id).await.optional()? {
434+
Some(krate) => krate,
435+
None => persist.update(conn).await?,
436+
};
378437

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-
}
438+
let owners = krate.owners(conn).await?;
439+
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
440+
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
441+
}
442+
443+
krate
444+
} else {
445+
// Trusted Publishing does not support creating new crates
446+
persist.update(conn).await?
447+
};
383448

384449
if krate.name != *name {
385450
return Err(bad_request(format_args!(
@@ -418,7 +483,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
418483
// Downcast is okay because the file length must be less than the max upload size
419484
// to get here, and max upload sizes are way less than i32 max
420485
.size(content_length as i32)
421-
.published_by(user.id)
486+
.maybe_published_by(auth.user_id())
422487
.checksum(&hex_cksum)
423488
.maybe_links(package.links.as_deref())
424489
.maybe_rust_version(rust_version.as_deref())
@@ -442,16 +507,20 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
442507
}
443508
})?;
444509

445-
versions_published_by::insert(version.id, &verified_email_address, conn).await?;
510+
if let Some(email_address) = verified_email_address {
511+
versions_published_by::insert(version.id, &email_address, conn).await?;
512+
}
446513

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?;
514+
if let AuthType::Regular(auth) = &auth {
515+
NewVersionOwnerAction::builder()
516+
.version_id(version.id)
517+
.user_id(auth.user().id)
518+
.maybe_api_token_id(auth.api_token_id())
519+
.action(VersionAction::Publish)
520+
.build()
521+
.insert(conn)
522+
.await?;
523+
}
455524

456525
// Link this new version to all dependencies
457526
add_dependencies(conn, &deps, version.id).await?;
@@ -464,7 +533,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
464533
.await
465534
.optional()?;
466535

467-
let num_versions = existing_default_version.as_ref().and_then(|t|t.1).unwrap_or_default();
536+
let num_versions = existing_default_version.as_ref().and_then(|t| t.1).unwrap_or_default();
468537
let mut default_version = None;
469538
// Upsert the `default_value` determined by the existing `default_value` and the
470539
// published version. Note that this could potentially write an outdated version
@@ -728,6 +797,13 @@ fn validate_rust_version(value: &str) -> AppResult<()> {
728797
}
729798
}
730799

800+
fn verified_email_error(domain: &str) -> BoxedAppError {
801+
bad_request(format!(
802+
"A verified email address is required to publish crates to crates.io. \
803+
Visit https://{domain}/settings/profile to set and verify your email address.",
804+
))
805+
}
806+
731807
fn convert_dependencies(
732808
normal_deps: Option<&DepsSet>,
733809
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)