diff --git a/.sqlx/query-226259d2006f1a0bc904f5a25f9bbc71c19a867b626493b9f973a5cc09f5e3c0.json b/.sqlx/query-226259d2006f1a0bc904f5a25f9bbc71c19a867b626493b9f973a5cc09f5e3c0.json new file mode 100644 index 000000000..0fda229cc --- /dev/null +++ b/.sqlx/query-226259d2006f1a0bc904f5a25f9bbc71c19a867b626493b9f973a5cc09f5e3c0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"enterprisesettings\" SET \"admin_device_management\" = $2 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "226259d2006f1a0bc904f5a25f9bbc71c19a867b626493b9f973a5cc09f5e3c0" +} diff --git a/.sqlx/query-30b117d8b863a0785fcb164bc428ea5591fe28ea9e07170906eb78c3ae4531d8.json b/.sqlx/query-30b117d8b863a0785fcb164bc428ea5591fe28ea9e07170906eb78c3ae4531d8.json new file mode 100644 index 000000000..f26afcd0a --- /dev/null +++ b/.sqlx/query-30b117d8b863a0785fcb164bc428ea5591fe28ea9e07170906eb78c3ae4531d8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"enterprisesettings\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "30b117d8b863a0785fcb164bc428ea5591fe28ea9e07170906eb78c3ae4531d8" +} diff --git a/.sqlx/query-a1836fa232271fdd5d61f0d34048741a269f654ef6eb1fa2f6b95e68ed7e04e1.json b/.sqlx/query-a1836fa232271fdd5d61f0d34048741a269f654ef6eb1fa2f6b95e68ed7e04e1.json new file mode 100644 index 000000000..31a8e79fb --- /dev/null +++ b/.sqlx/query-a1836fa232271fdd5d61f0d34048741a269f654ef6eb1fa2f6b95e68ed7e04e1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id \"id?\", \"admin_device_management\" FROM \"enterprisesettings\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id?", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "admin_device_management", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a1836fa232271fdd5d61f0d34048741a269f654ef6eb1fa2f6b95e68ed7e04e1" +} diff --git a/.sqlx/query-bcba1892b88c1aecf5fb4112c03b4ec9ffc2f61e38c74630dac5e32f3736c113.json b/.sqlx/query-bcba1892b88c1aecf5fb4112c03b4ec9ffc2f61e38c74630dac5e32f3736c113.json new file mode 100644 index 000000000..ba99ad563 --- /dev/null +++ b/.sqlx/query-bcba1892b88c1aecf5fb4112c03b4ec9ffc2f61e38c74630dac5e32f3736c113.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id \"id?\", \"admin_device_management\" FROM \"enterprisesettings\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id?", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "admin_device_management", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "bcba1892b88c1aecf5fb4112c03b4ec9ffc2f61e38c74630dac5e32f3736c113" +} diff --git a/.sqlx/query-c0883840b92550c4a2ba682d15ee4841bf9cdce2d87fa952457b157afbe2d756.json b/.sqlx/query-c0883840b92550c4a2ba682d15ee4841bf9cdce2d87fa952457b157afbe2d756.json new file mode 100644 index 000000000..9c7724501 --- /dev/null +++ b/.sqlx/query-c0883840b92550c4a2ba682d15ee4841bf9cdce2d87fa952457b157afbe2d756.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"enterprisesettings\" (\"admin_device_management\") VALUES ($1) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c0883840b92550c4a2ba682d15ee4841bf9cdce2d87fa952457b157afbe2d756" +} diff --git a/Cargo.lock b/Cargo.lock index 0f6e4c017..94fb6cf04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4848,18 +4848,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "struct-patch" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084489a427e3470993d4008c935a3d3f10936d520c5b2a527da392bb1e604eee" +checksum = "af1baa236355594336eb27127ae98c55ced19a6611456dd3bfe445b496e3b3e6" dependencies = [ "struct-patch-derive", ] [[package]] name = "struct-patch-derive" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e542f637a488f779cf52b27208dbc425dbb858a6ddbc9e77087d268f72494b3" +checksum = "10449466e3f3aa069ad534cbce19f38f2eaae74cdfdfbacc64d6afd99e36d7cf" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index d3d7d8f63..19062b78f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ sqlx = { version = "0.7", features = [ "uuid", ] } ssh-key = "0.6" -struct-patch = "0.7" +struct-patch = "0.8" tera = "1.20" thiserror = "1.0" # match axum-extra -> cookies diff --git a/migrations/20240819134151_enterprise_settings.down.sql b/migrations/20240819134151_enterprise_settings.down.sql new file mode 100644 index 000000000..525e164fd --- /dev/null +++ b/migrations/20240819134151_enterprise_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE enterprisesettings; diff --git a/migrations/20240819134151_enterprise_settings.up.sql b/migrations/20240819134151_enterprise_settings.up.sql new file mode 100644 index 000000000..4db6f235e --- /dev/null +++ b/migrations/20240819134151_enterprise_settings.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE enterprisesettings ( + id bigserial PRIMARY KEY, + admin_device_management BOOLEAN NOT NULL DEFAULT false +); + +INSERT INTO enterprisesettings (admin_device_management) values (false); diff --git a/src/enterprise/db/models/enterprise_settings.rs b/src/enterprise/db/models/enterprise_settings.rs new file mode 100644 index 000000000..2d97e2c66 --- /dev/null +++ b/src/enterprise/db/models/enterprise_settings.rs @@ -0,0 +1,47 @@ +use model_derive::Model; +use sqlx::PgExecutor; +use struct_patch::Patch; + +use crate::enterprise::license::{get_cached_license, validate_license}; + +#[derive(Model, Deserialize, Serialize, Patch)] +#[patch(attribute(derive(Serialize, Deserialize)))] +pub struct EnterpriseSettings { + #[serde(skip)] + pub id: Option, + // If true, only admins can manage devices + pub admin_device_management: bool, +} + +// We want to be conscious of what the defaults are here +#[allow(clippy::derivable_impls)] +impl Default for EnterpriseSettings { + fn default() -> Self { + Self { + id: None, + admin_device_management: false, + } + } +} + +impl EnterpriseSettings { + /// If license is valid returns current [`EnterpriseSettings`] object. + /// Otherwise returns [`EnterpriseSettings::default()`]. + pub async fn get<'e, E>(executor: E) -> Result + where + E: PgExecutor<'e>, + { + // avoid holding the rwlock across await, makes the future !Send + // and therefore unusable in axum handlers + let is_valid = { + let license = get_cached_license(); + validate_license(license.as_ref()).is_ok() + }; + if is_valid { + let settings = Self::find_by_id(executor, 1).await?; + Ok(settings.expect("EnterpriseSettings not found")) + } else { + Ok(EnterpriseSettings::default()) + } + } +} diff --git a/src/enterprise/db/models/mod.rs b/src/enterprise/db/models/mod.rs index 972680e37..742323f1d 100644 --- a/src/enterprise/db/models/mod.rs +++ b/src/enterprise/db/models/mod.rs @@ -1 +1,2 @@ +pub mod enterprise_settings; pub mod openid_provider; diff --git a/src/enterprise/handlers/enterprise_settings.rs b/src/enterprise/handlers/enterprise_settings.rs new file mode 100644 index 000000000..07442b9b3 --- /dev/null +++ b/src/enterprise/handlers/enterprise_settings.rs @@ -0,0 +1,49 @@ +use axum::{extract::State, http::StatusCode, Json}; +use serde_json::json; +use struct_patch::Patch; + +use super::LicenseInfo; +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + enterprise::db::models::enterprise_settings::{EnterpriseSettings, EnterpriseSettingsPatch}, + handlers::{ApiResponse, ApiResult}, +}; + +pub async fn get_enterprise_settings( + session: SessionInfo, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} retrieving enterprise settings", + session.user.username + ); + let settings = EnterpriseSettings::get(&appstate.pool).await?; + info!( + "User {} retrieved enterprise settings", + session.user.username + ); + Ok(ApiResponse { + json: json!(settings), + status: StatusCode::OK, + }) +} + +pub async fn patch_enterprise_settings( + _license: LicenseInfo, + _admin: AdminRole, + State(appstate): State, + session: SessionInfo, + Json(data): Json, +) -> ApiResult { + debug!( + "Admin {} patching enterprise settings.", + session.user.username, + ); + let mut settings = EnterpriseSettings::get(&appstate.pool).await?; + + settings.apply(data); + settings.save(&appstate.pool).await?; + info!("Admin {} patched settings.", session.user.username); + Ok(ApiResponse::default()) +} diff --git a/src/enterprise/handlers/mod.rs b/src/enterprise/handlers/mod.rs index d926bf0bb..c9b3d616b 100644 --- a/src/enterprise/handlers/mod.rs +++ b/src/enterprise/handlers/mod.rs @@ -1,8 +1,10 @@ use crate::{ + auth::SessionInfo, enterprise::license::validate_license, handlers::{ApiResponse, ApiResult}, }; +pub mod enterprise_settings; pub mod openid_login; pub mod openid_providers; @@ -12,13 +14,16 @@ use axum::{ http::{request::Parts, StatusCode}, }; -use super::license::get_cached_license; +use super::{db::models::enterprise_settings::EnterpriseSettings, license::get_cached_license}; use crate::{appstate::AppState, error::WebError}; pub struct LicenseInfo { pub valid: bool, } +/// Used to check if user is allowed to manage his devices. +pub struct CanManageDevices; + #[async_trait] impl FromRequestParts for LicenseInfo where @@ -30,7 +35,7 @@ where async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { let license = get_cached_license(); - match validate_license((*license).as_ref()) { + match validate_license(license.as_ref()) { // Useless struct, but may come in handy later Ok(_) => Ok(LicenseInfo { valid: true }), Err(e) => Err(WebError::Forbidden(e.to_string())), @@ -41,10 +46,34 @@ where pub async fn check_enterprise_status() -> ApiResult { let license = get_cached_license(); - let valid = validate_license((*license).as_ref()).is_ok(); + let valid = validate_license((license).as_ref()).is_ok(); Ok(ApiResponse { json: serde_json::json!({ "enabled": valid }), status: StatusCode::OK, }) } + +#[async_trait] +impl FromRequestParts for CanManageDevices +where + S: Send + Sync, + AppState: FromRef, +{ + type Rejection = WebError; + + /// Returns an error if current session user is not allowed to manage devices. + /// The permission is defined by [`EnterpriseSettings::admin_device_management`] setting. + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let appstate = AppState::from_ref(state); + let session = SessionInfo::from_request_parts(parts, state).await?; + let settings = EnterpriseSettings::get(&appstate.pool).await?; + if settings.admin_device_management && !session.is_admin { + Err(WebError::Forbidden( + "Only admin users can manage devices".into(), + )) + } else { + Ok(Self) + } + } +} diff --git a/src/enterprise/license.rs b/src/enterprise/license.rs index 4cffd1ea3..e2521d95f 100644 --- a/src/enterprise/license.rs +++ b/src/enterprise/license.rs @@ -1,4 +1,4 @@ -use std::sync::{Mutex, MutexGuard}; +use std::sync::{RwLock, RwLockReadGuard}; use anyhow::Result; use base64::prelude::*; @@ -14,17 +14,17 @@ use crate::{ server_config, }; -static LICENSE: Mutex> = Mutex::new(None); +static LICENSE: RwLock> = RwLock::new(None); pub fn set_cached_license(license: Option) { *LICENSE - .lock() + .write() .expect("Failed to acquire lock on the license mutex.") = license; } -pub fn get_cached_license() -> MutexGuard<'static, Option> { +pub fn get_cached_license() -> RwLockReadGuard<'static, Option> { LICENSE - .lock() + .read() .expect("Failed to acquire lock on the license mutex.") } diff --git a/src/handlers/app_info.rs b/src/handlers/app_info.rs index dc7f387e4..c0f60f693 100644 --- a/src/handlers/app_info.rs +++ b/src/handlers/app_info.rs @@ -26,7 +26,7 @@ pub(crate) async fn get_app_info( let networks = WireguardNetwork::all(&appstate.pool).await?; let settings = Settings::get_settings(&appstate.pool).await?; let license = get_cached_license(); - let enterprise = validate_license((*license).as_ref()).is_ok(); + let enterprise = validate_license((license).as_ref()).is_ok(); let res = AppInfo { network_present: !networks.is_empty(), smtp_enabled: settings.smtp_configured(), diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs index 16c167d68..00282c709 100644 --- a/src/handlers/settings.rs +++ b/src/handlers/settings.rs @@ -108,7 +108,7 @@ pub async fn patch_settings( session: SessionInfo, Json(data): Json, ) -> ApiResult { - debug!("Admin {} patching settings.", &session.user.username); + debug!("Admin {} patching settings.", session.user.username); let mut settings = Settings::get_settings(&appstate.pool).await?; // Handle updating the cached license @@ -119,7 +119,7 @@ pub async fn patch_settings( settings.apply(data); settings.save(&appstate.pool).await?; - info!("Admin {} patched settings.", &session.user.username); + info!("Admin {} patched settings.", session.user.username); Ok(ApiResponse::default()) } diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index 7fb332d8a..34a72f831 100644 --- a/src/handlers/wireguard.rs +++ b/src/handlers/wireguard.rs @@ -28,6 +28,7 @@ use crate::{ }, AddDevice, DbPool, Device, GatewayEvent, WireguardNetwork, }, + enterprise::handlers::CanManageDevices, grpc::GatewayMap, handlers::mail::send_new_device_added_email, server_config, @@ -517,6 +518,7 @@ pub struct AddDeviceResult { ) )] pub async fn add_device( + _can_manage_devices: CanManageDevices, session: SessionInfo, State(appstate): State, // Alias, because otherwise `axum` reports conflicting routes. @@ -663,6 +665,7 @@ pub async fn add_device( ) )] pub async fn modify_device( + _can_manage_devices: CanManageDevices, session: SessionInfo, Path(device_id): Path, State(appstate): State, @@ -785,6 +788,7 @@ pub async fn get_device( ) )] pub async fn delete_device( + _can_manage_devices: CanManageDevices, session: SessionInfo, Path(device_id): Path, State(appstate): State, diff --git a/src/lib.rs b/src/lib.rs index 224a31cfe..2778d2104 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,15 +13,16 @@ use axum::{ }; use enterprise::handlers::{ check_enterprise_status, + enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, openid_login::{auth_callback, get_auth_info}, openid_providers::{add_openid_provider, delete_openid_provider, get_current_openid_provider}, }; -use handlers::ssh_authorized_keys::{ - add_authentication_key, delete_authentication_key, fetch_authentication_keys, -}; use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, - ssh_authorized_keys::rename_authentication_key, + ssh_authorized_keys::{ + add_authentication_key, delete_authentication_key, fetch_authentication_keys, + rename_authentication_key, + }, yubikey::{delete_yubikey, rename_yubikey}, }; use ipnetwork::IpNetwork; @@ -157,10 +158,10 @@ mod openapi { AddDevice, UserDetails, UserInfo, }; use error::WebError; - use handlers::wireguard as device; use handlers::{ group::{self, BulkAssignToGroupsRequest, Groups}, user::{self, WalletInfoShort}, + wireguard as device, wireguard::AddDeviceResult, ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, StartEnrollmentRequest, Username, WalletChange, WalletSignature, @@ -388,6 +389,9 @@ pub fn build_webapp( .route("/settings/:id", put(set_default_branding)) // settings for frontend .route("/settings_essentials", get(get_settings_essentials)) + // enterprise settings + .route("/settings_enterprise", get(get_enterprise_settings)) + .route("/settings_enterprise", patch(patch_enterprise_settings)) // support .route("/support/configuration", get(configuration)) .route("/support/logs", get(logs)) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7eed5c45e..e92677388 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -2,7 +2,6 @@ pub(crate) mod client; use std::sync::{Arc, Mutex}; -use chrono::{DateTime, TimeZone, Utc}; use defguard::{ auth::failed_login::FailedLoginMap, build_webapp, diff --git a/tests/enterprise_settings.rs b/tests/enterprise_settings.rs new file mode 100644 index 000000000..3239870e8 --- /dev/null +++ b/tests/enterprise_settings.rs @@ -0,0 +1,217 @@ +mod common; + +use defguard::enterprise::{ + db::models::enterprise_settings::EnterpriseSettings, + license::{get_cached_license, set_cached_license}, +}; +use reqwest::StatusCode; + +use self::common::make_test_client; +use defguard::handlers::Auth; +use serde_json::{json, Value}; + +fn make_network() -> Value { + json!({ + "name": "network", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "allowed_groups": [], + "mfa_enabled": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 180 + }) +} + +#[tokio::test] +async fn test_only_enterprise_can_modify() { + // admin login + let (client, _client_state) = make_test_client().await; + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // try to patch enterprise settings + let settings = EnterpriseSettings { + id: None, + admin_device_management: true, + }; + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + + // server should say nono + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + + // server should say ok + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_admin_devices_management_is_enforced() { + // admin login + let (client, _) = make_test_client().await; + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // setup admin devices management + let settings = EnterpriseSettings { + id: None, + admin_device_management: true, + }; + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // make sure admin can still manage devices + let device = json!({ + "name": "device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // ensure normal users can't manage devices + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // add + let device = json!({ + "name": "userdevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // modify + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client.put("/api/v1/device/2").json(&device).send().await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // delete + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client.put("/api/v1/device/2").json(&device).send().await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_regular_user_device_management() { + // admin login + let (client, _) = make_test_client().await; + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // setup admin devices management + let settings = EnterpriseSettings { + id: None, + admin_device_management: false, + }; + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // make sure admin can manage devices + let device = json!({ + "name": "device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // ensure normal users can manage devices + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // add + let device = json!({ + "name": "userdevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // modify + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client.put("/api/v1/device/2").json(&device).send().await; + + assert_eq!(response.status(), StatusCode::OK); + + // delete + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client.put("/api/v1/device/2").json(&device).send().await; + + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/tests/openid_login.rs b/tests/openid_login.rs index 93c6936a0..89ac2b1b2 100644 --- a/tests/openid_login.rs +++ b/tests/openid_login.rs @@ -13,6 +13,7 @@ async fn make_client() -> TestClient { client } +#[allow(dead_code)] async fn make_client_v2(pool: DbPool, config: DefGuardConfig) -> TestClient { let (client, _) = make_base_client(pool, config).await; client diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index c0a4726cf..2ff545612 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -30,7 +30,7 @@ export const AppLoader = () => { getAppInfo, getEnterpriseStatus, user: { getMe }, - settings: { getEssentialSettings }, + settings: { getEssentialSettings, getEnterpriseSettings }, } = useApi(); const [userLoading, setUserLoading] = useState(true); const { setLocale } = useI18nContext(); @@ -81,6 +81,18 @@ export const AppLoader = () => { retry: false, }); + useQuery([QueryKeys.FETCH_ENTERPRISE_SETTINGS], getEnterpriseSettings, { + onSuccess: (settings) => { + setAppStore({ enterprise_settings: settings }); + }, + onError: (err) => { + // FIXME: Add a proper error message + toaster.error(LL.messages.errorVersion()); + console.error(err); + }, + refetchOnWindowFocus: false, + retry: false, + }); const { isLoading: settingsLoading, data: essentialSettings } = useQuery( [QueryKeys.FETCH_ESSENTIAL_SETTINGS], getEssentialSettings, diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 8ead81585..9af030058 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -892,6 +892,7 @@ const en: BaseTranslation = { global: 'Global settings', ldap: 'LDAP', openid: 'OpenID', + behavior: 'Behavior', }, messages: { editSuccess: 'Settings updated', @@ -1180,6 +1181,17 @@ const en: BaseTranslation = { }, }, }, + behavior: { + header: 'Behavior', + helper: '

