Skip to content

Commit

Permalink
Add HTTP User Detail extension
Browse files Browse the repository at this point in the history
This adds functionality to unFTP that allows unFTP to obtain user
detail over HTTP in addition to the already existing JSON file.

It uses the extact same format as the JSON file functionality. The
'usr-http-url' command line arguments activate this feature. You
pass it a base URL, unFTP appends a username to it and performs
a GET request to the URL. The HTTP server should respond with a
200 OK and JSON body containing an array of users which should
at least contain the requested user's details.

Later on we can support diffent HTTP verbs and sending the
username via an HTTP header instead of the URL path or Post
body. For now I'm just keeping it simple.
  • Loading branch information
hannesdejager committed May 31, 2024
1 parent b76ec5e commit 02c6c4e
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 66 deletions.
17 changes: 9 additions & 8 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ unftp-auth-rest = { version = "0.2.6", optional = true }
unftp-auth-jsonfile = { version = "0.3.4", optional = true }
unftp-sbe-rooter = "0.2.1"
unftp-sbe-restrict = "0.1.2"
url = "2.5.0"

[target.'cfg(unix)'.dependencies]
unftp-auth-pam = { version = "0.2.5", optional = true }
Expand Down
9 changes: 9 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub const REDIS_PORT: &str = "log-redis-port";
pub const ROOT_DIR: &str = "root-dir";
pub const STORAGE_BACKEND_TYPE: &str = "sbe-type";
pub const USR_JSON_PATH: &str = "usr-json-path";
pub const USR_HTTP_URL: &str = "usr-http-url";
pub const VERBOSITY: &str = "verbosity";

