11//! All routes related to managing owners of a crate
22
3+ use crate :: controllers:: helpers:: authorization:: Rights ;
34use crate :: controllers:: krate:: CratePath ;
45use crate :: models:: krate:: OwnerRemoveError ;
56use crate :: models:: {
67 krate:: NewOwnerInvite , token:: EndpointScope , CrateOwner , NewCrateOwnerInvitation ,
7- NewCrateOwnerInvitationOutcome ,
8+ NewCrateOwnerInvitationOutcome , NewTeam ,
89} ;
9- use crate :: models:: { Crate , Owner , Rights , Team , User } ;
10+ use crate :: models:: { Crate , Owner , Team , User } ;
1011use crate :: util:: errors:: { bad_request, crate_not_found, custom, AppResult , BoxedAppError } ;
1112use crate :: views:: EncodableOwner ;
1213use crate :: { app:: AppState , App } ;
@@ -15,12 +16,13 @@ use axum::Json;
1516use axum_extra:: json;
1617use axum_extra:: response:: ErasedJson ;
1718use chrono:: Utc ;
18- use crates_io_github:: GitHubClient ;
19+ use crates_io_github:: { GitHubClient , GitHubError } ;
1920use diesel:: prelude:: * ;
2021use diesel_async:: scoped_futures:: ScopedFutureExt ;
2122use diesel_async:: { AsyncConnection , AsyncPgConnection , RunQueryDsl } ;
2223use http:: request:: Parts ;
2324use http:: StatusCode ;
25+ use oauth2:: AccessToken ;
2426use secrecy:: { ExposeSecret , SecretString } ;
2527use thiserror:: Error ;
2628
@@ -176,7 +178,7 @@ async fn modify_owners(
176178
177179 let owners = krate. owners ( conn) . await ?;
178180
179- match user . rights ( & * app. github , & owners) . await ? {
181+ match Rights :: get ( user , & * app. github , & owners) . await ? {
180182 Rights :: Full => { }
181183 // Yes!
182184 Rights :: Publish => {
@@ -336,8 +338,26 @@ async fn add_team_owner(
336338 krate : & Crate ,
337339 login : & str ,
338340) -> Result < NewOwnerInvite , OwnerAddError > {
341+ // github:rust-lang:owners
342+ let mut chunks = login. split ( ':' ) ;
343+
344+ let team_system = chunks. next ( ) . unwrap ( ) ;
345+ if team_system != "github" {
346+ let error = "unknown organization handler, only 'github:org:team' is supported" ;
347+ return Err ( bad_request ( error) . into ( ) ) ;
348+ }
349+
350+ // unwrap is documented above as part of the calling contract
351+ let org = chunks. next ( ) . unwrap ( ) ;
352+ let team = chunks. next ( ) . ok_or_else ( || {
353+ let error = "missing github team argument; format is github:org:team" ;
354+ bad_request ( error)
355+ } ) ?;
356+
339357 // Always recreate teams to get the most up-to-date GitHub ID
340- let team = Team :: create_or_update ( gh_client, conn, login, req_user) . await ?;
358+ let team =
359+ create_or_update_github_team ( gh_client, conn, & login. to_lowercase ( ) , org, team, req_user)
360+ . await ?;
341361
342362 // Teams are added as owners immediately, since the above call ensures
343363 // the user is a team member.
@@ -352,6 +372,84 @@ async fn add_team_owner(
352372 Ok ( NewOwnerInvite :: Team ( team) )
353373}
354374
375+ /// Tries to create or update a Github Team. Assumes `org` and `team` are
376+ /// correctly parsed out of the full `name`. `name` is passed as a
377+ /// convenience to avoid rebuilding it.
378+ pub async fn create_or_update_github_team (
379+ gh_client : & dyn GitHubClient ,
380+ conn : & mut AsyncPgConnection ,
381+ login : & str ,
382+ org_name : & str ,
383+ team_name : & str ,
384+ req_user : & User ,
385+ ) -> AppResult < Team > {
386+ // GET orgs/:org/teams
387+ // check that `team` is the `slug` in results, and grab its data
388+
389+ // "sanitization"
390+ fn is_allowed_char ( c : char ) -> bool {
391+ matches ! ( c, 'a' ..='z' | 'A' ..='Z' | '0' ..='9' | '-' | '_' )
392+ }
393+
394+ if let Some ( c) = org_name. chars ( ) . find ( |c| !is_allowed_char ( * c) ) {
395+ return Err ( bad_request ( format_args ! (
396+ "organization cannot contain special \
397+ characters like {c}"
398+ ) ) ) ;
399+ }
400+
401+ let token = AccessToken :: new ( req_user. gh_access_token . expose_secret ( ) . to_string ( ) ) ;
402+ let team = gh_client. team_by_name ( org_name, team_name, & token) . await
403+ . map_err ( |_| {
404+ bad_request ( format_args ! (
405+ "could not find the github team {org_name}/{team_name}. \
406+ Make sure that you have the right permissions in GitHub. \
407+ See https://doc.rust-lang.org/cargo/reference/publishing.html#github-permissions"
408+ ) )
409+ } ) ?;
410+
411+ let org_id = team. organization . id ;
412+ let gh_login = & req_user. gh_login ;
413+
414+ let is_team_member = gh_client
415+ . team_membership ( org_id, team. id , gh_login, & token)
416+ . await ?
417+ . is_some_and ( |m| m. is_active ( ) ) ;
418+
419+ let can_add_team =
420+ is_team_member || is_gh_org_owner ( gh_client, org_id, gh_login, & token) . await ?;
421+
422+ if !can_add_team {
423+ return Err ( custom (
424+ StatusCode :: FORBIDDEN ,
425+ "only members of a team or organization owners can add it as an owner" ,
426+ ) ) ;
427+ }
428+
429+ let org = gh_client. org_by_name ( org_name, & token) . await ?;
430+
431+ NewTeam :: builder ( )
432+ . login ( & login. to_lowercase ( ) )
433+ . org_id ( org_id)
434+ . github_id ( team. id )
435+ . maybe_name ( team. name . as_deref ( ) )
436+ . maybe_avatar ( org. avatar_url . as_deref ( ) )
437+ . build ( )
438+ . create_or_update ( conn)
439+ . await
440+ . map_err ( Into :: into)
441+ }
442+
443+ async fn is_gh_org_owner (
444+ gh_client : & dyn GitHubClient ,
445+ org_id : i32 ,
446+ gh_login : & str ,
447+ token : & AccessToken ,
448+ ) -> Result < bool , GitHubError > {
449+ let membership = gh_client. org_membership ( org_id, gh_login, token) . await ?;
450+ Ok ( membership. is_some_and ( |m| m. is_active_admin ( ) ) )
451+ }
452+
355453/// Error results from a [`add_owner()`] model call.
356454#[ derive( Debug , Error ) ]
357455enum OwnerAddError {
0 commit comments