Here you can change app behavior.

', + fields: { + deviceManagement: { + label: 'Disable users ability to manage their devices', + helper: + "When this option is enabled, only users in the Admin group can manage devices in user profile (it's disabled for all other users)", + }, + }, + }, }, openidOverview: { pageTitle: 'OpenID Apps', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 9e14eab00..a44dd172b 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -1946,7 +1946,7 @@ type RootTranslation = { support: string } /** - * C​o​p​y​r​i​g​h​t​ ​©​ ​2​0​2​3​ + * C​o​p​y​r​i​g​h​t​ ​©​2​0​2​3​-​2​0​2​4 */ copyright: string version: { @@ -1956,7 +1956,7 @@ type RootTranslation = { */ open: RequiredParams<'version'> /** - * v​ ​{​v​e​r​s​i​o​n​} + * v​{​v​e​r​s​i​o​n​} * @param {string} version */ closed: RequiredParams<'version'> @@ -2198,6 +2198,10 @@ type RootTranslation = { * O​p​e​n​I​D */ openid: string + /** + * B​e​h​a​v​i​o​r + */ + behavior: string } messages: { /** @@ -2801,6 +2805,28 @@ type RootTranslation = { } } } + behavior: { + /** + * B​e​h​a​v​i​o​r + */ + header: string + /** + * <​p​>​H​e​r​e​ ​y​o​u​ ​c​a​n​ ​c​h​a​n​g​e​ ​a​p​p​ ​b​e​h​a​v​i​o​r​.​<​/​p​> + */ + helper: string + fields: { + deviceManagement: { + /** + * D​i​s​a​b​l​e​ ​u​s​e​r​s​ ​a​b​i​l​i​t​y​ ​t​o​ ​m​a​n​a​g​e​ ​t​h​e​i​r​ ​d​e​v​i​c​e​s + */ + label: string + /** + * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​o​n​l​y​ ​u​s​e​r​s​ ​i​n​ ​t​h​e​ ​A​d​m​i​n​ ​g​r​o​u​p​ ​c​a​n​ ​m​a​n​a​g​e​ ​d​e​v​i​c​e​s​ ​i​n​ ​u​s​e​r​ ​p​r​o​f​i​l​e​ ​(​i​t​'​s​ ​d​i​s​a​b​l​e​d​ ​f​o​r​ ​a​l​l​ ​o​t​h​e​r​ ​u​s​e​r​s​) + */ + helper: string + } + } + } } openidOverview: { /** @@ -3599,8 +3625,7 @@ type RootTranslation = { * D​e​f​g​u​a​r​d​ ​r​e​q​u​i​r​e​s​ ​t​o​ ​d​e​p​l​o​y​ ​a​ ​g​a​t​e​w​a​y​ ​n​o​d​e​ ​t​o​ ​c​o​n​t​r​o​l​ ​w​i​r​e​g​u​a​r​d​ ​V​P​N​ ​o​n​ ​t​h​e​ ​v​p​n​ ​s​e​r​v​e​r​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​M​o​r​e​ ​d​e​t​a​i​l​s​ ​c​a​n​ ​b​e​ ​f​o​u​n​d​ ​i​n​ ​t​h​e​ ​[​d​o​c​u​m​e​n​t​a​t​i​o​n​]​(​{​s​e​t​u​p​G​a​t​e​w​a​y​D​o​c​s​}​)​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​T​h​e​r​e​ ​a​r​e​ ​s​e​v​e​r​a​l​ ​w​a​y​s​ ​t​o​ ​d​e​p​l​o​y​ ​t​h​e​ ​g​a​t​e​w​a​y​ ​s​e​r​v​e​r​,​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​b​e​l​o​w​ ​i​s​ ​a​ ​D​o​c​k​e​r​ ​b​a​s​e​d​ ​e​x​a​m​p​l​e​,​ ​f​o​r​ ​o​t​h​e​r​ ​e​x​a​m​p​l​e​s​ ​p​l​e​a​s​e​ ​v​i​s​i​t​ ​[​d​o​c​u​m​e​n​t​a​t​i​o​n​]​(​{​s​e​t​u​p​G​a​t​e​w​a​y​D​o​c​s​}​)​.​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ + ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​b​e​l​o​w​ ​i​s​ ​a​ ​D​o​c​k​e​r​ ​b​a​s​e​d​ ​e​x​a​m​p​l​e​,​ ​f​o​r​ ​o​t​h​e​r​ ​e​x​a​m​p​l​e​s​ ​p​l​e​a​s​e​ ​v​i​s​i​t​ ​[​d​o​c​u​m​e​n​t​a​t​i​o​n​]​(​{​s​e​t​u​p​G​a​t​e​w​a​y​D​o​c​s​}​)​. * @param {string} setupGatewayDocs */ runCommand: RequiredParams<'setupGatewayDocs' | 'setupGatewayDocs'> @@ -6048,7 +6073,7 @@ export type TranslationFunctions = { support: () => LocalizedString } /** - * Copyright © 2023 + * Copyright ©2023-2024 */ copyright: () => LocalizedString version: { @@ -6057,7 +6082,7 @@ export type TranslationFunctions = { */ open: (arg: { version: string }) => LocalizedString /** - * v {version} + * v{version} */ closed: (arg: { version: string }) => LocalizedString } @@ -6296,6 +6321,10 @@ export type TranslationFunctions = { * OpenID */ openid: () => LocalizedString + /** + * Behavior + */ + behavior: () => LocalizedString } messages: { /** @@ -6896,6 +6925,28 @@ export type TranslationFunctions = { } } } + behavior: { + /** + * Behavior + */ + header: () => LocalizedString + /** + *

