Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/settings/tokens/new.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default class NewTokenController extends Controller {
@tracked scopesInvalid;
@tracked crateScopes;

ENDPOINT_SCOPES = ['change-owners', 'publish-new', 'publish-update', 'yank'];
ENDPOINT_SCOPES = ['change-owners', 'publish-new', 'publish-update', 'trusted-publishing', 'yank'];

scopeDescription = scopeDescription;

Expand Down
1 change: 1 addition & 0 deletions app/utils/token-scopes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const DESCRIPTIONS = {
'change-owners': 'Invite new crate owners or remove existing ones',
'publish-new': 'Publish new crates',
'publish-update': 'Publish new versions of existing crates',
'trusted-publishing': 'Manage trusted publishing configurations',
yank: 'Yank and unyank crate versions',
};

Expand Down
4 changes: 4 additions & 0 deletions crates/crates_io_database/src/models/token/scopes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::io::Write;
pub enum EndpointScope {
PublishNew,
PublishUpdate,
TrustedPublishing,
Yank,
ChangeOwners,
}
Expand All @@ -22,6 +23,7 @@ impl From<&EndpointScope> for &[u8] {
match scope {
EndpointScope::PublishNew => b"publish-new",
EndpointScope::PublishUpdate => b"publish-update",
EndpointScope::TrustedPublishing => b"trusted-publishing",
EndpointScope::Yank => b"yank",
EndpointScope::ChangeOwners => b"change-owners",
}
Expand All @@ -42,6 +44,7 @@ impl TryFrom<&[u8]> for EndpointScope {
match bytes {
b"publish-new" => Ok(EndpointScope::PublishNew),
b"publish-update" => Ok(EndpointScope::PublishUpdate),
b"trusted-publishing" => Ok(EndpointScope::TrustedPublishing),
b"yank" => Ok(EndpointScope::Yank),
b"change-owners" => Ok(EndpointScope::ChangeOwners),
_ => Err("Unrecognized enum variant".to_string()),
Expand Down Expand Up @@ -140,6 +143,7 @@ mod tests {
assert(EndpointScope::ChangeOwners, "\"change-owners\"");
assert(EndpointScope::PublishNew, "\"publish-new\"");
assert(EndpointScope::PublishUpdate, "\"publish-update\"");
assert(EndpointScope::TrustedPublishing, "\"trusted-publishing\"");
assert(EndpointScope::Yank, "\"yank\"");
}

Expand Down
28 changes: 28 additions & 0 deletions e2e/routes/settings/tokens/new.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,34 @@ test.describe('/settings/tokens/new', { tag: '@routes' }, () => {
await expect(page).toHaveURL('/settings/tokens/new?from=1');
await expect(page.locator('[data-test-title]')).toHaveText('Token not found');
});

test('trusted-publishing scope', async ({ page, msw }) => {
await prepare(msw);

await page.goto('/settings/tokens/new');
await expect(page).toHaveURL('/settings/tokens/new');

await page.fill('[data-test-name]', 'trusted-publishing-token');
await page.locator('[data-test-expiry]').selectOption('none');
await page.click('[data-test-scope="trusted-publishing"]');
await page.click('[data-test-generate]');

let token = msw.db.apiToken.findFirst({ where: { name: { equals: 'trusted-publishing-token' } } });
expect(token, 'API token has been created in the backend database').toBeTruthy();
expect(token.name).toBe('trusted-publishing-token');
expect(token.expiredAt).toBe(null);
expect(token.crateScopes).toBe(null);
expect(token.endpointScopes).toEqual(['trusted-publishing']);

await expect(page).toHaveURL('/settings/tokens');
await expect(page.locator('[data-test-api-token="1"] [data-test-name]')).toHaveText('trusted-publishing-token');
await expect(page.locator('[data-test-api-token="1"] [data-test-token]')).toHaveText(token.token);
await expect(page.locator('[data-test-api-token="1"] [data-test-endpoint-scopes]')).toHaveText(
'Scopes: trusted-publishing',
);
await expect(page.locator('[data-test-api-token="1"] [data-test-crate-scopes]')).toHaveCount(0);
await expect(page.locator('[data-test-api-token="1"] [data-test-expired-at]')).toHaveCount(0);
});
});

test.describe('/settings/tokens/new', { tag: '@routes' }, () => {
Expand Down
9 changes: 7 additions & 2 deletions src/controllers/trustpub/github_configs/create/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::util::errors::{AppResult, bad_request, forbidden, server_error};
use anyhow::Context;
use axum::Json;
use crates_io_database::models::OwnerKind;
use crates_io_database::models::token::EndpointScope;
use crates_io_database::models::trustpub::NewGitHubConfig;
use crates_io_database::schema::{crate_owners, emails, users};
use crates_io_github::GitHubError;
Expand All @@ -26,7 +27,7 @@ mod tests;
#[utoipa::path(
post,
path = "/api/v1/trusted_publishing/github_configs",
security(("cookie" = [])),
security(("cookie" = []), ("api_token" = [])),
request_body = inline(json::CreateRequest),
tag = "trusted_publishing",
responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))),
Expand All @@ -47,7 +48,11 @@ pub async fn create_trustpub_github_config(

let mut conn = state.db_write().await?;

let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?;
let auth = AuthCheck::default()
.with_endpoint_scope(EndpointScope::TrustedPublishing)
.for_crate(&json_config.krate)
.check(&parts, &mut conn)
.await?;
let auth_user = auth.user();

let krate = load_crate(&mut conn, &json_config.krate).await?;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
source: src/controllers/trustpub/github_configs/create/tests.rs
expression: response.json()
---
{
"github_config": {
"crate": "foo",
"created_at": "[datetime]",
"environment": null,
"id": 1,
"repository_name": "foo-rs",
"repository_owner": "rust-lang",
"repository_owner_id": 42,
"workflow_filename": "publish.yml"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
source: src/controllers/trustpub/github_configs/create/tests.rs
expression: response.json()
---
{
"github_config": {
"crate": "foo",
"created_at": "[datetime]",
"environment": null,
"id": 1,
"repository_name": "foo-rs",
"repository_owner": "rust-lang",
"repository_owner_id": 42,
"workflow_filename": "publish.yml"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
source: src/controllers/trustpub/github_configs/create/tests.rs
expression: response.json()
---
{
"github_config": {
"crate": "foo",
"created_at": "[datetime]",
"environment": null,
"id": 1,
"repository_name": "foo-rs",
"repository_owner": "rust-lang",
"repository_owner_id": 42,
"workflow_filename": "publish.yml"
}
}
137 changes: 135 additions & 2 deletions src/controllers/trustpub/github_configs/create/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::tests::builders::CrateBuilder;
use crate::tests::util::{RequestHelper, Response, TestApp};
use anyhow::anyhow;
use bytes::Bytes;
use crates_io_database::models::token::{CrateScope, EndpointScope};
use crates_io_database::schema::{emails, trustpub_configs_github};
use crates_io_github::{GitHubError, GitHubUser, MockGitHubClient};
use diesel::prelude::*;
Expand Down Expand Up @@ -221,7 +222,7 @@ async fn test_unauthenticated() -> anyhow::Result<()> {
}

#[tokio::test(flavor = "multi_thread")]
async fn test_token_auth() -> anyhow::Result<()> {
async fn test_legacy_token_auth() -> anyhow::Result<()> {
let (app, _client, cookie_client, token_client) = TestApp::full()
.with_github(simple_github_mock())
.with_token()
Expand All @@ -243,9 +244,141 @@ async fn test_token_auth() -> anyhow::Result<()> {
}
}))?;

let response = token_client.post::<()>(URL, body).await;
assert_snapshot!(response.status(), @"200 OK");
assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" });

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_token_auth_with_trusted_publishing_scope() -> anyhow::Result<()> {
let (app, _client, cookie_client, token_client) = TestApp::full()
.with_github(simple_github_mock())
.with_scoped_token(
Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]),
Some(vec![EndpointScope::TrustedPublishing]),
)
.await;

let mut conn = app.db_conn().await;

CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
.build(&mut conn)
.await?;

let body = serde_json::to_vec(&json!({
"github_config": {
"crate": CRATE_NAME,
"repository_owner": "rust-lang",
"repository_name": "foo-rs",
"workflow_filename": "publish.yml",
"environment": null,
}
}))?;

let response = token_client.post::<()>(URL, body).await;
assert_snapshot!(response.status(), @"200 OK");
assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" });

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_token_auth_without_trusted_publishing_scope() -> anyhow::Result<()> {
let (app, _client, cookie_client, token_client) = TestApp::full()
.with_github(simple_github_mock())
.with_scoped_token(
Some(vec![CrateScope::try_from(CRATE_NAME).unwrap()]),
Some(vec![EndpointScope::PublishUpdate]),
)
.await;

let mut conn = app.db_conn().await;

CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
.build(&mut conn)
.await?;

let body = serde_json::to_vec(&json!({
"github_config": {
"crate": CRATE_NAME,
"repository_owner": "rust-lang",
"repository_name": "foo-rs",
"workflow_filename": "publish.yml",
"environment": null,
}
}))?;

let response = token_client.post::<()>(URL, body).await;
assert_snapshot!(response.status(), @"403 Forbidden");
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#);
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#);

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_token_auth_with_wrong_crate_scope() -> anyhow::Result<()> {
let (app, _client, cookie_client, token_client) = TestApp::full()
.with_github(simple_github_mock())
.with_scoped_token(
Some(vec![CrateScope::try_from("other-crate").unwrap()]),
Some(vec![EndpointScope::TrustedPublishing]),
)
.await;

let mut conn = app.db_conn().await;

CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
.build(&mut conn)
.await?;

let body = serde_json::to_vec(&json!({
"github_config": {
"crate": CRATE_NAME,
"repository_owner": "rust-lang",
"repository_name": "foo-rs",
"workflow_filename": "publish.yml",
"environment": null,
}
}))?;

let response = token_client.post::<()>(URL, body).await;
assert_snapshot!(response.status(), @"403 Forbidden");
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this token does not have the required permissions to perform this action"}]}"#);

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_token_auth_with_wildcard_crate_scope() -> anyhow::Result<()> {
let (app, _client, cookie_client, token_client) = TestApp::full()
.with_github(simple_github_mock())
.with_scoped_token(
Some(vec![CrateScope::try_from("*").unwrap()]),
Some(vec![EndpointScope::TrustedPublishing]),
)
.await;

let mut conn = app.db_conn().await;

CrateBuilder::new(CRATE_NAME, cookie_client.as_model().id)
.build(&mut conn)
.await?;

let body = serde_json::to_vec(&json!({
"github_config": {
"crate": CRATE_NAME,
"repository_owner": "rust-lang",
"repository_name": "foo-rs",
"workflow_filename": "publish.yml",
"environment": null,
}
}))?;

let response = token_client.post::<()>(URL, body).await;
assert_snapshot!(response.status(), @"200 OK");
assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" });

Ok(())
}
Expand Down
16 changes: 10 additions & 6 deletions src/controllers/trustpub/github_configs/delete/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::email::EmailMessage;
use crate::util::errors::{AppResult, bad_request, not_found};
use anyhow::Context;
use axum::extract::Path;
use crates_io_database::models::token::EndpointScope;
use crates_io_database::models::trustpub::GitHubConfig;
use crates_io_database::models::{Crate, OwnerKind};
use crates_io_database::schema::{crate_owners, crates, emails, trustpub_configs_github, users};
Expand All @@ -24,7 +25,7 @@ mod tests;
params(
("id" = i32, Path, description = "ID of the Trusted Publishing configuration"),
),
security(("cookie" = [])),
security(("cookie" = []), ("api_token" = [])),
tag = "trusted_publishing",
responses((status = 204, description = "Successful Response")),
)]
Expand All @@ -35,11 +36,7 @@ pub async fn delete_trustpub_github_config(
) -> AppResult<StatusCode> {
let mut conn = state.db_write().await?;

let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?;
let auth_user = auth.user();

// Check that a trusted publishing config with the given ID exists,
// and fetch the corresponding crate.
// First, find the config and crate to get the crate name for scope validation
let (config, krate) = trustpub_configs_github::table
.inner_join(crates::table)
.filter(trustpub_configs_github::id.eq(id))
Expand All @@ -49,6 +46,13 @@ pub async fn delete_trustpub_github_config(
.optional()?
.ok_or_else(not_found)?;

let auth = AuthCheck::default()
.with_endpoint_scope(EndpointScope::TrustedPublishing)
.for_crate(&krate.name)
.check(&parts, &mut conn)
.await?;
let auth_user = auth.user();

// Load all crate owners for the given crate ID
let user_owners = crate_owners::table
.filter(crate_owners::crate_id.eq(config.crate_id))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
source: src/controllers/trustpub/github_configs/delete/tests.rs
expression: app.emails_snapshot().await
---
To: [email protected]
From: crates.io <[email protected]>
Subject: crates.io: Trusted Publishing configuration removed from foo
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable


Hello foo!

You removed a "Trusted Publishing" configuration for GitHub Actions from your crate "foo".

Trusted Publishing configuration:

- Repository owner: rust-lang
- Repository name: foo-rs
- Workflow filename: publish.yml
- Environment: (not set)

If you did not make this change and you think it was made maliciously, you can email [email protected] for assistance.

--
The crates.io Team
Loading
Loading