Skip to content

models: Remove remaining GitHubClient usage #10596

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 16, 2025
Merged
5 changes: 3 additions & 2 deletions src/controllers/crate_owner_invitation.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use crate::app::AppState;
use crate::auth::AuthCheck;
use crate::auth::Authentication;
use crate::controllers::helpers::authorization::Rights;
use crate::controllers::helpers::pagination::{Page, PaginationOptions, PaginationQueryParams};
use crate::models::crate_owner_invitation::AcceptError;
use crate::models::{Crate, CrateOwnerInvitation, Rights, User};
use crate::models::{Crate, CrateOwnerInvitation, User};
use crate::schema::{crate_owner_invitations, crates, users};
use crate::util::errors::{bad_request, custom, forbidden, internal, AppResult, BoxedAppError};
use crate::util::RequestUtils;
Expand Down Expand Up @@ -157,7 +158,7 @@ async fn prepare_list(
// Only allow crate owners to query pending invitations for their crate.
let krate: Crate = Crate::by_name(&crate_name).first(conn).await?;
let owners = krate.owners(conn).await?;
if user.rights(&*state.github, &owners).await? != Rights::Full {
if Rights::get(user, &*state.github, &owners).await? != Rights::Full {
let detail = "only crate owners can query pending invitations for their crate";
return Err(forbidden(detail));
}
Expand Down
1 change: 1 addition & 0 deletions src/controllers/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::util::errors::AppResult;
use axum::response::{IntoResponse, Response};
use axum_extra::json;

pub mod authorization;
pub(crate) mod pagination;

pub(crate) use self::pagination::Paginate;
Expand Down
57 changes: 57 additions & 0 deletions src/controllers/helpers/authorization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use crate::models::{Owner, User};
use crates_io_github::{GitHubClient, GitHubError};
use oauth2::AccessToken;
use secrecy::ExposeSecret;

/// Access rights to the crate (publishing and ownership management)
/// NOTE: The order of these variants matters!
#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Copy)]
pub enum Rights {
None,
Publish,
Full,
}

impl Rights {
/// Given this set of owners, determines the strongest rights the
/// user has.
///
/// Short-circuits on `Full` because you can't beat it. In practice, we'll always
/// see `[user, user, user, ..., team, team, team]`, so we could shortcircuit on
/// `Publish` as well, but this is a non-obvious invariant so we don't bother.
/// Sweet free optimization if teams are proving burdensome to check.
/// More than one team isn't really expected, though.
pub async fn get(
user: &User,
gh_client: &dyn GitHubClient,
owners: &[Owner],
) -> Result<Self, GitHubError> {
let token = AccessToken::new(user.gh_access_token.expose_secret().to_string());

let mut best = Self::None;
for owner in owners {
match *owner {
Owner::User(ref other_user) => {
if other_user.id == user.id {
return Ok(Self::Full);
}
}
Owner::Team(ref team) => {
// Phones home to GitHub to ask if this User is a member of the given team.
// Note that we're assuming that the given user is the one interested in
// the answer. If this is not the case, then we could accidentally leak
// private membership information here.
let is_team_member = gh_client
.team_membership(team.org_id, team.github_id, &user.gh_login, &token)
.await?
.is_some_and(|m| m.is_active());

if is_team_member {
best = Self::Publish;
}
}
}
}
Ok(best)
}
}
5 changes: 3 additions & 2 deletions src/controllers/krate/delete.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::app::AppState;
use crate::auth::AuthCheck;
use crate::controllers::helpers::authorization::Rights;
use crate::controllers::krate::CratePath;
use crate::email::Email;
use crate::models::{NewDeletedCrate, Rights};
use crate::models::NewDeletedCrate;
use crate::schema::{crate_downloads, crates, dependencies};
use crate::util::errors::{custom, AppResult, BoxedAppError};
use crate::worker::jobs;
Expand Down Expand Up @@ -68,7 +69,7 @@ pub async fn delete_crate(
// Check that the user is an owner of the crate (team owners are not allowed to delete crates)
let user = auth.user();
let owners = krate.owners(&mut conn).await?;
match user.rights(&*app.github, &owners).await? {
match Rights::get(user, &*app.github, &owners).await? {
Rights::Full => {}
Rights::Publish => {
let msg = "team members don't have permission to delete crates";
Expand Down
108 changes: 103 additions & 5 deletions src/controllers/krate/owners.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
//! All routes related to managing owners of a crate

use crate::controllers::helpers::authorization::Rights;
use crate::controllers::krate::CratePath;
use crate::models::krate::OwnerRemoveError;
use crate::models::{
krate::NewOwnerInvite, token::EndpointScope, CrateOwner, NewCrateOwnerInvitation,
NewCrateOwnerInvitationOutcome,
NewCrateOwnerInvitationOutcome, NewTeam,
};
use crate::models::{Crate, Owner, Rights, Team, User};
use crate::models::{Crate, Owner, Team, User};
use crate::util::errors::{bad_request, crate_not_found, custom, AppResult, BoxedAppError};
use crate::views::EncodableOwner;
use crate::{app::AppState, App};
Expand All @@ -15,12 +16,13 @@ use axum::Json;
use axum_extra::json;
use axum_extra::response::ErasedJson;
use chrono::Utc;
use crates_io_github::GitHubClient;
use crates_io_github::{GitHubClient, GitHubError};
use diesel::prelude::*;
use diesel_async::scoped_futures::ScopedFutureExt;
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
use http::request::Parts;
use http::StatusCode;
use oauth2::AccessToken;
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;

Expand Down Expand Up @@ -176,7 +178,7 @@ async fn modify_owners(

let owners = krate.owners(conn).await?;

match user.rights(&*app.github, &owners).await? {
match Rights::get(user, &*app.github, &owners).await? {
Rights::Full => {}
// Yes!
Rights::Publish => {
Expand Down Expand Up @@ -336,8 +338,26 @@ async fn add_team_owner(
krate: &Crate,
login: &str,
) -> Result<NewOwnerInvite, OwnerAddError> {
// github:rust-lang:owners
let mut chunks = login.split(':');

let team_system = chunks.next().unwrap();
if team_system != "github" {
let error = "unknown organization handler, only 'github:org:team' is supported";
return Err(bad_request(error).into());
}

// unwrap is documented above as part of the calling contract
let org = chunks.next().unwrap();
let team = chunks.next().ok_or_else(|| {
let error = "missing github team argument; format is github:org:team";
bad_request(error)
})?;

// Always recreate teams to get the most up-to-date GitHub ID
let team = Team::create_or_update(gh_client, conn, login, req_user).await?;
let team =
create_or_update_github_team(gh_client, conn, &login.to_lowercase(), org, team, req_user)
.await?;

// Teams are added as owners immediately, since the above call ensures
// the user is a team member.
Expand All @@ -352,6 +372,84 @@ async fn add_team_owner(
Ok(NewOwnerInvite::Team(team))
}

/// Tries to create or update a Github Team. Assumes `org` and `team` are
/// correctly parsed out of the full `name`. `name` is passed as a
/// convenience to avoid rebuilding it.
pub async fn create_or_update_github_team(
gh_client: &dyn GitHubClient,
conn: &mut AsyncPgConnection,
login: &str,
org_name: &str,
team_name: &str,
req_user: &User,
) -> AppResult<Team> {
// GET orgs/:org/teams
// check that `team` is the `slug` in results, and grab its data

// "sanitization"
fn is_allowed_char(c: char) -> bool {
matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_')
}

if let Some(c) = org_name.chars().find(|c| !is_allowed_char(*c)) {
return Err(bad_request(format_args!(
"organization cannot contain special \
characters like {c}"
)));
}

let token = AccessToken::new(req_user.gh_access_token.expose_secret().to_string());
let team = gh_client.team_by_name(org_name, team_name, &token).await
.map_err(|_| {
bad_request(format_args!(
"could not find the github team {org_name}/{team_name}. \
Make sure that you have the right permissions in GitHub. \
See https://doc.rust-lang.org/cargo/reference/publishing.html#github-permissions"
))
})?;

let org_id = team.organization.id;
let gh_login = &req_user.gh_login;

let is_team_member = gh_client
.team_membership(org_id, team.id, gh_login, &token)
.await?
.is_some_and(|m| m.is_active());

let can_add_team =
is_team_member || is_gh_org_owner(gh_client, org_id, gh_login, &token).await?;

if !can_add_team {
return Err(custom(
StatusCode::FORBIDDEN,
"only members of a team or organization owners can add it as an owner",
));
}

let org = gh_client.org_by_name(org_name, &token).await?;

NewTeam::builder()
.login(&login.to_lowercase())
.org_id(org_id)
.github_id(team.id)
.maybe_name(team.name.as_deref())
.maybe_avatar(org.avatar_url.as_deref())
.build()
.create_or_update(conn)
.await
.map_err(Into::into)
}

async fn is_gh_org_owner(
gh_client: &dyn GitHubClient,
org_id: i32,
gh_login: &str,
token: &AccessToken,
) -> Result<bool, GitHubError> {
let membership = gh_client.org_membership(org_id, gh_login, token).await?;
Ok(membership.is_some_and(|m| m.is_active_admin()))
}

/// Error results from a [`add_owner()`] model call.
#[derive(Debug, Error)]
enum OwnerAddError {
Expand Down
5 changes: 3 additions & 2 deletions src/controllers/krate/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ use url::Url;

use crate::models::{
default_versions::Version as DefaultVersion, Category, Crate, DependencyKind, Keyword,
NewCrate, NewVersion, NewVersionOwnerAction, Rights, VersionAction,
NewCrate, NewVersion, NewVersionOwnerAction, VersionAction,
};

use crate::controllers::helpers::authorization::Rights;
use crate::licenses::parse_license_expr;
use crate::middleware::log_request::RequestLogExt;
use crate::models::token::EndpointScope;
Expand Down Expand Up @@ -356,7 +357,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
};

let owners = krate.owners(conn).await?;
if user.rights(&*app.github, &owners).await? < Rights::Publish {
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
}

Expand Down
7 changes: 3 additions & 4 deletions src/controllers/version/update.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use super::CrateVersionPath;
use crate::app::AppState;
use crate::auth::{AuthCheck, Authentication};
use crate::controllers::helpers::authorization::Rights;
use crate::models::token::EndpointScope;
use crate::models::{
Crate, NewVersionOwnerAction, Rights, Version, VersionAction, VersionOwnerAction,
};
use crate::models::{Crate, NewVersionOwnerAction, Version, VersionAction, VersionOwnerAction};
use crate::rate_limiter::LimitedAction;
use crate::schema::versions;
use crate::util::errors::{bad_request, custom, AppResult};
Expand Down Expand Up @@ -122,7 +121,7 @@ pub async fn perform_version_yank_update(

let yanked = yanked.unwrap_or(version.yanked);

if user.rights(&*state.github, &owners).await? < Rights::Publish {
if Rights::get(user, &*state.github, &owners).await? < Rights::Publish {
if user.is_admin {
let action = if yanked { "yanking" } else { "unyanking" };
warn!(
Expand Down
4 changes: 1 addition & 3 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ pub use self::follow::Follow;
pub use self::keyword::{CrateKeyword, Keyword};
pub use self::krate::{Crate, CrateName, NewCrate, RecentCrateDownloads};
pub use self::owner::{CrateOwner, Owner, OwnerKind};
pub use self::rights::Rights;
pub use self::team::{NewTeam, Team};
pub use self::token::ApiToken;
pub use self::user::{NewUser, User};
Expand All @@ -32,8 +31,7 @@ mod follow;
mod keyword;
pub mod krate;
mod owner;
mod rights;
mod team;
pub mod team;
pub mod token;
pub mod user;
pub mod version;
8 changes: 0 additions & 8 deletions src/models/rights.rs

This file was deleted.

Loading