Here you can change app behavior.

+ */ + helper: () => LocalizedString + fields: { + deviceManagement: { + /** + * Disable users ability to manage their devices + */ + label: () => LocalizedString + /** + * When this option is enabled, only users in the Admin group can manage devices in user profile (it's disabled for all other users) + */ + helper: () => LocalizedString + } + } + } } openidOverview: { /** @@ -7688,7 +7739,6 @@ export type TranslationFunctions = { More details can be found in the [documentation]({setupGatewayDocs}). There are several ways to deploy the gateway server, below is a Docker based example, for other examples please visit [documentation]({setupGatewayDocs}). - */ runCommand: (arg: { setupGatewayDocs: string }) => LocalizedString /** diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index df0ac2c6e..484b78e46 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -879,6 +879,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe global: 'Globalne', ldap: 'LDAP', openid: 'OpenID', + behavior: 'Zachowanie', }, messages: { editSuccess: 'Ustawienia zaktualizowane.', @@ -1167,6 +1168,17 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, }, }, + behavior: { + header: 'Zachowanie', + helper: '

Tutaj możesz zmienić zachowanie aplikacji.

', + fields: { + deviceManagement: { + label: 'Zablokuj możliwość zarządzania urządzeniami przez użytkowników', + helper: + 'Kiedy ta opcja jest włączona, tylko użytkownicy w grupie "Admin" mogą zarządzać urządzeniami w profilu użytkownika', + }, + }, + }, }, openidOverview: { pageTitle: 'Aplikacje OpenID', diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index c66e05973..7f2236322 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -12,6 +12,7 @@ import { CardTabsData } from '../../shared/defguard-ui/components/Layout/CardTab import { LoaderSpinner } from '../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; import useApi from '../../shared/hooks/useApi'; import { QueryKeys } from '../../shared/queries'; +import { BehaviorSettings } from './components/BehaviorSettings/BehaviorSettings'; import { GlobalSettings } from './components/GlobalSettings/GlobalSettings'; import { LdapSettings } from './components/LdapSettings/LdapSettings'; import { OpenIdSettings } from './components/OpenIdSettings/OpenIdSettings'; @@ -23,6 +24,7 @@ const tabsContent: ReactNode[] = [ , , , + , ]; export const SettingsPage = () => { @@ -47,8 +49,8 @@ export const SettingsPage = () => { refetchOnWindowFocus: false, }); - const tabs = useMemo((): CardTabsData[] => { - return [ + const tabs = useMemo( + (): CardTabsData[] => [ { key: 0, content: LL.settingsPage.tabs.global(), @@ -73,8 +75,15 @@ export const SettingsPage = () => { active: activeCard === 3, onClick: () => setActiveCard(3), }, - ]; - }, [LL.settingsPage.tabs, activeCard]); + { + key: 4, + content: LL.settingsPage.tabs.behavior(), + active: activeCard === 4, + onClick: () => setActiveCard(4), + }, + ], + [LL.settingsPage.tabs, activeCard], + ); // set store useEffect(() => { diff --git a/web/src/pages/settings/components/BehaviorSettings/BehaviorSettings.tsx b/web/src/pages/settings/components/BehaviorSettings/BehaviorSettings.tsx new file mode 100644 index 000000000..edddcd9aa --- /dev/null +++ b/web/src/pages/settings/components/BehaviorSettings/BehaviorSettings.tsx @@ -0,0 +1,33 @@ +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { useAppStore } from '../../../../shared/hooks/store/useAppStore'; +import { BehaviorForm } from './components/BehaviorForm'; + +export const BehaviorSettings = () => { + const enterpriseEnabled = useAppStore((state) => state.enterprise_enabled); + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.enterpriseOnly; + return ( + <> + {!enterpriseEnabled && ( +
+
+
+

{localLL.title()}

+

+ {localLL.subtitle()}{' '} + + {localLL.website()} + + . +

+
+
+
+ )} +
+ +
+
+ + ); +}; diff --git a/web/src/pages/settings/components/BehaviorSettings/components/BehaviorForm.tsx b/web/src/pages/settings/components/BehaviorSettings/components/BehaviorForm.tsx new file mode 100644 index 000000000..def9f8505 --- /dev/null +++ b/web/src/pages/settings/components/BehaviorSettings/components/BehaviorForm.tsx @@ -0,0 +1,68 @@ +import './styles.scss'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import parse from 'html-react-parser'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { Card } from '../../../../../shared/defguard-ui/components/Layout/Card/Card'; +import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import { LabeledCheckbox } from '../../../../../shared/defguard-ui/components/Layout/LabeledCheckbox/LabeledCheckbox'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { MutationKeys } from '../../../../../shared/mutations'; +import { QueryKeys } from '../../../../../shared/queries'; + +export const BehaviorForm = () => { + const { LL } = useI18nContext(); + const toaster = useToaster(); + const { + settings: { patchEnterpriseSettings }, + } = useApi(); + + const settings = useAppStore((state) => state.enterprise_settings); + + const queryClient = useQueryClient(); + + const { mutate, isLoading } = useMutation( + [MutationKeys.EDIT_SETTINGS], + patchEnterpriseSettings, + { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_ENTERPRISE_SETTINGS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + onError: (err: AxiosError) => { + toaster.error(LL.messages.error()); + console.error(err); + }, + }, + ); + + if (!settings) return null; + + return ( +
+
+

{LL.settingsPage.behavior.header()}

+ {parse(LL.settingsPage.behavior.helper())} +
+ +
+ + mutate({ admin_device_management: !settings.admin_device_management }) + } + /> + + {parse(LL.settingsPage.behavior.fields.deviceManagement.helper())} + +
+
+
+ ); +}; diff --git a/web/src/pages/settings/components/BehaviorSettings/components/styles.scss b/web/src/pages/settings/components/BehaviorSettings/components/styles.scss new file mode 100644 index 000000000..a5536bc53 --- /dev/null +++ b/web/src/pages/settings/components/BehaviorSettings/components/styles.scss @@ -0,0 +1,19 @@ +@use '@scssutils' as *; + +#behavior-settings { + & > .card { + display: flex; + flex-flow: column; + row-gap: 16px; + + @include media-breakpoint-up(lg) { + padding: 16px 15px; + } + + & > .checkbox-row { + display: flex; + align-items: center; + gap: 10px; + } + } +} diff --git a/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx b/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx index a630bf113..54e1407c5 100644 --- a/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx +++ b/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx @@ -37,9 +37,10 @@ const formatDate = (date: string): string => { interface Props { device: Device; + modifiable: boolean; } -export const DeviceCard = ({ device }: Props) => { +export const DeviceCard = ({ device, modifiable }: Props) => { const [hovered, setHovered] = useState(false); const [expanded, setExpanded] = useState(false); const { LL } = useI18nContext(); @@ -144,6 +145,7 @@ export const DeviceCard = ({ device }: Props) => { { setEditDeviceModal({ visible: true, @@ -171,6 +173,7 @@ export const DeviceCard = ({ device }: Props) => { setDeleteDeviceModal({ visible: true, diff --git a/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx b/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx index 13a7f02de..9280d473e 100644 --- a/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx +++ b/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx @@ -4,6 +4,7 @@ import Skeleton from 'react-loading-skeleton'; import { useNavigate } from 'react-router'; import { useI18nContext } from '../../../../i18n/i18n-react'; +import { isUserAdmin } from '../../../../shared/helpers/isUserAdmin'; import { useAppStore } from '../../../../shared/hooks/store/useAppStore'; import { useUserProfileStore } from '../../../../shared/hooks/store/useUserProfileStore'; import { useAddDevicePageStore } from '../../../addDevice/hooks/useAddDevicePageStore'; @@ -16,9 +17,14 @@ import { EditUserDeviceModal } from './modals/EditUserDeviceModal/EditUserDevice export const UserDevices = () => { const navigate = useNavigate(); const appInfo = useAppStore((state) => state.appInfo); + const settings = useAppStore((state) => state.enterprise_settings); const { LL } = useI18nContext(); const userProfile = useUserProfileStore((state) => state.userProfile); const initAddDevice = useAddDevicePageStore((state) => state.init); + const canManageDevices = !!( + userProfile && + (!settings?.admin_device_management || isUserAdmin(userProfile.user)) + ); return (
@@ -37,7 +43,11 @@ export const UserDevices = () => { {userProfile.devices && userProfile.devices.length > 0 && (
{userProfile.devices.map((device) => ( - + ))}
)} @@ -45,7 +55,7 @@ export const UserDevices = () => { { initAddDevice({ username: userProfile.user.username, diff --git a/web/src/shared/hooks/store/useAppStore.ts b/web/src/shared/hooks/store/useAppStore.ts index c97897575..7b53bc151 100644 --- a/web/src/shared/hooks/store/useAppStore.ts +++ b/web/src/shared/hooks/store/useAppStore.ts @@ -3,13 +3,14 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; import { Locales } from '../../../i18n/i18n-types'; -import { AppInfo, SettingsEssentials } from '../../types'; +import { AppInfo, SettingsEnterprise, SettingsEssentials } from '../../types'; const defaultValues: StoreValues = { settings: undefined, language: undefined, appInfo: undefined, enterprise_enabled: false, + enterprise_settings: undefined, }; const persistKeys: Array = ['language']; @@ -37,6 +38,7 @@ type StoreValues = { language?: Locales; appInfo?: AppInfo; enterprise_enabled?: boolean; + enterprise_settings?: SettingsEnterprise; }; type StoreMethods = { diff --git a/web/src/shared/hooks/useApi.tsx b/web/src/shared/hooks/useApi.tsx index 7b66824ce..659340bbc 100644 --- a/web/src/shared/hooks/useApi.tsx +++ b/web/src/shared/hooks/useApi.tsx @@ -429,6 +429,13 @@ const useApi = (props?: HookProps): ApiHook => { const getEssentialSettings: ApiHook['settings']['getEssentialSettings'] = () => client.get('/settings_essentials').then(unpackRequest); + const getEnterpriseSettings: ApiHook['settings']['getEnterpriseSettings'] = () => + client.get('/settings_enterprise').then(unpackRequest); + + const patchEnterpriseSettings: ApiHook['settings']['patchEnterpriseSettings'] = ( + data, + ) => client.patch('/settings_enterprise', data).then(unpackRequest); + const testLdapSettings: ApiHook['settings']['testLdapSettings'] = () => client.get('/ldap/test').then(unpackRequest); @@ -628,6 +635,8 @@ const useApi = (props?: HookProps): ApiHook => { setDefaultBranding: setDefaultBranding, patchSettings, getEssentialSettings, + getEnterpriseSettings, + patchEnterpriseSettings, testLdapSettings, fetchOpenIdProviders: fetchOpenIdProvider, addOpenIdProvider, diff --git a/web/src/shared/queries.ts b/web/src/shared/queries.ts index 7834db9ad..0d539d21a 100644 --- a/web/src/shared/queries.ts +++ b/web/src/shared/queries.ts @@ -29,4 +29,5 @@ export const QueryKeys = { FETCH_OPENID_PROVIDERS: 'FETCH_OPENID_PROVIDERS', FETCH_OPENID_INFO: 'FETCH_OPENID_INFO', FETCH_ENTERPRISE_STATUS: 'FETCH_ENTERPRISE_STATUS', + FETCH_ENTERPRISE_SETTINGS: 'FETCH_ENTERPRISE_SETTINGS', }; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index a89624e15..ba2df3019 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -586,6 +586,8 @@ export interface ApiHook { setDefaultBranding: (id: string) => Promise; patchSettings: (data: Partial) => EmptyApiResponse; getEssentialSettings: () => Promise; + getEnterpriseSettings: () => Promise; + patchEnterpriseSettings: (data: Partial) => EmptyApiResponse; testLdapSettings: () => Promise; fetchOpenIdProviders: () => Promise; addOpenIdProvider: (data: OpenIdProvider) => Promise; @@ -871,6 +873,10 @@ export type SettingsLicense = { license: string; }; +export type SettingsEnterprise = { + admin_device_management: boolean; +}; + export interface Webhook { id: string; url: string;