Skip to content
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

Implement org membership based auth #147

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Prev Previous commit
Next Next commit
Clarify logic and misc upkeep changes
  • Loading branch information
magnalite committed Jun 14, 2023
commit 49fee58cc1e067f9b3916fda4d09dd42ecbf2681
5 changes: 2 additions & 3 deletions src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,8 @@ fn prompt_github_auth(api: url::Url, github_oauth_id: &str) -> anyhow::Result<()
"client_id": github_oauth_id,
"scope": "read:user read:org",
}))
.send()?;

let device_code_response = device_code_response.json::<DeviceCodeResponse>()?;
.send()?
.json::<DeviceCodeResponse>()?;

println!();
println!("Go to {}", device_code_response.verification_uri);
Expand Down
180 changes: 88 additions & 92 deletions wally-registry-backend/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::fmt;

use anyhow::format_err;
use anyhow::{format_err, Context};
use constant_time_eq::constant_time_eq;
use libwally::{package_id::PackageId, package_index::PackageIndex};
use reqwest::Client;
Expand All @@ -23,35 +23,35 @@ pub enum AuthMode {
Unauthenticated,
}

#[derive(Deserialize, Clone, Debug)]
pub struct GithubInfo {
#[derive(Deserialize)]
pub struct GithubOrgInfo {
login: String,
id: u64,
}

#[derive(Deserialize, Debug)]
pub struct GithubOrgInfoOrganization {
login: String,
#[derive(Deserialize)]
pub struct GithubOrgInfoResponse {
organization: GithubOrgInfo,
}

#[derive(Deserialize, Debug)]
pub struct GithubOrgInfo {
organization: GithubOrgInfoOrganization,
#[derive(Deserialize)]
pub struct GithubUserInfo {
login: String,
id: u64,
}

#[derive(Debug)]
pub struct GithubWriteAccessInfo {
pub user: GithubInfo,
pub token: String,
#[derive(Deserialize)]
pub struct GithubInfo {
user: GithubUserInfo,
orgs: Vec<String>,
}

impl GithubInfo {
pub fn login(&self) -> &str {
&self.login
&self.user.login
}

pub fn id(&self) -> &u64 {
&self.id
&self.user.id
}
}

Expand Down Expand Up @@ -95,65 +95,68 @@ async fn verify_github_token(request: &Request<'_>) -> Outcome<WriteAccess, Erro
}
};

let orgs = get_github_orgs(&token).await.unwrap_or_else(|err| {
eprintln!("{:?}", err);
Vec::new()
});

let user_info = get_github_user_info(&token)
.await
.map(|user| GithubInfo { user, orgs });

match user_info {
Err(err) => format_err!("Github auth failed: {}", err)
.status(Status::Unauthorized)
.into(),
Ok(info) => Outcome::Success(WriteAccess::Github(info)),
}
}

async fn get_github_user_info(token: &str) -> anyhow::Result<GithubUserInfo> {
let client = Client::new();

// The user is in no orgs we can see so we cannot get their userinfo from that.
// Users already logged in may not have given us read:org permission so we
// need to still support a basic read:user check.
// See: https://github.com/UpliftGames/wally/pull/147
// TODO: Eventually we can transition to only using org level oauth
let response = client
.get("https://api.github.com/user")
.header("accept", "application/json")
.header("user-agent", "wally")
.bearer_auth(&token)
.bearer_auth(token)
.send()
.await;

let github_info = match response {
Err(err) => {
return format_err!(err).status(Status::InternalServerError).into();
}
Ok(response) => response.json::<GithubInfo>().await,
};
.await
.context("Github user info request failed!")?;

match github_info {
Err(err) => format_err!("Github auth failed: {}", err)
.status(Status::Unauthorized)
.into(),
Ok(github_info) => {
return Outcome::Success(WriteAccess::Github(GithubWriteAccessInfo {
user: github_info,
token: token,
}));
}
}
response
.json::<GithubUserInfo>()
.await
.context("Failed to parse github user info")
}

pub async fn get_github_orgs(token: String) -> Result<Vec<String>, Error> {
pub async fn get_github_orgs(token: &str) -> Result<Vec<String>, Error> {
let client = Client::new();

let org_response = client
.get("https://api.github.com/user/memberships/orgs")
MobiusCraftFlip marked this conversation as resolved.
Show resolved Hide resolved
.header("accept", "application/json")
.header("user-agent", "wally")
.bearer_auth(&token)
.bearer_auth(token)
.send()
.await;
.await
.context("Github org membership request failed")?;

let github_org_info = match org_response {
Err(err) => {
return Err(format_err!(err).status(Status::InternalServerError));
}
Ok(response) => response.json::<Vec<GithubOrgInfo>>().await,
};
let github_org_info = org_response
.json::<Vec<GithubOrgInfoResponse>>()
.await
.context("Failed to parse github org membership")?;

match github_org_info {
Ok(github_org_info) => match github_org_info.get(0) {
Some(_) => Ok(github_org_info
.iter()
.map(|x| x.organization.login.to_lowercase())
.collect::<Vec<_>>()),
None => Ok(vec![]),
},
Err(err) => Err(format_err!("Github auth failed: {}", err).status(Status::Unauthorized)),
}
let orgs: Vec<_> = github_org_info
.iter()
.map(|org_info| org_info.organization.login.to_lowercase())
.collect();

Ok(orgs)
}

pub enum ReadAccess {
Expand Down Expand Up @@ -185,11 +188,13 @@ impl<'r> FromRequest<'r> for ReadAccess {

pub enum WriteAccess {
ApiKey,
Github(GithubWriteAccessInfo),
Github(GithubInfo),
}

pub enum WritePermission {
Default,
Owner,
User,
Org,
}

Expand All @@ -198,46 +203,37 @@ impl WriteAccess {
&self,
package_id: &PackageId,
index: &PackageIndex,
) -> Result<Option<WritePermission>, Error> {
) -> anyhow::Result<Option<WritePermission>> {
let scope = package_id.name().scope();

let write_permission = match self {
WriteAccess::ApiKey => Some(WritePermission::Default),
WriteAccess::Github(github_info) => {
match index.is_scope_owner(scope, github_info.user.id())? {
true => Some(WritePermission::Default),
// Only grant write access if the username matches the scope AND the scope has no existing owners or they are a member of the org
false => {
if github_info.user.login().to_lowercase() == scope
&& index.get_scope_owners(scope)?.is_empty()
{
Some(WritePermission::Default)
} else {
let orgs = get_github_orgs(github_info.token.clone()).await;
match orgs {
Ok(orgs) => {
if orgs.contains(&scope.to_string()) {
Some(WritePermission::Org)
} else {
None
}
},
Err(err) => {
return Err(format_err!("Failed to get Github Organisations, do you need to re-login. Error: {:?}", err)
.status(Status::Unauthorized)
.into())
},
}
}
}
}
}
};

Ok(write_permission)
match self {
WriteAccess::ApiKey => Ok(Some(WritePermission::Default)),
WriteAccess::Github(info) => github_write_permission_for_scope(info, scope, index),
}
}
}

fn github_write_permission_for_scope(
info: &GithubInfo,
scope: &str,
index: &PackageIndex,
) -> anyhow::Result<Option<WritePermission>> {
Ok(match index.is_scope_owner(scope, info.id())? {
true => Some(WritePermission::Owner),
false => {
// Only grant write access if the username matches the scope AND the scope has no existing owners
if info.login().to_lowercase() == scope && index.get_scope_owners(scope)?.is_empty() {
Some(WritePermission::User)
// ... or if they are in the organization!
} else if info.orgs.contains(&scope.to_string()) {
Some(WritePermission::Org)
} else {
None
}
}
})
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for WriteAccess {
type Error = Error;
Expand Down
23 changes: 12 additions & 11 deletions wally-registry-backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ async fn publish(
let manifest = get_manifest(&mut archive).status(Status::BadRequest)?;
let package_id = manifest.package_id();

let write_permission = authorization.can_write_package(&package_id, &index).await?;
let write_permission = authorization.can_write_package(&package_id, index).await?;

if write_permission.is_none() {
return Err(format_err!(
Expand All @@ -167,18 +167,19 @@ async fn publish(
}

// If a user can write but isn't in the scope owner file then we should add them!
if let WriteAccess::Github(github_info) = authorization {
let user_id = github_info.user.id();
if let WriteAccess::Github(github) = authorization {
let user_id = github.id();
let scope = package_id.name().scope();

match write_permission.unwrap() {
WritePermission::Default => {
if !index.is_scope_owner(scope, user_id)? {
index.add_scope_owner(scope, user_id)?;
}
// However we should only do this if they are the user matching this scope!
// If they have permission due to being a member of an org we want to leave
// the permission up to the org membership so if they are removed from the
// org they automatically lose write permission.
if let WritePermission::User = write_permission.unwrap() {
if !index.is_scope_owner(scope, user_id)? {
index.add_scope_owner(scope, user_id)?;
}
_ => {}
};
}
}

let package_metadata = index.get_package_metadata(manifest.package_id().name());
Expand All @@ -203,7 +204,7 @@ async fn publish(
if let Ok(mut search_backend) = search_backend.try_write() {
// TODO: Recrawling the whole index for each publish is very wasteful!
// Eventually this will get too expensive and we should only add the new package.
search_backend.crawl_packages(&index)?;
search_backend.crawl_packages(index)?;
}

Ok(Json(json!({
Expand Down