Skip to content

Commit 490c72f

Browse files
committed
controllers/krate/publish: Add support for Trusted Publishing access tokens
1 parent 6fe0761 commit 490c72f

8 files changed

+613
-52
lines changed

src/controllers/krate/publish.rs

Lines changed: 121 additions & 52 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,69 @@ 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 access_token = req
150+
.headers
151+
.get(header::AUTHORIZATION)
152+
.and_then(|h| h.as_bytes().strip_prefix(b"Bearer "))
153+
.map(AccessToken::from_byte_str)
154+
.transpose()
155+
.map_err(|_| forbidden("Invalid authentication token"))?;
156+
157+
let auth = match (access_token, &existing_crate) {
158+
(Some(access_token), Some(existing_crate)) => {
159+
let hashed_token = access_token.sha256();
160+
161+
let crate_ids: Vec<Option<i32>> = trustpub_tokens::table
162+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
163+
.filter(trustpub_tokens::expires_at.gt(now))
164+
.select(trustpub_tokens::crate_ids)
165+
.get_result(&mut conn)
166+
.await
167+
.optional()?
168+
.ok_or_else(|| forbidden("Invalid authentication token"))?;
169+
170+
if !crate_ids.contains(&Some(existing_crate.id)) {
171+
let name = &existing_crate.name;
172+
let error = format!("The provided access token is not valid for crate `{name}`");
173+
return Err(forbidden(error));
174+
}
133175

134-
let auth = AuthCheck::default()
135-
.with_endpoint_scope(endpoint_scope)
136-
.for_crate(&metadata.name)
137-
.check(&req, &mut conn)
138-
.await?;
176+
AuthType::TrustPub
177+
}
178+
_ => {
179+
let endpoint_scope = match existing_crate {
180+
Some(_) => EndpointScope::PublishUpdate,
181+
None => EndpointScope::PublishNew,
182+
};
139183

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-
})?;
184+
let auth = AuthCheck::default()
185+
.with_endpoint_scope(endpoint_scope)
186+
.for_crate(&metadata.name)
187+
.check(&req, &mut conn)
188+
.await?;
148189

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,
190+
AuthType::Regular(Box::new(auth))
191+
}
153192
};
154193

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

159213
let max_upload_size = existing_crate
160214
.as_ref()
@@ -343,9 +397,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
343397
validate_dependency(dep)?;
344398
}
345399

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

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-
};
423+
let krate = if let Some(user) = auth.user() {
424+
// To avoid race conditions, we try to insert
425+
// first so we know whether to add an owner
426+
let krate = match persist.create(conn, user.id).await.optional()? {
427+
Some(krate) => krate,
428+
None => persist.update(conn).await?,
429+
};
378430

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-
}
431+
let owners = krate.owners(conn).await?;
432+
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
433+
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
434+
}
435+
436+
krate
437+
} else {
438+
// Trusted Publishing does not support creating new crates
439+
persist.update(conn).await?
440+
};
383441

384442
if krate.name != *name {
385443
return Err(bad_request(format_args!(
@@ -418,7 +476,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
418476
// Downcast is okay because the file length must be less than the max upload size
419477
// to get here, and max upload sizes are way less than i32 max
420478
.size(content_length as i32)
421-
.published_by(user.id)
479+
.maybe_published_by(auth.user_id())
422480
.checksum(&hex_cksum)
423481
.maybe_links(package.links.as_deref())
424482
.maybe_rust_version(rust_version.as_deref())
@@ -442,16 +500,20 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
442500
}
443501
})?;
444502

445-
versions_published_by::insert(version.id, &verified_email_address, conn).await?;
503+
if let Some(email_address) = verified_email_address {
504+
versions_published_by::insert(version.id, &email_address, conn).await?;
505+
}
446506

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?;
507+
if let AuthType::Regular(auth) = &auth {
508+
NewVersionOwnerAction::builder()
509+
.version_id(version.id)
510+
.user_id(auth.user().id)
511+
.maybe_api_token_id(auth.api_token_id())
512+
.action(VersionAction::Publish)
513+
.build()
514+
.insert(conn)
515+
.await?;
516+
}
455517

456518
// Link this new version to all dependencies
457519
add_dependencies(conn, &deps, version.id).await?;
@@ -728,6 +790,13 @@ fn validate_rust_version(value: &str) -> AppResult<()> {
728790
}
729791
}
730792

793+
fn verified_email_error(domain: &str) -> BoxedAppError {
794+
bad_request(format!(
795+
"A verified email address is required to publish crates to crates.io. \
796+
Visit https://{domain}/settings/profile to set and verify your email address.",
797+
))
798+
}
799+
731800
fn convert_dependencies(
732801
normal_deps: Option<&DepsSet>,
733802
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)