#[derive(ArgEnum, Clone, Debug)]
Expand Down Expand Up @@ -502,6 +503,14 @@ pub(crate) fn clap_app(tmp_dir: &str) -> clap::Command {
.env("UNFTP_USR_JSON_PATH")
.takes_value(true),
)
.arg(
Arg::new(USR_HTTP_URL)
.long("usr-http-url")
.value_name("URL")
.help("The URL to fetch user details from via a GET request. The username will be appended to this path.")
.env("UNFTP_USR_HTTP_URL")
.takes_value(true),
)
.arg(
Arg::new(PUBSUB_BASE_URL)
.long("ntf-pubsub-base-url")
Expand Down
16 changes: 8 additions & 8 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::domain::user::{User, UserDetailProvider};
use crate::domain::user::{User, UserDetailError, UserDetailProvider};
use async_trait::async_trait;
use libunftp::auth::{AuthenticationError, Credentials, DefaultUser};

Expand Down Expand Up @@ -32,11 +32,10 @@ impl libunftp::auth::Authenticator<User> for LookupAuthenticator {
) -> Result<User, AuthenticationError> {
self.inner.authenticate(username, creds).await?;
let user_provider = self.usr_detail.as_ref().unwrap();
if let Some(user) = user_provider.provide_user_detail(username) {
Ok(user)
} else {
Ok(User::with_defaults(username))
}
Ok(user_provider
.provide_user_detail(username)
.await
.map_err(|e| AuthenticationError::with_source("error getting user detail", e))?)
}

async fn cert_auth_sufficient(&self, username: &str) -> bool {
Expand All @@ -47,8 +46,9 @@ impl libunftp::auth::Authenticator<User> for LookupAuthenticator {
#[derive(Debug)]
pub struct DefaultUserProvider {}

#[async_trait]
impl UserDetailProvider for DefaultUserProvider {
fn provide_user_detail(&self, username: &str) -> Option<User> {
Some(User::with_defaults(username))
async fn provide_user_detail(&self, username: &str) -> Result<User, UserDetailError> {
Ok(User::with_defaults(username))
}
}
45 changes: 42 additions & 3 deletions src/domain/user.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
//! Contains definitions pertaining to FTP User Detail
use async_trait::async_trait;
use libunftp::auth::UserDetail;
use std::fmt::{Debug, Display, Formatter};
use std::path::PathBuf;
use slog::error;
use std::{
fmt::{Debug, Display, Formatter},
path::PathBuf,
};
use thiserror::Error;
use unftp_sbe_restrict::{UserWithPermissions, VfsOperations};
use unftp_sbe_rooter::UserWithRoot;

Expand Down Expand Up @@ -64,6 +70,39 @@ impl UserWithPermissions for User {

/// Implementation of UserDetailProvider can look up and provide FTP user account details from
/// a source.
#[async_trait]
pub trait UserDetailProvider: Debug {
fn provide_user_detail(&self, username: &str) -> Option<User>;
/// This will do the lookup. An error is returned if the user was not found or something else
/// went wrong.
async fn provide_user_detail(&self, username: &str) -> Result<User, UserDetailError>;
}

/// The error type returned by [`UserDetailProvider`]
#[derive(Debug, Error)]
pub enum UserDetailError {
#[error("{0}")]
Generic(String),
#[error("user '{username:?}' not found")]
UserNotFound { username: String },
#[error("error getting user details: {0}: {1:?}")]
ImplPropagated(
String,
#[source] Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
),
}

impl UserDetailError {
/// Creates a new domain specific error
#[allow(dead_code)]
pub fn new(s: impl Into<String>) -> Self {
UserDetailError::ImplPropagated(s.into(), None)
}

/// Creates a new domain specific error with the given source error.
pub fn with_source<E>(s: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
UserDetailError::ImplPropagated(s.into(), Some(Box::new(source)))
}
}
4 changes: 2 additions & 2 deletions src/infra/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Infra contains infrastructure specific implementations of things in the [`domain`](crate::domain)
//! module.
mod pubsub;
mod workload_identity;

pub mod userdetail_http;
pub mod usrdetail_json;
mod workload_identity;

pub use pubsub::PubsubEventDispatcher;
69 changes: 69 additions & 0 deletions src/infra/userdetail_http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! A libunftp [`UserDetail`](libunftp::auth::user::UserDetail) provider that obtains user detail
//! over HTTP.

use crate::domain::user::{User, UserDetailError, UserDetailProvider};
use crate::infra::usrdetail_json::JsonUserProvider;
use async_trait::async_trait;
use http::{Method, Request};
use hyper::{Body, Client};
use url::form_urlencoded;

/// A libunftp [`UserDetail`](libunftp::auth::user::UserDetail) provider that obtains user detail
/// over HTTP.
#[derive(Debug)]
pub struct HTTPUserDetailProvider {
url: String,
#[allow(dead_code)]
header_name: Option<String>,
}

impl HTTPUserDetailProvider {
/// Creates a provider that will obtain user detail from the specified URL.
pub fn new(url: impl Into<String>) -> HTTPUserDetailProvider {
HTTPUserDetailProvider {
url: url.into(),
header_name: None,
}
}
}

impl Default for HTTPUserDetailProvider {
fn default() -> Self {
HTTPUserDetailProvider {
url: "http://localhost:8080/users/".to_string(),
header_name: None,
}
}
}

#[async_trait]
impl UserDetailProvider for HTTPUserDetailProvider {
async fn provide_user_detail(&self, username: &str) -> Result<User, UserDetailError> {
let _url_suffix: String = form_urlencoded::byte_serialize(username.as_bytes()).collect();
let req = Request::builder()
.method(Method::GET)
.header("Content-type", "application/json")
.uri(format!("{}{}", self.url, username))
.body(Body::empty())
.map_err(|e| UserDetailError::with_source("error creating request", e))?;

let client = Client::new();

let resp = client
.request(req)
.await
.map_err(|e| UserDetailError::with_source("error doing HTTP request", e))?;

let body_bytes = hyper::body::to_bytes(resp.into_body())
.await
.map_err(|e| UserDetailError::with_source("error parsing body", e))?;

let json_str = std::str::from_utf8(body_bytes.as_ref())
.map_err(|e| UserDetailError::with_source("body is not a valid UTF string", e))?;

let json_usr_provider =
JsonUserProvider::from_json(json_str).map_err(UserDetailError::Generic)?;

json_usr_provider.provide_user_detail(username).await
}
}
82 changes: 45 additions & 37 deletions src/infra/usrdetail_json.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::domain::user::{User, UserDetailProvider};
use crate::domain::user::{User, UserDetailError, UserDetailProvider};
use async_trait::async_trait;
use serde::Deserialize;
use std::path::PathBuf;
use unftp_sbe_restrict::VfsOperations;
Expand Down Expand Up @@ -28,42 +29,49 @@ impl JsonUserProvider {
}
}

#[async_trait]
impl UserDetailProvider for JsonUserProvider {
fn provide_user_detail(&self, username: &str) -> Option<User> {
self.users.iter().find(|u| u.username == username).map(|u| {
let u = u.clone();
User {
username: u.username,
name: u.name,
surname: u.surname,
account_enabled: u.account_enabled.unwrap_or(true),
vfs_permissions: u.vfs_perms.map_or(VfsOperations::all(), |p| {
p.iter()
.fold(VfsOperations::all(), |ops, s| match s.as_str() {
"none" => VfsOperations::empty(),
"all" => VfsOperations::all(),
"-mkdir" => ops - VfsOperations::MK_DIR,
"-rmdir" => ops - VfsOperations::RM_DIR,
"-del" => ops - VfsOperations::DEL,
"-ren" => ops - VfsOperations::RENAME,
"-md5" => ops - VfsOperations::MD5,
"-get" => ops - VfsOperations::GET,
"-put" => ops - VfsOperations::PUT,
"-list" => ops - VfsOperations::LIST,
"+mkdir" => ops | VfsOperations::MK_DIR,
"+rmdir" => ops | VfsOperations::RM_DIR,
"+del" => ops | VfsOperations::DEL,
"+ren" => ops | VfsOperations::RENAME,
"+md5" => ops | VfsOperations::MD5,
"+get" => ops | VfsOperations::GET,
"+put" => ops | VfsOperations::PUT,
"+list" => ops | VfsOperations::LIST,
_ => ops,
})
}),
allowed_mime_types: None,
root: u.root.map(PathBuf::from),
}
})
async fn provide_user_detail(&self, username: &str) -> Result<User, UserDetailError> {
self.users
.iter()
.find(|u| u.username == username)
.ok_or(UserDetailError::UserNotFound {
username: String::from(username),
})
.map(|u| {
let u = u.clone();
User {
username: u.username,
name: u.name,
surname: u.surname,
account_enabled: u.account_enabled.unwrap_or(true),
vfs_permissions: u.vfs_perms.map_or(VfsOperations::all(), |p| {
p.iter()
.fold(VfsOperations::all(), |ops, s| match s.as_str() {
"none" => VfsOperations::empty(),
"all" => VfsOperations::all(),
"-mkdir" => ops - VfsOperations::MK_DIR,
"-rmdir" => ops - VfsOperations::RM_DIR,
"-del" => ops - VfsOperations::DEL,
"-ren" => ops - VfsOperations::RENAME,
"-md5" => ops - VfsOperations::MD5,
"-get" => ops - VfsOperations::GET,
"-put" => ops - VfsOperations::PUT,
"-list" => ops - VfsOperations::LIST,
"+mkdir" => ops | VfsOperations::MK_DIR,
"+rmdir" => ops | VfsOperations::RM_DIR,
"+del" => ops | VfsOperations::DEL,
"+ren" => ops | VfsOperations::RENAME,
"+md5" => ops | VfsOperations::MD5,
"+get" => ops | VfsOperations::GET,
"+put" => ops | VfsOperations::PUT,
"+list" => ops | VfsOperations::LIST,
_ => ops,
})
}),
allowed_mime_types: None,
root: u.root.map(PathBuf::from),
}
})
}
}
30 changes: 22 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod metrics;
mod notify;
mod storage;

use crate::infra::userdetail_http::HTTPUserDetailProvider;
use crate::{
app::libunftp_version, args::FtpsClientAuthType, auth::DefaultUserProvider, notify::FTPListener,
};
Expand Down Expand Up @@ -99,14 +100,27 @@ fn make_auth(
Some("json") => make_json_auth(m),
unknown_type => Err(format!("unknown auth type: {}", unknown_type.unwrap())),
}?;
auth.set_usr_detail(match m.value_of(args::USR_JSON_PATH) {
Some(path) => {
let json: String = load_user_file(path)
.map_err(|e| format!("could not load user file '{}': {}", path, e))?;
Box::new(JsonUserProvider::from_json(json.as_str())?)
}
None => Box::new(DefaultUserProvider {}),
});
auth.set_usr_detail(
match (
m.value_of(args::USR_JSON_PATH),
m.value_of(args::USR_HTTP_URL),
) {
(Some(path), None) => {
let json: String = load_user_file(path)
.map_err(|e| format!("could not load user file '{}': {}", path, e))?;
Box::new(JsonUserProvider::from_json(json.as_str())?)
}
(None, Some(url)) => Box::new(HTTPUserDetailProvider::new(url)),
(None, None) => Box::new(DefaultUserProvider {}),
_ => {
return Err(format!(
"please specify either '{}' or '{}' but not both",
args::USR_JSON_PATH,
args::USR_HTTP_URL
))
}
},
);
Ok(Arc::new(auth))
}

Expand Down

0 comments on commit 02c6c4e

Please sign in to comment.