Skip to content

Commit

Permalink
feat: api endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
christos-h authored Mar 11, 2022
1 parent 999a879 commit 5a13669
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 33 deletions.
139 changes: 118 additions & 21 deletions api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io::Write;
use std::path::PathBuf;
use std::sync::RwLock;
use rand::Rng;
use rocket::form::validate::Contains;
use lib::DeploymentApiError;

#[derive(Debug, PartialEq, Hash, Eq, Deserialize, Serialize)]
#[derive(Debug, PartialEq, Hash, Eq, Deserialize, Serialize, Responder)]
pub struct ApiKey(String);

/// Parses an authorization header string into an ApiKey
Expand All @@ -17,19 +22,19 @@ impl TryFrom<Option<&str>> for ApiKey {

fn try_from(s: Option<&str>) -> Result<Self, Self::Error> {
match s {
None => Err(AuthorizationError::Missing),
None => Err(AuthorizationError::Missing(())),
Some(s) => {
let parts: Vec<&str> = s.split(' ').collect();
if parts.len() != 2 {
return Err(AuthorizationError::Malformed);
return Err(AuthorizationError::Malformed(()));
}
// unwrap ok because of explicit check above
let key = *parts.get(1).unwrap();
// comes in base64 encoded
let decoded_bytes =
base64::decode(key).map_err(|_| AuthorizationError::Malformed)?;
base64::decode(key).map_err(|_| AuthorizationError::Malformed(()))?;
let mut decoded_string =
String::from_utf8(decoded_bytes).map_err(|_| AuthorizationError::Malformed)?;
String::from_utf8(decoded_bytes).map_err(|_| AuthorizationError::Malformed(()))?;
// remove colon at the end
decoded_string.pop();
Ok(ApiKey(decoded_string))
Expand All @@ -38,20 +43,29 @@ impl TryFrom<Option<&str>> for ApiKey {
}
}

#[derive(Debug)]
/// A broad class of authorization errors.
/// The empty tuples here are needed by `Responder`.
#[derive(Debug, Responder)]
#[allow(dead_code)]
#[response(content_type = "json")]
pub enum AuthorizationError {
Missing,
Malformed,
Unauthorized,
#[response(status = 400)]
Missing(()),
#[response(status = 400)]
Malformed(()),
#[response(status = 401)]
Unauthorized(()),
#[response(status = 409)]
AlreadyExists(()),
}

impl Display for AuthorizationError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
AuthorizationError::Missing => write!(f, "API key is missing"),
AuthorizationError::Malformed => write!(f, "API key is malformed"),
AuthorizationError::Unauthorized => write!(f, "API key is unauthorized"),
AuthorizationError::Missing(_) => write!(f, "API key is missing"),
AuthorizationError::Malformed(_) => write!(f, "API key is malformed"),
AuthorizationError::Unauthorized(_) => write!(f, "API key is unauthorized"),
AuthorizationError::AlreadyExists(_) => write!(f, "username already exists"),
}
}
}
Expand All @@ -71,7 +85,7 @@ impl<'r> FromRequest<'r> for User {
match USER_DIRECTORY.user_for_api_key(&api_key) {
None => {
log::warn!("authorization failure for api key {:?}", &api_key);
Outcome::Failure((Status::Unauthorized, AuthorizationError::Unauthorized))
Outcome::Failure((Status::Unauthorized, AuthorizationError::Unauthorized(())))
}
Some(user) => Outcome::Success(user),
}
Expand All @@ -81,30 +95,113 @@ impl<'r> FromRequest<'r> for User {
#[derive(Clone, Deserialize, Serialize, Debug)]
pub(crate) struct User {
pub(crate) name: String,
pub(crate) project_name: String,
pub(crate) projects: Vec<String>,
}

lazy_static! {
static ref USER_DIRECTORY: UserDirectory = UserDirectory::from_user_file();
pub(crate) static ref USER_DIRECTORY: UserDirectory = UserDirectory::from_user_file();
}

#[derive(Debug)]
struct UserDirectory {
users: HashMap<String, User>,
pub(crate) struct UserDirectory {
users: RwLock<HashMap<String, User>>,
}

impl UserDirectory {
/// Validates if a user owns an existing project, if not:
/// - first there is a check to see if this project exists globally, if yes
/// will return an error since the project does not belong to the current user
/// - if not, will create the project for the user
/// Finally saves `users` state to `users.toml`.
pub(crate) fn validate_or_create_project(&self, user: &User, project_name: &String) -> Result<(), DeploymentApiError> {
if user.projects.contains(project_name) {
return Ok(());
}

let mut users = self.users.write().unwrap();

let project_for_name = users.values()
.flat_map(|users| &users.projects)
.find(|project| project == &project_name);

if project_for_name.is_some() {
return Err(DeploymentApiError::ProjectAlreadyExists(
format!("project with name `{}` already exists", project_name)
));
}

// at this point we know that the user does not have this project
// and that another user does not have it
let user = users.values_mut()
.find(|u| u.name == user.name)
.ok_or(DeploymentApiError::Internal(
"there was an issue getting the user credentials while validating the project".to_string()
)
)?;

user.projects.push(project_name.clone());

self.save(&*users);

Ok(())
}

/// Creates a new user and returns the user's corresponding API Key.
/// If the user exists, will error.
/// Finally saves `users` state to `users.toml`.
pub(crate) fn create_user(&self, username: String) -> Result<ApiKey, AuthorizationError> {
let mut users = self.users.write().unwrap();

for user in users.values() {
if user.name == username {
return Err(AuthorizationError::AlreadyExists(()));
}
}

let api_key: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(16)
.map(char::from)
.collect();

let user = User {
name: username,
projects: vec![],
};

users.insert(api_key.clone(), user);

self.save(&*users);

Ok(ApiKey(api_key))
}

/// Overwrites users.toml with a new `HashMap<String, User>`
fn save(&self, users: &HashMap<String, User>) {
// Save the config
let mut users_file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(Self::users_toml_file_path())
.unwrap();

write!(users_file, "{}", toml::to_string_pretty(&*users).unwrap())
.expect("could not write contents to users.toml");
}

fn user_for_api_key(&self, api_key: &ApiKey) -> Option<User> {
self.users.get(&api_key.0).cloned()
self.users.read().unwrap().get(&api_key.0).cloned()
}

fn from_user_file() -> Self {
let file_path = Self::users_toml_file_path();
let file_contents: String = std::fs::read_to_string(&file_path)
.expect(&format!("this should blow up if the users.toml file is not present at {:?}", &file_path));
let users = toml::from_str(&file_contents)
.expect("this should blow up if the users.toml file is unparseable");
let directory = Self {
users: toml::from_str(&file_contents)
.expect("this should blow up if the users.toml file is unparseable"),
users: RwLock::new(users),
};

log::debug!("initialising user directory: {:#?}", &directory);
Expand All @@ -118,7 +215,7 @@ impl UserDirectory {
Err(_) => {
log::debug!("could not find environment variable `UNVEIL_USERS_TOML`, defaulting to MANIFEST_DIR");
let manifest_path: PathBuf = env!("CARGO_MANIFEST_DIR").into();
manifest_path.join("users.toml")
manifest_path.join("users.toml")
}
}
}
Expand Down
20 changes: 15 additions & 5 deletions api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@ use uuid::Uuid;


use crate::args::Args;
use crate::auth::User;
use crate::auth::{ApiKey, AuthorizationError, User, USER_DIRECTORY};
use crate::build::{BuildSystem, FsBuildSystem};
use crate::deployment::DeploymentSystem;

type ApiResult<T, E> = Result<Json<T>, E>;


/// Creates a user if the username is available and returns the corresponding
/// API key.
/// Returns an error if the user already exists.
#[post("/users/<username>")]
async fn create_user(username: String) -> Result<ApiKey, AuthorizationError> {
USER_DIRECTORY.create_user(username)
}

/// Status API to be used to check if the service is alive
#[get("/status")]
async fn status() {}
Expand Down Expand Up @@ -98,7 +107,7 @@ async fn create_project(
project: ProjectConfig,
user: User,
) -> ApiResult<DeploymentMeta, DeploymentApiError> {
validate_user_for_project(&user, project.name())?;
USER_DIRECTORY.validate_or_create_project(&user, project.name())?;

let deployment = state
.deployment_manager
Expand All @@ -107,8 +116,8 @@ async fn create_project(
Ok(Json(deployment))
}

fn validate_user_for_project(user: &User, project_name: &str) -> Result<(), DeploymentApiError> {
if project_name != user.project_name {
fn validate_user_for_project(user: &User, project_name: &String) -> Result<(), DeploymentApiError> {
if !user.projects.contains(project_name) {
log::warn!(
"failed to authenticate user {:?} for project `{}`",
&user,
Expand All @@ -127,7 +136,7 @@ fn validate_user_for_deployment(
user: &User,
meta: &DeploymentMeta,
) -> Result<(), DeploymentApiError> {
if meta.config.name() != &user.project_name {
if !user.projects.contains(meta.config.name()) {
log::warn!(
"failed to authenticate user {:?} for deployment `{}`",
&user,
Expand Down Expand Up @@ -177,6 +186,7 @@ async fn rocket() -> _ {
delete_project,
create_project,
get_project,
create_user,
status
],
)
Expand Down
13 changes: 8 additions & 5 deletions api/users.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# api key / user mapping are defined like this.

[my_api_key]
name = "test_user"
project_name = "hello-world-rocket-app"
name = 'test_user'
projects = [
'hello-world-rocket-app',
'postgres-rocket-app',
]



[ci-test]
name = "ci"
project_name = "hello-world-rocket-app"
projects = ["hello-world-rocket-app"]
4 changes: 2 additions & 2 deletions examples/rocket/hello-world/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ pub enum DeploymentApiError {
NotFound(String),
#[response(status = 400)]
BadRequest(String),
#[response(status = 409)]
ProjectAlreadyExists(String)
}

impl Display for DeploymentApiError {
Expand All @@ -154,6 +156,7 @@ impl Display for DeploymentApiError {
DeploymentApiError::Internal(s) => write!(f, "internal: {}", s),
DeploymentApiError::NotFound(s) => write!(f, "not found: {}", s),
DeploymentApiError::BadRequest(s) => write!(f, "bad request: {}", s),
DeploymentApiError::ProjectAlreadyExists(s) => write!(f, "conflict: {}", s)
}
}
}
Expand Down

0 comments on commit 5a13669

Please sign in to comment.