Skip to content

Commit 1b5fb23

Browse files
committed
crate/update: Add support for scoped API tokens
1 parent f6a812f commit 1b5fb23

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")]
@@ -123,3 +124,139 @@ async fn test_update_nonexistent_crate() {
123124

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

0 commit comments

Comments
 (0)