diff --git a/Cargo.lock b/Cargo.lock index 780c51d..ec3b181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -683,9 +683,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] @@ -1039,9 +1039,9 @@ dependencies = [ [[package]] name = "idna" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", @@ -2743,6 +2743,7 @@ dependencies = [ "unftp-sbe-gcs", "unftp-sbe-restrict", "unftp-sbe-rooter", + "url", ] [[package]] @@ -2879,9 +2880,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.8" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" @@ -2912,9 +2913,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index 98247d9..e77ada5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/src/args.rs b/src/args.rs index 1dba43d..981dc2c 100644 --- a/src/args.rs +++ b/src/args.rs @@ -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)] @@ -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") diff --git a/src/auth.rs b/src/auth.rs index 1fbdd53..e312306 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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}; @@ -32,11 +32,10 @@ impl libunftp::auth::Authenticator for LookupAuthenticator { ) -> Result { 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 { @@ -47,8 +46,9 @@ impl libunftp::auth::Authenticator for LookupAuthenticator { #[derive(Debug)] pub struct DefaultUserProvider {} +#[async_trait] impl UserDetailProvider for DefaultUserProvider { - fn provide_user_detail(&self, username: &str) -> Option { - Some(User::with_defaults(username)) + async fn provide_user_detail(&self, username: &str) -> Result { + Ok(User::with_defaults(username)) } } diff --git a/src/domain/user.rs b/src/domain/user.rs index 44c6a1d..27c2188 100644 --- a/src/domain/user.rs +++ b/src/domain/user.rs @@ -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; @@ -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; + /// 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; +} + +/// 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>, + ), +} + +impl UserDetailError { + /// Creates a new domain specific error + #[allow(dead_code)] + pub fn new(s: impl Into) -> Self { + UserDetailError::ImplPropagated(s.into(), None) + } + + /// Creates a new domain specific error with the given source error. + pub fn with_source(s: impl Into, source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + UserDetailError::ImplPropagated(s.into(), Some(Box::new(source))) + } } diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 87c86d8..0d82c55 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -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; diff --git a/src/infra/userdetail_http.rs b/src/infra/userdetail_http.rs new file mode 100644 index 0000000..0dd2070 --- /dev/null +++ b/src/infra/userdetail_http.rs @@ -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, +} + +impl HTTPUserDetailProvider { + /// Creates a provider that will obtain user detail from the specified URL. + pub fn new(url: impl Into) -> 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 { + 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 + } +} diff --git a/src/infra/usrdetail_json.rs b/src/infra/usrdetail_json.rs index 77e1921..38e1d5a 100644 --- a/src/infra/usrdetail_json.rs +++ b/src/infra/usrdetail_json.rs @@ -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; @@ -28,42 +29,49 @@ impl JsonUserProvider { } } +#[async_trait] impl UserDetailProvider for JsonUserProvider { - fn provide_user_detail(&self, username: &str) -> Option { - 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 { + 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), + } + }) } } diff --git a/src/main.rs b/src/main.rs index 16a17b4..f412ccc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, }; @@ -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)) }