Skip to content

Commit e93afe2

Browse files
committed
crate/update: Add support for scoped API tokens
1 parent 0b9faa7 commit e93afe2

File tree

2 files changed

+155
-4
lines changed

2 files changed

+155
-4
lines changed

src/controllers/krate/update.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ use crate::auth::AuthCheck;
33
use crate::controllers::krate::CratePath;
44
use crate::email::EmailMessage;
55
use crate::middleware::real_ip::RealIp;
6+
use crate::models::token::EndpointScope;
67
use crate::models::{Crate, User};
78
use crate::schema::*;
8-
use crate::util::errors::{AppResult, crate_not_found, custom};
9+
use crate::util::errors::{AppResult, crate_not_found, custom, forbidden};
910
use crate::views::EncodableCrate;
1011
use anyhow::Context;
1112
use axum::{Extension, Json};
@@ -52,12 +53,25 @@ pub async fn update_crate(
5253
) -> AppResult<Json<PatchResponse>> {
5354
let mut conn = app.db_write().await?;
5455

55-
// Check that the user is authenticated
56-
let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?;
57-
5856
// Check that the crate exists
5957
let krate = path.load_crate(&mut conn).await?;
6058

59+
// Check that the user is authenticated with appropriate permissions
60+
let auth = AuthCheck::default()
61+
.with_endpoint_scope(EndpointScope::TrustedPublishing)
62+
.for_crate(&krate.name)
63+
.check(&req, &mut conn)
64+
.await?;
65+
66+
if auth
67+
.api_token()
68+
.is_some_and(|token| token.endpoint_scopes.is_none())
69+
{
70+
return Err(forbidden(
71+
"This endpoint cannot be used with legacy API tokens. Use a scoped API token instead.",
72+
));
73+
}
74+
6175
// Update crate settings in a transaction
6276
conn.transaction(|conn| {
6377
update_inner(conn, &app, &krate, auth.user(), &real_ip, body).scope_boxed()

src/tests/routes/crates/update.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::builders::CrateBuilder;
22
use crate::util::{RequestHelper, TestApp};
3+
use crates_io::models::token::{CrateScope, EndpointScope};
34
use insta::{assert_json_snapshot, assert_snapshot};
45

56
#[tokio::test(flavor = "multi_thread")]
@@ -131,3 +132,139 @@ async fn test_update_nonexistent_crate() {
131132

132133
assert_eq!(app.emails().await.len(), 0);
133134
}
135+
136+
mod auth {
137+
use super::*;
138+
139+
const CRATE_NAME: &str = "foo";
140+
141+
async fn prepare() -> (TestApp, crate::util::MockCookieUser) {
142+
let (app, _, user) = TestApp::full().with_user().await;
143+
let mut conn = app.db_conn().await;
144+
145+
// Create a crate
146+
let owner_id = user.as_model().id;
147+
CrateBuilder::new(CRATE_NAME, owner_id)
148+
.expect_build(&mut conn)
149+
.await;
150+
151+
(app, user)
152+
}
153+
154+
#[tokio::test(flavor = "multi_thread")]
155+
async fn token_user_with_legacy_token() {
156+
let (app, user) = prepare().await;
157+
let token = user.db_new_token("test-token").await;
158+
159+
let url = format!("/api/v1/crates/{}", CRATE_NAME);
160+
let body = serde_json::json!({ "trustpub_only": true });
161+
let response = token.patch::<()>(&url, body.to_string()).await;
162+
assert_snapshot!(response.status(), @"403 Forbidden");
163+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"This endpoint cannot be used with legacy API tokens. Use a scoped API token instead."}]}"#);
164+
165+
assert_eq!(app.emails().await.len(), 0);
166+
}
167+
168+
#[tokio::test(flavor = "multi_thread")]
169+
async fn token_user_with_correct_endpoint_scope() {
170+
let (app, user) = prepare().await;
171+
let token = user
172+
.db_new_scoped_token(
173+
"test-token",
174+
None,
175+
Some(vec![EndpointScope::TrustedPublishing]),
176+
None,
177+
)
178+
.await;
179+
180+
let url = format!("/api/v1/crates/{}", CRATE_NAME);
181+
let body = serde_json::json!({ "trustpub_only": true });
182+
let response = token.patch::<()>(&url, body.to_string()).await;
183+
assert_snapshot!(response.status(), @"200 OK");
184+
185+
assert!(!app.emails().await.is_empty());
186+
}
187+
188+
#[tokio::test(flavor = "multi_thread")]
189+
async fn token_user_with_incorrect_endpoint_scope() {
190+
let (app, user) = prepare().await;
191+
let token = user
192+
.db_new_scoped_token(
193+
"test-token",
194+
None,
195+
Some(vec![EndpointScope::PublishUpdate]),
196+
None,
197+
)
198+
.await;
199+
200+
let url = format!("/api/v1/crates/{}", CRATE_NAME);
201+
let body = serde_json::json!({ "trustpub_only": true });
202+
let response = token.patch::<()>(&url, body.to_string()).await;
203+
assert_snapshot!(response.status(), @"403 Forbidden");
204+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#);
205+
206+
assert_eq!(app.emails().await.len(), 0);
207+
}
208+
209+
#[tokio::test(flavor = "multi_thread")]
210+
async fn token_user_with_only_crate_scope() {
211+
let (app, user) = prepare().await;
212+
let token = user
213+
.db_new_scoped_token(
214+
"test-token",
215+
Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]),
216+
None,
217+
None,
218+
)
219+
.await;
220+
221+
let url = format!("/api/v1/crates/{}", CRATE_NAME);
222+
let body = serde_json::json!({ "trustpub_only": true });
223+
let response = token.patch::<()>(&url, body.to_string()).await;
224+
assert_snapshot!(response.status(), @"403 Forbidden");
225+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"This endpoint cannot be used with legacy API tokens. Use a scoped API token instead."}]}"#);
226+
227+
assert_eq!(app.emails().await.len(), 0);
228+
}
229+
230+
#[tokio::test(flavor = "multi_thread")]
231+
async fn token_user_with_incorrect_crate_scope() {
232+
let (app, user) = prepare().await;
233+
let token = user
234+
.db_new_scoped_token(
235+
"test-token",
236+
Some(vec![CrateScope::try_from("bar").unwrap()]),
237+
Some(vec![EndpointScope::TrustedPublishing]),
238+
None,
239+
)
240+
.await;
241+
242+
let url = format!("/api/v1/crates/{}", CRATE_NAME);
243+
let body = serde_json::json!({ "trustpub_only": true });
244+
let response = token.patch::<()>(&url, body.to_string()).await;
245+
assert_snapshot!(response.status(), @"403 Forbidden");
246+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#);
247+
248+
assert_eq!(app.emails().await.len(), 0);
249+
}
250+
251+
#[tokio::test(flavor = "multi_thread")]
252+
async fn token_user_with_both_scopes() {
253+
let (app, user) = prepare().await;
254+
let token = user
255+
.db_new_scoped_token(
256+
"test-token",
257+
Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]),
258+
Some(vec![EndpointScope::TrustedPublishing]),
259+
None,
260+
)
261+
.await;
262+
263+
let url = format!("/api/v1/crates/{}", CRATE_NAME);
264+
let body = serde_json::json!({ "trustpub_only": true });
265+
let response = token.patch::<()>(&url, body.to_string()).await;
266+
assert_snapshot!(response.status(), @"200 OK");
267+
268+
assert!(!app.emails().await.is_empty());
269+
}
270+
}

0 commit comments

Comments
 (0)