diff --git a/warpgate-admin/src/api/mod.rs b/warpgate-admin/src/api/mod.rs index 8f2479d..75c43a0 100644 --- a/warpgate-admin/src/api/mod.rs +++ b/warpgate-admin/src/api/mod.rs @@ -12,19 +12,17 @@ mod ssh_keys; mod targets; mod tickets_detail; mod tickets_list; -mod users_list; +mod users; pub fn get() -> impl OpenApi { ( sessions_list::Api, sessions_detail::Api, recordings_detail::Api, - users_list::Api, roles::ListApi, roles::DetailApi, - targets::ListApi, - targets::DetailApi, - targets::RolesApi, + (targets::ListApi, targets::DetailApi, targets::RolesApi), + (users::ListApi, users::DetailApi, users::RolesApi), tickets_list::Api, tickets_detail::Api, known_hosts_list::Api, diff --git a/warpgate-admin/src/api/users.rs b/warpgate-admin/src/api/users.rs new file mode 100644 index 0000000..19d6969 --- /dev/null +++ b/warpgate-admin/src/api/users.rs @@ -0,0 +1,328 @@ +use std::sync::Arc; + +use poem::web::Data; +use poem_openapi::param::Path; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Object, OpenApi}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, + QueryOrder, Set, +}; +use tokio::sync::Mutex; +use uuid::Uuid; +use warpgate_common::{ + Role as RoleConfig, User as UserConfig, UserAuthCredential, UserRequireCredentialsPolicy, + WarpgateError, +}; +use warpgate_db_entities::{Role, User, UserRoleAssignment}; + +#[derive(Object)] +struct UserDataRequest { + username: String, + credentials: Vec, + credential_policy: Option, +} + +#[derive(ApiResponse)] +enum GetUsersResponse { + #[oai(status = 200)] + Ok(Json>), +} +#[derive(ApiResponse)] +enum CreateUserResponse { + #[oai(status = 201)] + Created(Json), + + #[oai(status = 400)] + BadRequest(Json), +} + +pub struct ListApi; + +#[OpenApi] +impl ListApi { + #[oai(path = "/users", method = "get", operation_id = "get_users")] + async fn api_get_all_users( + &self, + db: Data<&Arc>>, + ) -> poem::Result { + let db = db.lock().await; + + let users = User::Entity::find() + .order_by_asc(User::Column::Username) + .all(&*db) + .await + .map_err(WarpgateError::from)?; + + let users: Result, _> = users.into_iter().map(|t| t.try_into()).collect(); + let users = users.map_err(WarpgateError::from)?; + + Ok(GetUsersResponse::Ok(Json(users))) + } + + #[oai(path = "/users", method = "post", operation_id = "create_user")] + async fn api_create_user( + &self, + db: Data<&Arc>>, + body: Json, + ) -> poem::Result { + if body.username.is_empty() { + return Ok(CreateUserResponse::BadRequest(Json("name".into()))); + } + + let db = db.lock().await; + + let values = User::ActiveModel { + id: Set(Uuid::new_v4()), + username: Set(body.username.clone()), + credentials: Set( + serde_json::to_value(body.credentials.clone()).map_err(WarpgateError::from)? + ), + credential_policy: Set(serde_json::to_value(body.credential_policy.clone()) + .map_err(WarpgateError::from)?), + }; + + let user = values.insert(&*db).await.map_err(WarpgateError::from)?; + + Ok(CreateUserResponse::Created(Json( + user.try_into().map_err(WarpgateError::from)?, + ))) + } +} + +#[derive(ApiResponse)] +enum GetUserResponse { + #[oai(status = 200)] + Ok(Json), + #[oai(status = 404)] + NotFound, +} + +#[derive(ApiResponse)] +enum UpdateUserResponse { + #[oai(status = 200)] + Ok(Json), + #[oai(status = 400)] + BadRequest, + #[oai(status = 404)] + NotFound, +} + +#[derive(ApiResponse)] +enum DeleteUserResponse { + #[oai(status = 204)] + Deleted, + + #[oai(status = 404)] + NotFound, +} + +pub struct DetailApi; + +#[OpenApi] +impl DetailApi { + #[oai(path = "/users/:id", method = "get", operation_id = "get_user")] + async fn api_get_user( + &self, + db: Data<&Arc>>, + id: Path, + ) -> poem::Result { + let db = db.lock().await; + + let Some(user) = User::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)? else { + return Ok(GetUserResponse::NotFound); + }; + + Ok(GetUserResponse::Ok(Json( + user.try_into().map_err(poem::error::InternalServerError)?, + ))) + } + + #[oai(path = "/users/:id", method = "put", operation_id = "update_user")] + async fn api_update_user( + &self, + db: Data<&Arc>>, + body: Json, + id: Path, + ) -> poem::Result { + let db = db.lock().await; + + let Some(user) = User::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)? else { + return Ok(UpdateUserResponse::NotFound); + }; + + let mut model: User::ActiveModel = user.into(); + model.username = Set(body.username.clone()); + model.credentials = + Set(serde_json::to_value(body.credentials.clone()).map_err(WarpgateError::from)?); + model.credential_policy = + Set(serde_json::to_value(body.credential_policy.clone()) + .map_err(WarpgateError::from)?); + let user = model + .update(&*db) + .await + .map_err(poem::error::InternalServerError)?; + + Ok(UpdateUserResponse::Ok(Json( + user.try_into().map_err(WarpgateError::from)?, + ))) + } + + #[oai(path = "/users/:id", method = "delete", operation_id = "delete_user")] + async fn api_delete_user( + &self, + db: Data<&Arc>>, + id: Path, + ) -> poem::Result { + let db = db.lock().await; + + let Some(user) = User::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)? else { + return Ok(DeleteUserResponse::NotFound); + }; + + user.delete(&*db) + .await + .map_err(poem::error::InternalServerError)?; + Ok(DeleteUserResponse::Deleted) + } +} + +#[derive(ApiResponse)] +enum GetUserRolesResponse { + #[oai(status = 200)] + Ok(Json>), + #[oai(status = 404)] + NotFound, +} + +#[derive(ApiResponse)] +enum AddUserRoleResponse { + #[oai(status = 201)] + Created, + #[oai(status = 409)] + AlreadyExists, +} + +#[derive(ApiResponse)] +enum DeleteUserRoleResponse { + #[oai(status = 204)] + Deleted, + #[oai(status = 404)] + NotFound, +} + +pub struct RolesApi; + +#[OpenApi] +impl RolesApi { + #[oai( + path = "/users/:id/roles", + method = "get", + operation_id = "get_user_roles" + )] + async fn api_get_user_roles( + &self, + db: Data<&Arc>>, + id: Path, + ) -> poem::Result { + let db = db.lock().await; + + let Some((_, roles)) = User::Entity::find_by_id(*id) + .find_with_related(Role::Entity) + .all(&*db) + .await + .map(|x| x.into_iter().next()) + .map_err(WarpgateError::from)? else { + return Ok(GetUserRolesResponse::NotFound) + }; + + Ok(GetUserRolesResponse::Ok(Json( + roles.into_iter().map(|x| x.into()).collect(), + ))) + } + + #[oai( + path = "/users/:id/roles/:role_id", + method = "post", + operation_id = "add_user_role" + )] + async fn api_add_user_role( + &self, + db: Data<&Arc>>, + id: Path, + role_id: Path, + ) -> poem::Result { + let db = db.lock().await; + + if !UserRoleAssignment::Entity::find() + .filter(UserRoleAssignment::Column::UserId.eq(id.0.clone())) + .filter(UserRoleAssignment::Column::RoleId.eq(role_id.0.clone())) + .all(&*db) + .await + .map_err(WarpgateError::from)? + .is_empty() + { + return Ok(AddUserRoleResponse::AlreadyExists); + } + + let values = UserRoleAssignment::ActiveModel { + user_id: Set(id.0), + role_id: Set(role_id.0), + ..Default::default() + }; + + values.insert(&*db).await.map_err(WarpgateError::from)?; + + Ok(AddUserRoleResponse::Created) + } + + #[oai( + path = "/users/:id/roles/:role_id", + method = "delete", + operation_id = "delete_user_role" + )] + async fn api_delete_user_role( + &self, + db: Data<&Arc>>, + id: Path, + role_id: Path, + ) -> poem::Result { + let db = db.lock().await; + + let Some(user) = User::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)? else { + return Ok(DeleteUserRoleResponse::NotFound); + }; + + let Some(role) = Role::Entity::find_by_id(role_id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)? else { + return Ok(DeleteUserRoleResponse::NotFound); + }; + + let Some(model) = UserRoleAssignment::Entity::find() + .filter(UserRoleAssignment::Column::UserId.eq(id.0)) + .filter(UserRoleAssignment::Column::RoleId.eq(role_id.0)) + .one(&*db) + .await + .map_err(WarpgateError::from)? else { + return Ok(DeleteUserRoleResponse::NotFound); + }; + + model.delete(&*db).await.map_err(WarpgateError::from)?; + + Ok(DeleteUserRoleResponse::Deleted) + } +} diff --git a/warpgate-admin/src/api/users_list.rs b/warpgate-admin/src/api/users_list.rs deleted file mode 100644 index 3383b07..0000000 --- a/warpgate-admin/src/api/users_list.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::sync::Arc; - -use poem::web::Data; -use poem_openapi::payload::Json; -use poem_openapi::{ApiResponse, OpenApi}; -use tokio::sync::Mutex; -use warpgate_core::{ConfigProvider, UserSnapshot}; - -pub struct Api; - -#[derive(ApiResponse)] -enum GetUsersResponse { - #[oai(status = 200)] - Ok(Json>), -} - -#[OpenApi] -impl Api { - #[oai(path = "/users", method = "get", operation_id = "get_users")] - async fn api_get_all_users( - &self, - config_provider: Data<&Arc>>, - ) -> poem::Result { - let mut users = config_provider.lock().await.list_users().await?; - users.sort_by(|a, b| a.username.cmp(&b.username)); - Ok(GetUsersResponse::Ok(Json(users))) - } -} diff --git a/warpgate-common/src/auth/cred.rs b/warpgate-common/src/auth/cred.rs index 2548629..241852d 100644 --- a/warpgate-common/src/auth/cred.rs +++ b/warpgate-common/src/auth/cred.rs @@ -1,9 +1,10 @@ use bytes::Bytes; +use poem_openapi::Enum; use serde::{Deserialize, Serialize}; use crate::Secret; -#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Enum)] pub enum CredentialKind { #[serde(rename = "password")] Password, diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index a350b93..1b3d7d1 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::time::Duration; use defaults::*; -use poem_openapi::Object; +use poem_openapi::{Object, Union}; use serde::{Deserialize, Serialize}; pub use target::*; use url::Url; @@ -16,37 +16,51 @@ use crate::auth::CredentialKind; use crate::helpers::otp::OtpSecretKey; use crate::{ListenEndpoint, Secret, WarpgateError}; -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)] #[serde(tag = "type")] +#[oai(discriminator_name = "kind", one_of)] pub enum UserAuthCredential { #[serde(rename = "password")] - Password { hash: Secret }, + Password(UserPasswordCredential), #[serde(rename = "publickey")] - PublicKey { key: Secret }, + PublicKey(UserPublicKeyCredential), #[serde(rename = "otp")] - Totp { - #[serde(with = "crate::helpers::serde_base64_secret")] - key: OtpSecretKey, - }, + Totp(UserTotpCredential), #[serde(rename = "sso")] - Sso { - provider: Option, - email: String, - }, + Sso(UserSsoCredential), +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct UserPasswordCredential { + pub hash: Secret, +} +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct UserPublicKeyCredential { + pub key: Secret, +} +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct UserTotpCredential { + #[serde(with = "crate::helpers::serde_base64_secret")] + pub key: OtpSecretKey, +} +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] +pub struct UserSsoCredential { + pub provider: Option, + pub email: String, } impl UserAuthCredential { pub fn kind(&self) -> CredentialKind { match self { - Self::Password { .. } => CredentialKind::Password, - Self::PublicKey { .. } => CredentialKind::PublicKey, - Self::Totp { .. } => CredentialKind::Otp, - Self::Sso { .. } => CredentialKind::Sso, + Self::Password(_)=> CredentialKind::Password, + Self::PublicKey(_)=> CredentialKind::PublicKey, + Self::Totp(_)=> CredentialKind::Otp, + Self::Sso(_)=> CredentialKind::Sso, } } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct UserRequireCredentialsPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub http: Option>, @@ -56,8 +70,10 @@ pub struct UserRequireCredentialsPolicy { pub mysql: Option>, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct User { + #[serde(default)] + pub id: Uuid, pub username: String, pub credentials: Vec, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/warpgate-common/src/helpers/otp.rs b/warpgate-common/src/helpers/otp.rs index da26120..6498ec1 100644 --- a/warpgate-common/src/helpers/otp.rs +++ b/warpgate-common/src/helpers/otp.rs @@ -1,17 +1,16 @@ use std::time::SystemTime; -use bytes::Bytes; use rand::Rng; use totp_rs::{Algorithm, TOTP}; use super::rng::get_crypto_rng; use crate::types::Secret; -pub type OtpExposedSecretKey = Bytes; +pub type OtpExposedSecretKey = Vec; pub type OtpSecretKey = Secret; pub fn generate_key() -> OtpSecretKey { - Secret::new(Bytes::from_iter(get_crypto_rng().gen::<[u8; 32]>())) + Secret::new(get_crypto_rng().gen::<[u8; 32]>().into()) } pub fn generate_setup_url(key: &OtpSecretKey, label: &str) -> Secret { diff --git a/warpgate-common/src/helpers/serde_base64_secret.rs b/warpgate-common/src/helpers/serde_base64_secret.rs index c6c30a3..df52eda 100644 --- a/warpgate-common/src/helpers/serde_base64_secret.rs +++ b/warpgate-common/src/helpers/serde_base64_secret.rs @@ -1,16 +1,15 @@ -use bytes::Bytes; use serde::Serializer; use super::serde_base64; use crate::Secret; -pub fn serialize(secret: &Secret, serializer: S) -> Result { - serde_base64::serialize(secret.expose_secret().as_ref(), serializer) +pub fn serialize(secret: &Secret>, serializer: S) -> Result { + serde_base64::serialize(secret.expose_secret(), serializer) } pub fn deserialize<'de, D: serde::Deserializer<'de>>( deserializer: D, -) -> Result, D::Error> { +) -> Result>, D::Error> { let inner = serde_base64::deserialize(deserializer)?; Ok(Secret::new(inner)) } diff --git a/warpgate-common/src/types/secret.rs b/warpgate-common/src/types/secret.rs index 8ba8362..88aa513 100644 --- a/warpgate-common/src/types/secret.rs +++ b/warpgate-common/src/types/secret.rs @@ -94,6 +94,7 @@ impl poem_openapi::types::Type for Secret { } } + impl ParseFromJSON for Secret { fn parse_from_json(value: Option) -> poem_openapi::types::ParseResult { T::parse_from_json(value) diff --git a/warpgate-core/src/config_providers/db.rs b/warpgate-core/src/config_providers/db.rs index 5236d28..30763e5 100644 --- a/warpgate-core/src/config_providers/db.rs +++ b/warpgate-core/src/config_providers/db.rs @@ -13,43 +13,37 @@ use warpgate_common::auth::{ use warpgate_common::helpers::hash::verify_password_hash; use warpgate_common::helpers::otp::verify_totp; use warpgate_common::{ - Role as RoleConfig, Target as TargetConfig, User, UserAuthCredential, WarpgateConfig, + Role as RoleConfig, Target as TargetConfig, User as UserConfig, UserAuthCredential, + UserPasswordCredential, UserPublicKeyCredential, UserSsoCredential, UserTotpCredential, WarpgateError, }; -use warpgate_db_entities::{Role, Target}; +use warpgate_db_entities::{Role, Target, User}; use super::ConfigProvider; -use crate::UserSnapshot; pub struct DatabaseConfigProvider { db: Arc>, - config: Arc>, } impl DatabaseConfigProvider { - pub async fn new( - db: &Arc>, - config: &Arc>, - ) -> Self { - Self { - db: db.clone(), - config: config.clone(), - } + pub async fn new(db: &Arc>) -> Self { + Self { db: db.clone() } } } #[async_trait] impl ConfigProvider for DatabaseConfigProvider { - async fn list_users(&mut self) -> Result, WarpgateError> { - Ok(self - .config - .lock() - .await - .store - .users - .iter() - .map(UserSnapshot::new) - .collect::>()) + async fn list_users(&mut self) -> Result, WarpgateError> { + let db = self.db.lock().await; + + let users = User::Entity::find() + .order_by_asc(User::Column::Username) + .all(&*db) + .await?; + + let users: Result, _> = users.into_iter().map(|t| t.try_into()).collect(); + + Ok(users?) } async fn list_targets(&mut self) -> Result, WarpgateError> { @@ -70,21 +64,20 @@ impl ConfigProvider for DatabaseConfigProvider { &mut self, username: &str, ) -> Result>, WarpgateError> { - let user = { - self.config - .lock() - .await - .store - .users - .iter() - .find(|x| x.username == username) - .map(User::to_owned) - }; - let Some(user) = user else { + let db = self.db.lock().await; + + let user_model = User::Entity::find() + .filter(User::Column::Username.eq(username)) + .one(&*db) + .await?; + + let Some(user_model) = user_model else { error!("Selected user not found: {}", username); return Ok(None); }; + let user: UserConfig = user_model.try_into()?; + let supported_credential_types: HashSet = user.credentials.iter().map(|x| x.kind()).collect(); let default_policy = Box::new(AnySingleCredentialPolicy { @@ -142,15 +135,12 @@ impl ConfigProvider for DatabaseConfigProvider { }; Ok(self - .config - .lock() - .await - .store - .users + .list_users() + .await? .iter() .find(|x| { for cred in x.credentials.iter() { - if let UserAuthCredential::Sso { provider, email } = cred { + if let UserAuthCredential::Sso(UserSsoCredential { provider, email }) = cred { if provider.as_ref().unwrap_or(client_provider) == client_provider && email == client_email { @@ -168,20 +158,19 @@ impl ConfigProvider for DatabaseConfigProvider { username: &str, client_credential: &AuthCredential, ) -> Result { - let user = { - self.config - .lock() - .await - .store - .users - .iter() - .find(|x| x.username == username) - .map(User::to_owned) - }; - let Some(user) = user else { - error!("Selected user not found: {}", username); - return Ok(false); - }; + let db = self.db.lock().await; + + let user_model = User::Entity::find() + .filter(User::Column::Username.eq(username)) + .one(&*db) + .await?; + + let Some(user_model) = user_model else { + error!("Selected user not found: {}", username); + return Ok(false); + }; + + let user: UserConfig = user_model.try_into()?; match client_credential { AuthCredential::PublicKey { @@ -194,17 +183,17 @@ impl ConfigProvider for DatabaseConfigProvider { debug!(username = &user.username[..], "Client key: {}", client_key); return Ok(user.credentials.iter().any(|credential| match credential { - UserAuthCredential::PublicKey { key: ref user_key } => { - &client_key == user_key.expose_secret() - } + UserAuthCredential::PublicKey(UserPublicKeyCredential { + key: ref user_key, + }) => &client_key == user_key.expose_secret(), _ => false, })); } AuthCredential::Password(client_password) => { return Ok(user.credentials.iter().any(|credential| match credential { - UserAuthCredential::Password { + UserAuthCredential::Password(UserPasswordCredential { hash: ref user_password_hash, - } => verify_password_hash( + }) => verify_password_hash( client_password.expose_secret(), user_password_hash.expose_secret(), ) @@ -220,9 +209,9 @@ impl ConfigProvider for DatabaseConfigProvider { } AuthCredential::Otp(client_otp) => { return Ok(user.credentials.iter().any(|credential| match credential { - UserAuthCredential::Totp { + UserAuthCredential::Totp(UserTotpCredential { key: ref user_otp_key, - } => verify_totp(client_otp.expose_secret(), user_otp_key), + }) => verify_totp(client_otp.expose_secret(), user_otp_key), _ => false, })) } @@ -231,10 +220,10 @@ impl ConfigProvider for DatabaseConfigProvider { email: client_email, } => { for credential in user.credentials.iter() { - if let UserAuthCredential::Sso { + if let UserAuthCredential::Sso(UserSsoCredential { ref provider, ref email, - } = credential + }) = credential { if provider.as_ref().unwrap_or(client_provider) == client_provider { return Ok(email == client_email); @@ -252,23 +241,19 @@ impl ConfigProvider for DatabaseConfigProvider { username: &str, target_name: &str, ) -> Result { - let config = self.config.lock().await; - let user = config - .store - .users - .iter() - .find(|x| x.username == username) - .map(User::to_owned); - let db = self.db.lock().await; let target_model: Option = Target::Entity::find() - .order_by_desc(Target::Column::Name) .filter(Target::Column::Name.eq(target_name)) .one(&*db) .await?; - let Some(user) = user else { + let user_model = User::Entity::find() + .filter(User::Column::Username.eq(username)) + .one(&*db) + .await?; + + let Some(user_model) = user_model else { error!("Selected user not found: {}", username); return Ok(false); }; @@ -287,8 +272,8 @@ impl ConfigProvider for DatabaseConfigProvider { .map(|x| x.name) .collect(); - let user_roles: HashSet = Role::Entity::find() - .filter(Role::Column::Name.is_in(user.roles)) + let user_roles: HashSet = user_model + .find_related(Role::Entity) .all(&*db) .await? .into_iter() diff --git a/warpgate-core/src/config_providers/file.rs b/warpgate-core/src/config_providers/file.rs index 84fa34a..94caeb6 100644 --- a/warpgate-core/src/config_providers/file.rs +++ b/warpgate-core/src/config_providers/file.rs @@ -11,10 +11,12 @@ use warpgate_common::auth::{ }; use warpgate_common::helpers::hash::verify_password_hash; use warpgate_common::helpers::otp::verify_totp; -use warpgate_common::{Target, User, UserAuthCredential, WarpgateConfig, WarpgateError}; +use warpgate_common::{ + Target, User, UserAuthCredential, UserPasswordCredential, UserPublicKeyCredential, + UserSsoCredential, UserTotpCredential, WarpgateConfig, WarpgateError, +}; use super::ConfigProvider; -use crate::UserSnapshot; pub struct FileConfigProvider { config: Arc>, @@ -30,7 +32,7 @@ impl FileConfigProvider { #[async_trait] impl ConfigProvider for FileConfigProvider { - async fn list_users(&mut self) -> Result, WarpgateError> { + async fn list_users(&mut self) -> Result, WarpgateError> { Ok(self .config .lock() @@ -38,7 +40,7 @@ impl ConfigProvider for FileConfigProvider { .store .users .iter() - .map(UserSnapshot::new) + .map(Clone::clone) .collect::>()) } @@ -138,7 +140,7 @@ impl ConfigProvider for FileConfigProvider { .iter() .find(|x| { for cred in x.credentials.iter() { - if let UserAuthCredential::Sso { provider, email } = cred { + if let UserAuthCredential::Sso(UserSsoCredential { provider, email }) = cred { if provider.as_ref().unwrap_or(client_provider) == client_provider && email == client_email { @@ -182,17 +184,17 @@ impl ConfigProvider for FileConfigProvider { debug!(username = &user.username[..], "Client key: {}", client_key); return Ok(user.credentials.iter().any(|credential| match credential { - UserAuthCredential::PublicKey { key: ref user_key } => { - &client_key == user_key.expose_secret() - } + UserAuthCredential::PublicKey(UserPublicKeyCredential { + key: ref user_key, + }) => &client_key == user_key.expose_secret(), _ => false, })); } AuthCredential::Password(client_password) => { return Ok(user.credentials.iter().any(|credential| match credential { - UserAuthCredential::Password { + UserAuthCredential::Password(UserPasswordCredential { hash: ref user_password_hash, - } => verify_password_hash( + }) => verify_password_hash( client_password.expose_secret(), user_password_hash.expose_secret(), ) @@ -208,9 +210,9 @@ impl ConfigProvider for FileConfigProvider { } AuthCredential::Otp(client_otp) => { return Ok(user.credentials.iter().any(|credential| match credential { - UserAuthCredential::Totp { + UserAuthCredential::Totp(UserTotpCredential { key: ref user_otp_key, - } => verify_totp(client_otp.expose_secret(), user_otp_key), + }) => verify_totp(client_otp.expose_secret(), user_otp_key), _ => false, })) } @@ -219,10 +221,10 @@ impl ConfigProvider for FileConfigProvider { email: client_email, } => { for credential in user.credentials.iter() { - if let UserAuthCredential::Sso { + if let UserAuthCredential::Sso(UserSsoCredential { ref provider, ref email, - } = credential + }) = credential { if provider.as_ref().unwrap_or(client_provider) == client_provider { return Ok(email == client_email); diff --git a/warpgate-core/src/config_providers/mod.rs b/warpgate-core/src/config_providers/mod.rs index 0d17b46..8c66a4f 100644 --- a/warpgate-core/src/config_providers/mod.rs +++ b/warpgate-core/src/config_providers/mod.rs @@ -11,14 +11,12 @@ use tokio::sync::Mutex; use tracing::*; use uuid::Uuid; use warpgate_common::auth::{AuthCredential, CredentialPolicy}; -use warpgate_common::{Secret, Target, WarpgateError}; +use warpgate_common::{Secret, Target, WarpgateError, User}; use warpgate_db_entities::Ticket; -use crate::UserSnapshot; - #[async_trait] pub trait ConfigProvider { - async fn list_users(&mut self) -> Result, WarpgateError>; + async fn list_users(&mut self) -> Result, WarpgateError>; async fn list_targets(&mut self) -> Result, WarpgateError>; diff --git a/warpgate-core/src/data.rs b/warpgate-core/src/data.rs index 28120cb..df85701 100644 --- a/warpgate-core/src/data.rs +++ b/warpgate-core/src/data.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use poem_openapi::Object; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use warpgate_common::{SessionId, Target, User}; +use warpgate_common::{SessionId, Target}; use warpgate_db_entities::Session; #[derive(Serialize, Deserialize, Object)] @@ -31,16 +31,3 @@ impl From for SessionSnapshot { } } } - -#[derive(Serialize, Deserialize, Object)] -pub struct UserSnapshot { - pub username: String, -} - -impl UserSnapshot { - pub fn new(user: &User) -> Self { - Self { - username: user.username.clone(), - } - } -} diff --git a/warpgate-core/src/db/mod.rs b/warpgate-core/src/db/mod.rs index 87ad6fb..508b2a2 100644 --- a/warpgate-core/src/db/mod.rs +++ b/warpgate-core/src/db/mod.rs @@ -1,17 +1,20 @@ use std::collections::HashMap; use std::time::Duration; -use tracing::*; + use anyhow::Result; use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectOptions, Database, DatabaseConnection, EntityTrait, QueryFilter, TransactionTrait, }; +use tracing::*; use uuid::Uuid; use warpgate_common::helpers::fs::secure_file; use warpgate_common::{TargetOptions, TargetWebAdminOptions, WarpgateConfig, WarpgateError}; use warpgate_db_entities::Target::TargetKind; -use warpgate_db_entities::{LogEntry, Role, Target, TargetRoleAssignment}; +use warpgate_db_entities::{ + LogEntry, Role, Target, TargetRoleAssignment, User, UserRoleAssignment, +}; use warpgate_db_migrations::migrate_database; use crate::consts::{BUILTIN_ADMIN_ROLE_NAME, BUILTIN_ADMIN_TARGET_NAME}; @@ -158,7 +161,6 @@ async fn migrate_config_into_db( info!("Migrating config file into the database"); let mut role_lookup = HashMap::new(); - let mut target_lookup = HashMap::new(); for role_config in config.store.roles.iter() { let role = match Role::Entity::find() @@ -206,7 +208,6 @@ async fn migrate_config_into_db( values.insert(&*db).await.map_err(WarpgateError::from)? } }; - target_lookup.insert(target_config.name.clone(), target.id); for role_name in target_config.allow_roles.iter() { if let Some(role_id) = role_lookup.get(role_name) { @@ -229,6 +230,50 @@ async fn migrate_config_into_db( } config.store.targets = vec![]; + for user_config in config.store.users.iter() { + let user = match User::Entity::find() + .filter(User::Column::Username.eq(user_config.username.clone())) + .all(db) + .await? + .first() + { + Some(x) => x.to_owned(), + None => { + let values = User::ActiveModel { + id: Set(Uuid::new_v4()), + username: Set(user_config.username.clone()), + credentials: Set(serde_json::to_value(user_config.credentials.clone()) + .map_err(WarpgateError::from)?), + credential_policy: Set(serde_json::to_value(user_config.require.clone()) + .map_err(WarpgateError::from)?), + }; + + info!("Migrating user {}", user_config.username); + values.insert(&*db).await.map_err(WarpgateError::from)? + } + }; + + for role_name in user_config.roles.iter() { + if let Some(role_id) = role_lookup.get(role_name) { + if UserRoleAssignment::Entity::find() + .filter(UserRoleAssignment::Column::UserId.eq(user.id)) + .filter(UserRoleAssignment::Column::RoleId.eq(*role_id)) + .all(db) + .await? + .is_empty() + { + let values = UserRoleAssignment::ActiveModel { + user_id: Set(user.id), + role_id: Set(*role_id), + ..Default::default() + }; + values.insert(&*db).await.map_err(WarpgateError::from)?; + } + } + } + } + config.store.users = vec![]; + Ok(()) } diff --git a/warpgate-core/src/db/uuid.rs b/warpgate-core/src/db/uuid.rs deleted file mode 100644 index 4225fd2..0000000 --- a/warpgate-core/src/db/uuid.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::io::Write; - -use crate::UUID; - -impl FromSql for UUID -where - Vec: FromSql, -{ - fn from_sql(bytes: Option<&B::RawValue>) -> diesel::deserialize::Result { - let value = >::from_sql(bytes)?; - Ok(UUID::from_bytes(&value)?) - } -} - -impl ToSql for UUID -where - [u8]: ToSql, -{ - fn to_sql( - &self, - out: &mut diesel::serialize::Output, - ) -> diesel::serialize::Result { - let bytes = self.0.as_bytes(); - <[u8] as ToSql>::to_sql(bytes, out) - } -} - -impl AsExpression for UUID { - type Expression = Bound; - - fn as_expression(self) -> Self::Expression { - Bound::new(self) - } -} - -impl<'a> AsExpression for &'a UUID { - type Expression = Bound; - - fn as_expression(self) -> Self::Expression { - Bound::new(self) - } -} -// impl Expression for UUID { -// type SqlType = diesel::sql_types::Binary; -// } diff --git a/warpgate-core/src/services.rs b/warpgate-core/src/services.rs index 65af99f..0bfb55a 100644 --- a/warpgate-core/src/services.rs +++ b/warpgate-core/src/services.rs @@ -39,8 +39,7 @@ impl Services { Arc::new(Mutex::new(FileConfigProvider::new(&config).await)) as ConfigProviderArc } ConfigProviderKind::Database => { - Arc::new(Mutex::new(DatabaseConfigProvider::new(&db, &config).await)) - as ConfigProviderArc + Arc::new(Mutex::new(DatabaseConfigProvider::new(&db).await)) as ConfigProviderArc } }; diff --git a/warpgate-db-entities/src/TargetRoleAssignment.rs b/warpgate-db-entities/src/TargetRoleAssignment.rs index 2a8da73..07670b5 100644 --- a/warpgate-db-entities/src/TargetRoleAssignment.rs +++ b/warpgate-db-entities/src/TargetRoleAssignment.rs @@ -5,7 +5,7 @@ use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "target_roles")] -#[oai(rename = "Target")] +#[oai(rename = "TargetRoleAssignment")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: u32, diff --git a/warpgate-db-entities/src/User.rs b/warpgate-db-entities/src/User.rs new file mode 100644 index 0000000..5d5f376 --- /dev/null +++ b/warpgate-db-entities/src/User.rs @@ -0,0 +1,48 @@ +use poem_openapi::Object; +use sea_orm::entity::prelude::*; +use serde::Serialize; +use uuid::Uuid; +use warpgate_common::{User, UserAuthCredential, UserRequireCredentialsPolicy}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] +#[sea_orm(table_name = "users")] +#[oai(rename = "User")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub username: String, + pub credentials: serde_json::Value, + pub credential_policy: serde_json::Value, +} + +impl Related for Entity { + fn to() -> RelationDef { + super::UserRoleAssignment::Relation::Role.def() + } + + fn via() -> Option { + Some(super::UserRoleAssignment::Relation::User.def().rev()) + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl TryFrom for User { + type Error = serde_json::Error; + + fn try_from(model: Model) -> Result { + let credentials: Vec = serde_json::from_value(model.credentials)?; + let credential_policy: Option = + serde_json::from_value(model.credential_policy)?; + Ok(Self { + id: model.id, + username: model.username, + roles: vec![], + credentials, + require: credential_policy, + }) + } +} diff --git a/warpgate-db-entities/src/UserRoleAssignment.rs b/warpgate-db-entities/src/UserRoleAssignment.rs new file mode 100644 index 0000000..c7d875b --- /dev/null +++ b/warpgate-db-entities/src/UserRoleAssignment.rs @@ -0,0 +1,37 @@ +use poem_openapi::Object; +use sea_orm::entity::prelude::*; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] +#[sea_orm(table_name = "user_roles")] +#[oai(rename = "UserRoleAssignment")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: u32, + pub user_id: Uuid, + pub role_id: Uuid, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + User, + Role, +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::User => Entity::belongs_to(super::User::Entity) + .from(Column::UserId) + .to(super::User::Column::Id) + .into(), + Self::Role => Entity::belongs_to(super::Role::Entity) + .from(Column::RoleId) + .to(super::Role::Column::Id) + .into(), + } + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/warpgate-db-entities/src/lib.rs b/warpgate-db-entities/src/lib.rs index cf7bf48..0c3cd41 100644 --- a/warpgate-db-entities/src/lib.rs +++ b/warpgate-db-entities/src/lib.rs @@ -8,3 +8,5 @@ pub mod Session; pub mod Target; pub mod TargetRoleAssignment; pub mod Ticket; +pub mod User; +pub mod UserRoleAssignment; diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index a968834..62fb79f 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -9,6 +9,7 @@ mod m00004_create_known_host; mod m00005_create_log_entry; mod m00006_add_session_protocol; mod m00007_targets_and_roles; +mod m00008_users; pub struct Migrator; @@ -23,6 +24,7 @@ impl MigratorTrait for Migrator { Box::new(m00005_create_log_entry::Migration), Box::new(m00006_add_session_protocol::Migration), Box::new(m00007_targets_and_roles::Migration), + Box::new(m00008_users::Migration), ] } } diff --git a/warpgate-db-migrations/src/m00007_targets_and_roles.rs b/warpgate-db-migrations/src/m00007_targets_and_roles.rs index 1647482..441d467 100644 --- a/warpgate-db-migrations/src/m00007_targets_and_roles.rs +++ b/warpgate-db-migrations/src/m00007_targets_and_roles.rs @@ -1,7 +1,7 @@ use sea_orm::Schema; use sea_orm_migration::prelude::*; -mod role { +pub(crate) mod role { use sea_orm::entity::prelude::*; use uuid::Uuid; diff --git a/warpgate-db-migrations/src/m00008_users.rs b/warpgate-db-migrations/src/m00008_users.rs new file mode 100644 index 0000000..a3887c9 --- /dev/null +++ b/warpgate-db-migrations/src/m00008_users.rs @@ -0,0 +1,102 @@ +use sea_orm::Schema; +use sea_orm_migration::prelude::*; + +mod user { + use sea_orm::entity::prelude::*; + use uuid::Uuid; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "users")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub username: String, + pub credentials: serde_json::Value, + pub credential_policy: serde_json::Value, + } + + impl Related for Entity { + fn to() -> RelationDef { + super::user_role_assignment::Relation::User.def() + } + + fn via() -> Option { + Some(super::user_role_assignment::Relation::Role.def().rev()) + } + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} + +mod user_role_assignment { + use sea_orm::entity::prelude::*; + use uuid::Uuid; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "user_roles")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: u32, + pub user_id: Uuid, + pub role_id: Uuid, + } + + #[derive(Copy, Clone, Debug, EnumIter)] + pub enum Relation { + User, + Role, + } + + impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::User => Entity::belongs_to(super::user::Entity) + .from(Column::UserId) + .to(super::user::Column::Id) + .into(), + Self::Role => Entity::belongs_to(crate::m00007_targets_and_roles::role::Entity) + .from(Column::RoleId) + .to(crate::m00007_targets_and_roles::role::Column::Id) + .into(), + } + } + } + + impl ActiveModelBehavior for ActiveModel {} +} + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m00008_users" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let builder = manager.get_database_backend(); + let schema = Schema::new(builder); + manager + .create_table(schema.create_table_from_entity(user::Entity)) + .await?; + manager + .create_table(schema.create_table_from_entity(user_role_assignment::Entity)) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(user_role_assignment::Entity).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(user::Entity).to_owned()) + .await?; + Ok(()) + } +} diff --git a/warpgate-web/src/admin/App.svelte b/warpgate-web/src/admin/App.svelte index 857fb95..964415c 100644 --- a/warpgate-web/src/admin/App.svelte +++ b/warpgate-web/src/admin/App.svelte @@ -54,6 +54,12 @@ const routes = { '/roles/:id': wrap({ asyncComponent: () => import('./Role.svelte'), }), + '/users/create': wrap({ + asyncComponent: () => import('./CreateUser.svelte'), + }), + // '/users/:id': wrap({ + // asyncComponent: () => import('./User.svelte'), + // }), '/ssh': wrap({ asyncComponent: () => import('./SSH.svelte'), }), diff --git a/warpgate-web/src/admin/Config.svelte b/warpgate-web/src/admin/Config.svelte index 12b5a22..2358b3f 100644 --- a/warpgate-web/src/admin/Config.svelte +++ b/warpgate-web/src/admin/Config.svelte @@ -4,79 +4,115 @@ import { link } from 'svelte-spa-router' import { Alert, Spinner } from 'sveltestrap' -
-

Targets

- - Add a target - -
- -{#await api.getTargets()} - -{:then targets} -
- {#each targets as target} - +
+ -{:catch error} - {error} -{/await} -
-

Roles

- - Add a role - -
- -{#await api.getRoles()} - -{:then roles} -
- {#each roles as role} - +
+ + + {#await api.getUsers()} + + {:then users} +
+ {#each users as user} + + + + {user.username} + + + {/each} +
+ {:catch error} + {error} + {/await} + +
+

Roles

+ + Add a role + +
+ + {#await api.getRoles()} + + {:then roles} +
+ {#each roles as role} + + + + {role.name} + + + {/each} +
+ {:catch error} + {error} + {/await}
-{:catch error} - {error} -{/await} +