Skip to content

Commit 014ee26

Browse files
committed
trustpub: Implement POST /api/v1/trusted_publishing/gitlab_configs API endpoint
1 parent 0877be0 commit 014ee26

15 files changed

+920
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
use crate::app::AppState;
2+
use crate::auth::AuthCheck;
3+
use crate::controllers::krate::load_crate;
4+
use crate::controllers::trustpub::emails::{ConfigCreatedEmail, ConfigType};
5+
use crate::controllers::trustpub::gitlab_configs::json;
6+
use crate::util::errors::{AppResult, bad_request, custom, forbidden};
7+
use anyhow::Context;
8+
use axum::Json;
9+
use crates_io_database::models::OwnerKind;
10+
use crates_io_database::models::token::EndpointScope;
11+
use crates_io_database::models::trustpub::{GitLabConfig, NewGitLabConfig};
12+
use crates_io_database::schema::{crate_owners, emails, users};
13+
use crates_io_trustpub::gitlab::validation::{
14+
validate_environment, validate_namespace, validate_project, validate_workflow_filepath,
15+
};
16+
use diesel::prelude::*;
17+
use diesel_async::RunQueryDsl;
18+
use http::request::Parts;
19+
use tracing::warn;
20+
21+
const MAX_CONFIGS_PER_CRATE: usize = 5;
22+
23+
#[utoipa::path(
24+
post,
25+
path = "/api/v1/trusted_publishing/gitlab_configs",
26+
security(("cookie" = []), ("api_token" = [])),
27+
request_body = inline(json::CreateRequest),
28+
tag = "trusted_publishing",
29+
responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))),
30+
)]
31+
pub async fn create_trustpub_gitlab_config(
32+
state: AppState,
33+
parts: Parts,
34+
json: json::CreateRequest,
35+
) -> AppResult<Json<json::CreateResponse>> {
36+
let json_config = json.gitlab_config;
37+
38+
validate_namespace(&json_config.namespace)?;
39+
validate_project(&json_config.project)?;
40+
validate_workflow_filepath(&json_config.workflow_filepath)?;
41+
if let Some(env) = &json_config.environment {
42+
validate_environment(env)?;
43+
}
44+
45+
let mut conn = state.db_write().await?;
46+
47+
let auth = AuthCheck::default()
48+
.with_endpoint_scope(EndpointScope::TrustedPublishing)
49+
.for_crate(&json_config.krate)
50+
.check(&parts, &mut conn)
51+
.await?;
52+
let auth_user = auth.user();
53+
54+
let krate = load_crate(&mut conn, &json_config.krate).await?;
55+
56+
// Check if the crate has reached the maximum number of configs
57+
let config_count = GitLabConfig::count_for_crate(&mut conn, krate.id).await?;
58+
if config_count >= MAX_CONFIGS_PER_CRATE as i64 {
59+
let message = format!(
60+
"This crate already has the maximum number of GitLab Trusted Publishing configurations ({})",
61+
MAX_CONFIGS_PER_CRATE
62+
);
63+
return Err(custom(http::StatusCode::CONFLICT, message));
64+
}
65+
66+
let user_owners = crate_owners::table
67+
.filter(crate_owners::crate_id.eq(krate.id))
68+
.filter(crate_owners::deleted.eq(false))
69+
.filter(crate_owners::owner_kind.eq(OwnerKind::User))
70+
.inner_join(users::table)
71+
.inner_join(emails::table.on(users::id.eq(emails::user_id)))
72+
.select((users::id, users::gh_login, emails::email, emails::verified))
73+
.load::<(i32, String, String, bool)>(&mut conn)
74+
.await?;
75+
76+
let (_, _, _, email_verified) = user_owners
77+
.iter()
78+
.find(|(id, _, _, _)| *id == auth_user.id)
79+
.ok_or_else(|| bad_request("You are not an owner of this crate"))?;
80+
81+
if !email_verified {
82+
let message = "You must verify your email address to create a Trusted Publishing config";
83+
return Err(forbidden(message));
84+
}
85+
86+
// Save the new GitLab OIDC config to the database
87+
88+
let new_config = NewGitLabConfig {
89+
crate_id: krate.id,
90+
namespace: &json_config.namespace,
91+
project: &json_config.project,
92+
workflow_filepath: &json_config.workflow_filepath,
93+
environment: json_config.environment.as_deref(),
94+
};
95+
96+
let saved_config = new_config.insert(&mut conn).await?;
97+
98+
// Send notification emails to crate owners
99+
100+
let recipients = user_owners
101+
.into_iter()
102+
.filter(|(_, _, _, verified)| *verified)
103+
.map(|(_, login, email, _)| (login, email))
104+
.collect::<Vec<_>>();
105+
106+
for (recipient, email_address) in &recipients {
107+
let saved_config = ConfigType::GitLab(&saved_config);
108+
109+
let context = ConfigCreatedEmail {
110+
recipient,
111+
auth_user,
112+
krate: &krate,
113+
saved_config,
114+
};
115+
116+
if let Err(err) = send_notification_email(&state, email_address, context).await {
117+
warn!("Failed to send trusted publishing notification to {email_address}: {err}");
118+
}
119+
}
120+
121+
let gitlab_config = json::GitLabConfig {
122+
id: saved_config.id,
123+
krate: krate.name,
124+
namespace: saved_config.namespace,
125+
project: saved_config.project,
126+
workflow_filepath: saved_config.workflow_filepath,
127+
environment: saved_config.environment,
128+
created_at: saved_config.created_at,
129+
};
130+
131+
Ok(Json(json::CreateResponse { gitlab_config }))
132+
}
133+
134+
async fn send_notification_email(
135+
state: &AppState,
136+
email_address: &str,
137+
context: ConfigCreatedEmail<'_>,
138+
) -> anyhow::Result<()> {
139+
let email = context.render();
140+
let email = email.context("Failed to render email template")?;
141+
142+
state
143+
.emails
144+
.send(email_address, email)
145+
.await
146+
.context("Failed to send email")
147+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use axum::Json;
2+
use axum::extract::FromRequest;
3+
use chrono::{DateTime, Utc};
4+
use serde::{Deserialize, Serialize};
5+
6+
#[derive(Debug, Serialize, utoipa::ToSchema)]
7+
pub struct GitLabConfig {
8+
#[schema(example = 42)]
9+
pub id: i32,
10+
#[schema(example = "regex")]
11+
#[serde(rename = "crate")]
12+
pub krate: String,
13+
#[schema(example = "rust-lang")]
14+
pub namespace: String,
15+
#[schema(example = "regex")]
16+
pub project: String,
17+
#[schema(example = ".gitlab-ci.yml")]
18+
pub workflow_filepath: String,
19+
#[schema(example = json!(null))]
20+
pub environment: Option<String>,
21+
pub created_at: DateTime<Utc>,
22+
}
23+
24+
#[derive(Debug, Deserialize, utoipa::ToSchema)]
25+
pub struct NewGitLabConfig {
26+
#[schema(example = "regex")]
27+
#[serde(rename = "crate")]
28+
pub krate: String,
29+
#[schema(example = "rust-lang")]
30+
pub namespace: String,
31+
#[schema(example = "regex")]
32+
pub project: String,
33+
#[schema(example = ".gitlab-ci.yml")]
34+
pub workflow_filepath: String,
35+
#[schema(example = json!(null))]
36+
pub environment: Option<String>,
37+
}
38+
39+
#[derive(Debug, Deserialize, FromRequest, utoipa::ToSchema)]
40+
#[from_request(via(Json))]
41+
pub struct CreateRequest {
42+
pub gitlab_config: NewGitLabConfig,
43+
}
44+
45+
#[derive(Debug, Serialize, utoipa::ToSchema)]
46+
pub struct CreateResponse {
47+
pub gitlab_config: GitLabConfig,
48+
}
49+
50+
#[derive(Debug, Serialize, utoipa::ToSchema)]
51+
pub struct ListResponse {
52+
pub gitlab_configs: Vec<GitLabConfig>,
53+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod create;
2+
pub mod json;

src/controllers/trustpub/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod emails;
22
pub mod github_configs;
3+
pub mod gitlab_configs;
34
pub mod tokens;

src/router.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
9898
trustpub::github_configs::delete::delete_trustpub_github_config,
9999
trustpub::github_configs::list::list_trustpub_github_configs,
100100
))
101+
.routes(routes!(
102+
trustpub::gitlab_configs::create::create_trustpub_gitlab_config,
103+
))
101104
.split_for_parts();
102105

103106
let mut router = router

0 commit comments

Comments
 (0)