diff --git a/.sqlx/query-0d6fec510bbe64e1f9475b210efd9b910f589d8b8f15d11babd409a3fcbdd968.json b/.sqlx/query-0d6fec510bbe64e1f9475b210efd9b910f589d8b8f15d11babd409a3fcbdd968.json index 86a1c5d34..5a23aceb9 100644 --- a/.sqlx/query-0d6fec510bbe64e1f9475b210efd9b910f589d8b8f15d11babd409a3fcbdd968.json +++ b/.sqlx/query-0d6fec510bbe64e1f9475b210efd9b910f589d8b8f15d11babd409a3fcbdd968.json @@ -26,12 +26,12 @@ } } }, - "Text", + "Int4", "Bool", "Bool", "Bool", "Bool", - "Text", + "Int4", "Bool" ] }, diff --git a/.sqlx/query-394c7d22ad81bb8e379e36ff7aed125dadafdf69a5dfb5319bfc33c10eff7063.json b/.sqlx/query-394c7d22ad81bb8e379e36ff7aed125dadafdf69a5dfb5319bfc33c10eff7063.json index d2e8bbfe4..04d9e8c49 100644 --- a/.sqlx/query-394c7d22ad81bb8e379e36ff7aed125dadafdf69a5dfb5319bfc33c10eff7063.json +++ b/.sqlx/query-394c7d22ad81bb8e379e36ff7aed125dadafdf69a5dfb5319bfc33c10eff7063.json @@ -34,7 +34,7 @@ { "ordinal": 3, "name": "min_os_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 4, @@ -59,7 +59,7 @@ { "ordinal": 8, "name": "min_kernel_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 9, diff --git a/.sqlx/query-53bf9681753885fe1e261f1e7ed560c3971ca815eae5f94666151d14e1c8a7e2.json b/.sqlx/query-53bf9681753885fe1e261f1e7ed560c3971ca815eae5f94666151d14e1c8a7e2.json index ab8024e8d..9f47dc5b5 100644 --- a/.sqlx/query-53bf9681753885fe1e261f1e7ed560c3971ca815eae5f94666151d14e1c8a7e2.json +++ b/.sqlx/query-53bf9681753885fe1e261f1e7ed560c3971ca815eae5f94666151d14e1c8a7e2.json @@ -34,7 +34,7 @@ { "ordinal": 3, "name": "min_os_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 4, @@ -59,7 +59,7 @@ { "ordinal": 8, "name": "min_kernel_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 9, diff --git a/.sqlx/query-6362549c0bce9437168b99cfbe8082a1b723915ba74b649f3124e45496cf6a92.json b/.sqlx/query-6362549c0bce9437168b99cfbe8082a1b723915ba74b649f3124e45496cf6a92.json index d480e9f1a..2b9466b21 100644 --- a/.sqlx/query-6362549c0bce9437168b99cfbe8082a1b723915ba74b649f3124e45496cf6a92.json +++ b/.sqlx/query-6362549c0bce9437168b99cfbe8082a1b723915ba74b649f3124e45496cf6a92.json @@ -34,7 +34,7 @@ { "ordinal": 3, "name": "min_os_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 4, @@ -59,7 +59,7 @@ { "ordinal": 8, "name": "min_kernel_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 9, diff --git a/.sqlx/query-d27fc58c77a426371d02b6d6de5567ba8d71028e15208f3fa3906ad05f8fdec5.json b/.sqlx/query-d27fc58c77a426371d02b6d6de5567ba8d71028e15208f3fa3906ad05f8fdec5.json index bd04f3b48..d650efc76 100644 --- a/.sqlx/query-d27fc58c77a426371d02b6d6de5567ba8d71028e15208f3fa3906ad05f8fdec5.json +++ b/.sqlx/query-d27fc58c77a426371d02b6d6de5567ba8d71028e15208f3fa3906ad05f8fdec5.json @@ -21,12 +21,12 @@ } } }, - "Text", + "Int4", "Bool", "Bool", "Bool", "Bool", - "Text", + "Int4", "Bool" ] }, diff --git a/.sqlx/query-f694147f2e2d667ef7b438fb9140883ba7e43903204660b12c459c36a5ba7ee4.json b/.sqlx/query-f694147f2e2d667ef7b438fb9140883ba7e43903204660b12c459c36a5ba7ee4.json index b7b00ba44..91a6d79ec 100644 --- a/.sqlx/query-f694147f2e2d667ef7b438fb9140883ba7e43903204660b12c459c36a5ba7ee4.json +++ b/.sqlx/query-f694147f2e2d667ef7b438fb9140883ba7e43903204660b12c459c36a5ba7ee4.json @@ -34,7 +34,7 @@ { "ordinal": 3, "name": "min_os_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 4, @@ -59,7 +59,7 @@ { "ordinal": 8, "name": "min_kernel_version", - "type_info": "Text" + "type_info": "Int4" }, { "ordinal": 9, diff --git a/crates/defguard_core/src/enterprise/db/models/device_posture.rs b/crates/defguard_core/src/enterprise/db/models/device_posture.rs index 3472cefc2..29349ce55 100644 --- a/crates/defguard_core/src/enterprise/db/models/device_posture.rs +++ b/crates/defguard_core/src/enterprise/db/models/device_posture.rs @@ -35,7 +35,7 @@ pub struct DevicePostureOsRule { #[model(enum)] pub os_type: OsType, // shared - pub min_os_version: Option, + pub min_os_version: Option, // Windows, macOS, Linux pub disk_encryption_required: Option, // Windows only @@ -43,7 +43,7 @@ pub struct DevicePostureOsRule { pub ad_domain_joined_required: Option, pub windows_security_update_current: Option, // Linux only - pub min_kernel_version: Option, + pub min_kernel_version: Option, // macOS, iOS, Android only pub device_integrity_required: Option, } @@ -58,7 +58,8 @@ impl DevicePostureOsRule { Self, "SELECT id, posture_id, os_type \"os_type: OsType\", min_os_version, \ disk_encryption_required, antivirus_required, ad_domain_joined_required, \ - windows_security_update_current, min_kernel_version, device_integrity_required \ + windows_security_update_current, min_kernel_version, \ + device_integrity_required \ FROM device_posture_os_rule WHERE posture_id = $1", posture_id ) diff --git a/crates/defguard_core/src/enterprise/handlers/device_posture.rs b/crates/defguard_core/src/enterprise/handlers/device_posture.rs index 2aa67d065..0e0bace9f 100644 --- a/crates/defguard_core/src/enterprise/handlers/device_posture.rs +++ b/crates/defguard_core/src/enterprise/handlers/device_posture.rs @@ -32,27 +32,18 @@ use crate::{ /// Minimum defguard desktop client versions available for posture rules. /// FIXME: 2.0 does not actually exist, remove before release /// TODO: also consider if this is the best way to store possible options -pub static CLIENT_VERSIONS: &[&str] = &["1.6", "2.0"]; +pub const CLIENT_VERSIONS: &[&str] = &["1.6", "2.0"]; -/// Valid Linux kernel version families for posture rules. -pub static KERNEL_VERSIONS: &[&str] = &["5.x", "6.x", "7.x"]; +pub const WINDOWS_OS_VERSIONS: &[i32] = &[10, 11]; +pub const MACOS_OS_VERSIONS: &[i32] = &[13, 14, 15, 26]; +pub const IOS_OS_VERSIONS: &[i32] = &[17, 18, 26]; +pub const ANDROID_OS_VERSIONS: &[i32] = &[13, 14, 15, 16]; -/// Returns the list of valid `min_os_version` values for a given OS type. -/// TODO: consider a better format for storing versions -#[must_use] -pub fn valid_os_versions(os_type: &OsType) -> &'static [&'static str] { - match os_type { - OsType::Windows => &["Windows 10", "Windows 11"], - OsType::Macos => &[ - "macOS 13 Ventura", - "macOS 14 Sonoma", - "macOS 15 Sequoia", - "macOS 26 Tahoe", - ], - OsType::Linux => KERNEL_VERSIONS, - OsType::Ios => &["17", "18", "26"], - OsType::Android => &["13", "14", "15", "16"], - } +/// Valid Linux kernel major versions for posture rules. +pub const LINUX_KERNEL_VERSIONS: &[i32] = &[5, 6, 7]; + +fn owned_client_versions(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() } /// Per-OS rule included in a posture check policy API. @@ -63,26 +54,26 @@ pub fn valid_os_versions(os_type: &OsType) -> &'static [&'static str] { #[serde(tag = "os_type", rename_all = "lowercase")] pub enum ApiOsRule { Windows { - min_os_version: Option, + min_os_version: Option, disk_encryption_required: Option, antivirus_required: Option, ad_domain_joined_required: Option, windows_security_update_current: Option, }, Macos { - min_os_version: Option, + min_os_version: Option, disk_encryption_required: Option, device_integrity_required: Option, }, Linux { - min_kernel_version: Option, + min_kernel_version: Option, disk_encryption_required: Option, }, Ios { - min_os_version: Option, + min_os_version: Option, }, Android { - min_os_version: Option, + min_os_version: Option, device_integrity_required: Option, }, } @@ -240,6 +231,56 @@ impl From> for ApiDevicePosture { } } +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct DevicePostureOsVersionCatalog { + pub windows: Vec, + pub macos: Vec, + pub ios: Vec, + pub android: Vec, +} + +impl DevicePostureOsVersionCatalog { + #[must_use] + pub fn new() -> Self { + Self { + windows: WINDOWS_OS_VERSIONS.to_vec(), + macos: MACOS_OS_VERSIONS.to_vec(), + ios: IOS_OS_VERSIONS.to_vec(), + android: ANDROID_OS_VERSIONS.to_vec(), + } + } +} + +impl Default for DevicePostureOsVersionCatalog { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct DevicePostureVersionMetadata { + pub os_versions: DevicePostureOsVersionCatalog, + pub linux_kernel_versions: Vec, + pub client_versions: Vec, +} + +impl DevicePostureVersionMetadata { + #[must_use] + pub fn new() -> Self { + Self { + os_versions: DevicePostureOsVersionCatalog::new(), + linux_kernel_versions: LINUX_KERNEL_VERSIONS.to_vec(), + client_versions: owned_client_versions(CLIENT_VERSIONS), + } + } +} + +impl Default for DevicePostureVersionMetadata { + fn default() -> Self { + Self::new() + } +} + /// Request body for creating or updating a device posture check policy. #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct EditDevicePosture { @@ -372,14 +413,14 @@ fn apply_os_rule_filters( append_string_array_filter( query_builder, &versions, - &format!(" AND {alias}.min_os_version"), + &format!(" AND {alias}.min_os_version::text"), ); } OsType::Linux => { append_string_array_filter( query_builder, &versions, - &format!(" AND {alias}.min_kernel_version"), + &format!(" AND {alias}.min_kernel_version::text"), ); } } @@ -464,6 +505,68 @@ fn validate_device_posture_base(data: &EditDevicePosture) -> Result<(), WebError validate_device_posture_os_rules(&data.os_rules) } +fn validate_device_posture_os_rule(rule: &ApiOsRule) -> Result<(), WebError> { + let os_type = rule.os_type(); + + match rule { + ApiOsRule::Windows { + min_os_version: Some(v), + .. + } if !WINDOWS_OS_VERSIONS.contains(v) => Err(WebError::BadRequest(format!( + "Unknown min_os_version '{v}' for {os_type:?}. Valid values: {}", + WINDOWS_OS_VERSIONS + .iter() + .map(i32::to_string) + .collect::>() + .join(", ") + ))), + ApiOsRule::Macos { + min_os_version: Some(v), + .. + } if !MACOS_OS_VERSIONS.contains(v) => Err(WebError::BadRequest(format!( + "Unknown min_os_version '{v}' for {os_type:?}. Valid values: {}", + MACOS_OS_VERSIONS + .iter() + .map(i32::to_string) + .collect::>() + .join(", ") + ))), + ApiOsRule::Ios { + min_os_version: Some(v), + } if !IOS_OS_VERSIONS.contains(v) => Err(WebError::BadRequest(format!( + "Unknown min_os_version '{v}' for {os_type:?}. Valid values: {}", + IOS_OS_VERSIONS + .iter() + .map(i32::to_string) + .collect::>() + .join(", ") + ))), + ApiOsRule::Android { + min_os_version: Some(v), + .. + } if !ANDROID_OS_VERSIONS.contains(v) => Err(WebError::BadRequest(format!( + "Unknown min_os_version '{v}' for {os_type:?}. Valid values: {}", + ANDROID_OS_VERSIONS + .iter() + .map(i32::to_string) + .collect::>() + .join(", ") + ))), + ApiOsRule::Linux { + min_kernel_version: Some(kv), + .. + } if !LINUX_KERNEL_VERSIONS.contains(kv) => Err(WebError::BadRequest(format!( + "Unknown min_kernel_version '{kv}'. Valid values: {}", + LINUX_KERNEL_VERSIONS + .iter() + .map(i32::to_string) + .collect::>() + .join(", ") + ))), + _ => Ok(()), + } +} + /// Validates the `os_rules` list in an [`EditDevicePosture`] request. /// /// Returns `Err(WebError::BadRequest(...))` if: @@ -479,34 +582,7 @@ fn validate_device_posture_os_rules(os_rules: &[ApiOsRule]) -> Result<(), WebErr "Duplicate os_type '{os_type:?}' in os_rules" ))); } - let valid_versions = valid_os_versions(&os_type); - let min_os_version = match rule { - ApiOsRule::Windows { min_os_version, .. } - | ApiOsRule::Macos { min_os_version, .. } - | ApiOsRule::Ios { min_os_version } - | ApiOsRule::Android { min_os_version, .. } => min_os_version, - ApiOsRule::Linux { .. } => &None, - }; - if let Some(v) = min_os_version { - if !valid_versions.contains(&v.as_str()) { - return Err(WebError::BadRequest(format!( - "Unknown min_os_version '{v}' for {os_type:?}. Valid values: {}", - valid_versions.join(", ") - ))); - } - } - if let ApiOsRule::Linux { - min_kernel_version: Some(kv), - .. - } = rule - { - if !KERNEL_VERSIONS.contains(&kv.as_str()) { - return Err(WebError::BadRequest(format!( - "Unknown min_kernel_version '{kv}'. Valid values: {}", - KERNEL_VERSIONS.join(", ") - ))); - } - } + validate_device_posture_os_rule(rule)?; } Ok(()) } @@ -587,6 +663,41 @@ pub async fn create_device_posture( Ok(ApiResponse::json(response, StatusCode::CREATED)) } +#[utoipa::path( + get, + path = "/api/v1/device-posture/versions", + tag = "DevicePosture", + responses( + (status = 200, description = "Valid device posture OS and client versions", body = DevicePostureVersionMetadata), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - enterprise license required") + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +/// Return the backend-owned catalog of selectable posture-check versions. +/// +/// # Errors +/// +/// Returns an error when the requester is unauthorized or lacks the required license. +pub async fn get_device_posture_versions( + _license: EnterpriseLicenseInfo, + _admin: AdminRole, + session: SessionInfo, +) -> ApiResult { + debug!( + "User {} fetching device posture version metadata", + session.user.username + ); + + Ok(ApiResponse::json( + DevicePostureVersionMetadata::new(), + StatusCode::OK, + )) +} + #[utoipa::path( get, path = "/api/v1/device-posture", diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index a25dd3434..78cf0329f 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -111,8 +111,8 @@ use crate::{ check_enterprise_info, device_posture::{ create_device_posture, delete_device_posture, duplicate_device_posture, - get_device_posture, list_device_postures, set_locations_for_posture, - set_postures_for_location, update_device_posture, + get_device_posture, get_device_posture_versions, list_device_postures, + set_locations_for_posture, set_postures_for_location, update_device_posture, }, enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, openid_login::{auth_callback, get_auth_info}, @@ -536,6 +536,7 @@ pub fn build_webapp( let api_router = api_router.nest( "/api/v1", Router::new() + .route("/device-posture/versions", get(get_device_posture_versions)) .route( "/device-posture", get(list_device_postures).post(create_device_posture), diff --git a/crates/defguard_core/tests/integration/api/device_posture.rs b/crates/defguard_core/tests/integration/api/device_posture.rs index b90d0a7f8..f022830a1 100644 --- a/crates/defguard_core/tests/integration/api/device_posture.rs +++ b/crates/defguard_core/tests/integration/api/device_posture.rs @@ -1,10 +1,11 @@ use defguard_common::db::setup_pool; use defguard_core::{ enterprise::{ - db::models::device_posture::{DevicePosture, DevicePostureSnapshot, OsType}, + db::models::device_posture::{DevicePosture, DevicePostureSnapshot}, handlers::device_posture::{ - ApiDevicePosture, ApiOsRule, AssignLocationsData, AssignPosturesData, CLIENT_VERSIONS, - EditDevicePosture, valid_os_versions, + ANDROID_OS_VERSIONS, ApiDevicePosture, ApiOsRule, AssignLocationsData, + AssignPosturesData, CLIENT_VERSIONS, DevicePostureVersionMetadata, EditDevicePosture, + IOS_OS_VERSIONS, LINUX_KERNEL_VERSIONS, MACOS_OS_VERSIONS, WINDOWS_OS_VERSIONS, }, license::{get_cached_license, set_cached_license}, }, @@ -86,6 +87,31 @@ async fn test_device_posture_enterprise_license_required( set_cached_license(saved); } +#[sqlx::test] +async fn test_device_posture_versions_metadata(_: PgPoolOptions, options: PgConnectOptions) { + let (client, _) = setup(options).await; + + let response = client.get("/api/v1/device-posture/versions").send().await; + assert_eq!(response.status(), StatusCode::OK); + let metadata: DevicePostureVersionMetadata = response.json().await; + + assert_eq!(metadata.os_versions.windows, WINDOWS_OS_VERSIONS.to_vec()); + assert_eq!(metadata.os_versions.macos, MACOS_OS_VERSIONS.to_vec()); + assert_eq!( + metadata.linux_kernel_versions, + LINUX_KERNEL_VERSIONS.to_vec() + ); + assert_eq!(metadata.os_versions.ios, IOS_OS_VERSIONS.to_vec()); + assert_eq!(metadata.os_versions.android, ANDROID_OS_VERSIONS.to_vec()); + assert_eq!( + metadata.client_versions, + CLIENT_VERSIONS + .iter() + .map(|value| (*value).to_owned()) + .collect::>() + ); +} + #[sqlx::test] async fn test_device_posture_crud(_: PgPoolOptions, options: PgConnectOptions) { let (mut client, _) = setup(options).await; @@ -390,8 +416,8 @@ async fn test_device_posture_list_filters_os_and_defguard( ) { let (mut client, _) = setup(options).await; - let windows_version = valid_os_versions(&OsType::Windows)[0]; - let android_version = valid_os_versions(&OsType::Android)[2]; + let windows_version = WINDOWS_OS_VERSIONS[0]; + let android_version = ANDROID_OS_VERSIONS[2]; let filtered = EditDevicePosture { name: "Filtered posture".to_owned(), @@ -400,14 +426,14 @@ async fn test_device_posture_list_filters_os_and_defguard( allow_prerelease_client: true, os_rules: vec![ ApiOsRule::Windows { - min_os_version: Some(windows_version.to_owned()), + min_os_version: Some(windows_version), disk_encryption_required: Some(true), antivirus_required: Some(true), ad_domain_joined_required: None, windows_security_update_current: None, }, ApiOsRule::Android { - min_os_version: Some(android_version.to_owned()), + min_os_version: Some(android_version), device_integrity_required: Some(true), }, ], @@ -422,15 +448,21 @@ async fn test_device_posture_list_filters_os_and_defguard( let other = EditDevicePosture { name: "Other posture".to_owned(), description: None, - min_client_version: None, + min_client_version: Some(CLIENT_VERSIONS[0].to_owned()), allow_prerelease_client: false, - os_rules: vec![ApiOsRule::Windows { - min_os_version: Some(valid_os_versions(&OsType::Windows)[1].to_owned()), - disk_encryption_required: Some(false), - antivirus_required: Some(false), - ad_domain_joined_required: None, - windows_security_update_current: None, - }], + os_rules: vec![ + ApiOsRule::Windows { + min_os_version: Some(windows_version), + disk_encryption_required: Some(true), + antivirus_required: Some(true), + ad_domain_joined_required: None, + windows_security_update_current: None, + }, + ApiOsRule::Android { + min_os_version: Some(android_version), + device_integrity_required: Some(true), + }, + ], }; let response = client .post("/api/v1/device-posture") @@ -440,9 +472,18 @@ async fn test_device_posture_list_filters_os_and_defguard( assert_eq!(response.status(), StatusCode::CREATED); client.drain_all_events(); + let response = client + .get("/api/v1/device-posture?defguard=Pre-release%20allowed") + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let page: PaginatedApiResponse = response.json().await; + assert_eq!(page.data.len(), 1); + assert_eq!(page.data[0].name, "Filtered posture"); + let response = client .get( - "/api/v1/device-posture?windows=Windows%2010&windows=Disk%20encryption&windows=Antivirus&android=15&android=Device%20integrity&defguard=1.6&defguard=Prerelease%20allowed", + "/api/v1/device-posture?windows=10&windows=Disk%20encryption&windows=Antivirus&android=15&android=Device%20integrity&defguard=1.6&defguard=Pre-release%20allowed", ) .send() .await; @@ -456,8 +497,8 @@ async fn test_device_posture_list_filters_os_and_defguard( async fn test_device_posture_os_rules_create_and_get(_: PgPoolOptions, options: PgConnectOptions) { let (mut client, _) = setup(options).await; - let windows_version = valid_os_versions(&OsType::Windows)[0]; - let macos_version = valid_os_versions(&OsType::Macos)[0]; + let windows_version = WINDOWS_OS_VERSIONS[0]; + let macos_version = MACOS_OS_VERSIONS[0]; let edit = EditDevicePosture { name: "With Rules".to_owned(), @@ -466,14 +507,14 @@ async fn test_device_posture_os_rules_create_and_get(_: PgPoolOptions, options: allow_prerelease_client: false, os_rules: vec![ ApiOsRule::Windows { - min_os_version: Some(windows_version.to_owned()), + min_os_version: Some(windows_version), disk_encryption_required: Some(true), antivirus_required: Some(false), ad_domain_joined_required: None, windows_security_update_current: Some(true), }, ApiOsRule::Macos { - min_os_version: Some(macos_version.to_owned()), + min_os_version: Some(macos_version), disk_encryption_required: Some(true), device_integrity_required: Some(true), }, @@ -661,7 +702,7 @@ async fn test_device_posture_os_rules_validation(_: PgPoolOptions, options: PgCo min_client_version: None, allow_prerelease_client: false, os_rules: vec![ApiOsRule::Windows { - min_os_version: Some("Windows 7".to_owned()), + min_os_version: Some(7), disk_encryption_required: None, antivirus_required: None, ad_domain_joined_required: None, @@ -714,7 +755,7 @@ async fn test_device_posture_os_rules_validation(_: PgPoolOptions, options: PgCo min_client_version: None, allow_prerelease_client: false, os_rules: vec![ApiOsRule::Linux { - min_kernel_version: Some("4.x".to_owned()), + min_kernel_version: Some(4), disk_encryption_required: None, }], }; diff --git a/migrations/20260513122829_[2.1.0]_system_versions_to_numbers.down.sql b/migrations/20260513122829_[2.1.0]_system_versions_to_numbers.down.sql new file mode 100644 index 000000000..43d7af0a6 --- /dev/null +++ b/migrations/20260513122829_[2.1.0]_system_versions_to_numbers.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE device_posture_os_rule DROP COLUMN min_os_version; +ALTER TABLE device_posture_os_rule ADD COLUMN min_os_version text; + +ALTER TABLE device_posture_os_rule DROP COLUMN min_kernel_version; +ALTER TABLE device_posture_os_rule ADD COLUMN min_kernel_version text; diff --git a/migrations/20260513122829_[2.1.0]_system_versions_to_numbers.up.sql b/migrations/20260513122829_[2.1.0]_system_versions_to_numbers.up.sql new file mode 100644 index 000000000..ad68e928f --- /dev/null +++ b/migrations/20260513122829_[2.1.0]_system_versions_to_numbers.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE device_posture_os_rule DROP COLUMN min_os_version; +ALTER TABLE device_posture_os_rule ADD COLUMN min_os_version integer; + +ALTER TABLE device_posture_os_rule DROP COLUMN min_kernel_version; +ALTER TABLE device_posture_os_rule ADD COLUMN min_kernel_version integer; diff --git a/web/biome.json b/web/biome.json index bd43a3e51..94e6a383f 100644 --- a/web/biome.json +++ b/web/biome.json @@ -9,6 +9,7 @@ "ignoreUnknown": false, "includes": [ "src/**", + "tests/**", "!src/messages", "!src/paraglide/**/*.js", "!src/routeTree.gen.ts", diff --git a/web/messages/en/components.json b/web/messages/en/components.json index c674f4bd3..43547120e 100644 --- a/web/messages/en/components.json +++ b/web/messages/en/components.json @@ -180,6 +180,42 @@ "posture_checks_empty_title": "No posture checks added yet.", "posture_checks_empty_subtitle": "Add your first posture check to enhance access security.", "posture_checks_button_add": "Add new posture check", + "posture_checks_wizard_title": "Add posture check", + "posture_checks_wizard_subtitle": "To activate localization, make sure at least one Edge component is connected.", + "posture_checks_wizard_step_operating_systems": "Operating systems", + "posture_checks_wizard_step_operating_systems_description": "Define who can connect to your network.", + "posture_checks_wizard_operating_systems_title": "Select allowed operating systems", + "posture_checks_wizard_operating_systems_subtitle": "Only systems that match the rules below will be allowed to connect. All other connection attempts will be automatically denied.", + "posture_checks_wizard_operating_systems_windows_security_updates": "Check security updates", + "posture_checks_wizard_operating_systems_windows_security_updates_description": "Require devices to have the latest security updates installed.", + "posture_checks_wizard_operating_systems_security_conditions": "Security conditions", + "posture_checks_wizard_operating_systems_security_conditions_description": "Optionally define additional parameters for access control.", + "posture_checks_wizard_operating_systems_condition_active_directory": "Connected to Active Directory", + "posture_checks_wizard_operating_systems_condition_antivirus": "Antivirus installed", + "posture_checks_wizard_operating_systems_condition_disk_encryption": "Disk encryption enabled", + "posture_checks_wizard_operating_systems_condition_device_integrity": "Device integrity enabled", + "posture_checks_wizard_summary_defguard_label": "Defguard", + "posture_checks_wizard_summary_defguard_version": "Defguard {version} and higher", + "posture_checks_wizard_summary_prerelease": "Allow pre-release versions of the Defguard client.", + "posture_checks_wizard_summary_linux_version": "Kernel {version} and higher", + "posture_checks_wizard_summary_ios_version": "iOS {version} and higher", + "posture_checks_wizard_summary_android_version": "Android {version} and higher", + "posture_checks_wizard_step_client_version": "Defguard client version", + "posture_checks_wizard_step_client_version_description": "Define what version of the Defguard client you would like to support", + "posture_checks_wizard_client_version_note": "\u201cDefguard versions\u201d includes major releases as well as all subsequent patch updates with fixes and improvements.", + "posture_checks_wizard_client_version_option": "{version} and higher", + "posture_checks_wizard_client_version_prerelease_title": "Allow users who run pre-release versions of the Defguard client to access the system.", + "posture_checks_wizard_client_version_prerelease_description": "If you want to allow access for users testing pre-release (non-stable) versions of the Defguard client, enable this option.", + "posture_checks_wizard_step_details": "Name and description", + "posture_checks_wizard_step_details_description": "Name your posture check.", + "posture_checks_wizard_details_note": "Provide a clear and descriptive name for the posture check policy that makes its purpose easy to understand when it is applied, along with an optional short description that explains the context and conditions of the rule (e.g., Windows 11 restrictions, minimum Defguard version 2.0).", + "posture_checks_wizard_details_description_optional_label": "Description (optional)", + "posture_checks_wizard_step_summary": "Summary", + "posture_checks_wizard_step_summary_description": "Check your posture check settings before submitting.", + "posture_checks_wizard_summary_banner": "Only systems matching the conditions below will be allowed to access the VPN once this posture check is assigned to a location. All users who do not meet these requirements will be denied access.", + "posture_checks_wizard_confirm_modal_title": "Prevent admin lockout", + "posture_checks_wizard_confirm_modal_content": "Before applying this posture check, please confirm that at least one System Administrator will still meet all the required conditions.\n\nIf no administrator meets these requirements, admin access to the system may be lost.", + "posture_checks_wizard_summary_submit": "Create posture check", "cmp_video_support_launcher": "Video support", "cmp_video_support_list_label": "Video support", "cmp_video_tutorials_overlay_error": "Video unavailable", diff --git a/web/package.json b/web/package.json index 835b341be..bdb92f4a0 100644 --- a/web/package.json +++ b/web/package.json @@ -8,8 +8,8 @@ "build": "vite build && tsc -b", "preview": "vite preview", "biome": "biome", - "lint": "biome check ./src/ && prettier src/**/*.scss --check --log-level error && stylelint \"src/**/*.scss\" -c ./.stylelintrc.json && tsc -b", - "fix": "biome check ./src/ --write --unsafe && prettier src/**/*.scss -w --log-level silent", + "lint": "biome check ./src/ ./tests/ && prettier src/**/*.scss --check --log-level error && stylelint \"src/**/*.scss\" -c ./.stylelintrc.json && tsc -b", + "fix": "biome check ./src/ ./tests/ --write --unsafe && prettier src/**/*.scss -w --log-level silent", "tsc": "tsc", "test": "vitest run", "test:ui": "vitest --ui", diff --git a/web/src/pages/AddPostureCheckWizardPage/AddPostureCheckWizardPage.tsx b/web/src/pages/AddPostureCheckWizardPage/AddPostureCheckWizardPage.tsx new file mode 100644 index 000000000..47c32a98a --- /dev/null +++ b/web/src/pages/AddPostureCheckWizardPage/AddPostureCheckWizardPage.tsx @@ -0,0 +1,93 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useCallback, useEffect, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; +import type { WizardPageStep } from '../../shared/components/wizard/types'; +import { WizardPage } from '../../shared/components/wizard/WizardPage/WizardPage'; +import { getDevicePostureVersionMetadataQueryOptions } from '../../shared/query'; +import { closeAddPostureCheckWizard } from './navigation'; +import './style.scss'; +import { getPostureCheckVersionValues } from '../PostureChecksPage/types'; +import { AddPostureCheckClientVersionStep } from './steps/AddPostureCheckClientVersionStep'; +import { AddPostureCheckDetailsStep } from './steps/AddPostureCheckDetailsStep'; +import { AddPostureCheckOperatingSystemsStep } from './steps/AddPostureCheckOperatingSystemsStep'; +import { AddPostureCheckSummaryStep } from './steps/AddPostureCheckSummaryStep'; +import { AddPostureCheckWizardStep, type AddPostureCheckWizardStepValue } from './types'; +import { useAddPostureCheckWizardStore } from './useAddPostureCheckWizardStore'; + +export const AddPostureCheckWizardPage = () => { + const activeStep = useAddPostureCheckWizardStore((s) => s.activeStep); + const syncVersionValues = useAddPostureCheckWizardStore((s) => s.syncVersionValues); + const navigate = useNavigate(); + const { data: versionMetadata } = useSuspenseQuery( + getDevicePostureVersionMetadataQueryOptions, + ); + const versionValues = useMemo( + () => getPostureCheckVersionValues(versionMetadata), + [versionMetadata], + ); + + useEffect(() => { + syncVersionValues(versionValues); + }, [syncVersionValues, versionValues]); + + const onClose = useCallback(() => { + closeAddPostureCheckWizard(navigate); + }, [navigate]); + + const steps = useMemo( + (): Record => ({ + [AddPostureCheckWizardStep.OperatingSystems]: ( + + ), + [AddPostureCheckWizardStep.ClientVersion]: ( + + ), + [AddPostureCheckWizardStep.Details]: , + [AddPostureCheckWizardStep.Summary]: , + }), + [versionValues], + ); + + const stepsConfig = useMemo( + (): Record => ({ + [AddPostureCheckWizardStep.OperatingSystems]: { + id: AddPostureCheckWizardStep.OperatingSystems, + label: m.posture_checks_wizard_step_operating_systems(), + order: 0, + description: m.posture_checks_wizard_step_operating_systems_description(), + }, + [AddPostureCheckWizardStep.ClientVersion]: { + id: AddPostureCheckWizardStep.ClientVersion, + label: m.posture_checks_wizard_step_client_version(), + order: 1, + description: m.posture_checks_wizard_step_client_version_description(), + }, + [AddPostureCheckWizardStep.Details]: { + id: AddPostureCheckWizardStep.Details, + label: m.posture_checks_wizard_step_details(), + order: 2, + description: m.posture_checks_wizard_step_details_description(), + }, + [AddPostureCheckWizardStep.Summary]: { + id: AddPostureCheckWizardStep.Summary, + label: m.posture_checks_wizard_step_summary(), + order: 3, + description: m.posture_checks_wizard_step_summary_description(), + }, + }), + [], + ); + + return ( + + {steps[activeStep]} + + ); +}; diff --git a/web/src/pages/AddPostureCheckWizardPage/navigation.ts b/web/src/pages/AddPostureCheckWizardPage/navigation.ts new file mode 100644 index 000000000..c33a9d27f --- /dev/null +++ b/web/src/pages/AddPostureCheckWizardPage/navigation.ts @@ -0,0 +1,12 @@ +import type { useNavigate } from '@tanstack/react-router'; +import { useAddPostureCheckWizardStore } from './useAddPostureCheckWizardStore'; + +type NavigateFn = ReturnType; + +export const closeAddPostureCheckWizard = (navigate: NavigateFn) => { + void navigate({ to: '/acl/posture-checks', replace: true }).then(() => { + setTimeout(() => { + useAddPostureCheckWizardStore.getState().reset(); + }, 100); + }); +}; diff --git a/web/src/pages/AddPostureCheckWizardPage/operatingSystemVersionLabels.ts b/web/src/pages/AddPostureCheckWizardPage/operatingSystemVersionLabels.ts new file mode 100644 index 000000000..a96f385cd --- /dev/null +++ b/web/src/pages/AddPostureCheckWizardPage/operatingSystemVersionLabels.ts @@ -0,0 +1,23 @@ +import { policyOsVariantToText } from '../../shared/utils/policyPostures'; +import { + PostureCheckOs, + type PostureCheckOsValue, + type PostureCheckOsVersionValue, +} from '../PostureChecksPage/types'; + +export const getOperatingSystemVersionOptionLabel = ( + operatingSystem: PostureCheckOsValue, + value: PostureCheckOsVersionValue, +) => { + switch (operatingSystem) { + case PostureCheckOs.Windows: + case PostureCheckOs.Macos: + return `${policyOsVariantToText(operatingSystem)} ${value} or higher`; + case PostureCheckOs.Linux: + return `Kernel ${value} or higher`; + case PostureCheckOs.Ios: + return `iOS ${value} or higher`; + case PostureCheckOs.Android: + return `Android ${value} or higher`; + } +}; diff --git a/web/src/pages/AddPostureCheckWizardPage/payload.ts b/web/src/pages/AddPostureCheckWizardPage/payload.ts new file mode 100644 index 000000000..d1a6c8873 --- /dev/null +++ b/web/src/pages/AddPostureCheckWizardPage/payload.ts @@ -0,0 +1,95 @@ +import type { + EditDevicePostureOsRule, + EditDevicePostureRequest, +} from '../../shared/api/types'; +import { + type PostureCheckDefguardVersionValue, + PostureCheckOs, + type PostureCheckOsValue, +} from '../PostureChecksPage/types'; +import type { + OperatingSystemConditionKey, + OperatingSystemFormState, +} from './useAddPostureCheckWizardStore'; + +type BuildAddPostureCheckRequestInput = { + allowPrereleaseClient: boolean; + configuredOperatingSystems: PostureCheckOsValue[]; + description: string | null; + minimumClientVersion: PostureCheckDefguardVersionValue; + name: string; + operatingSystemState: Record; +}; + +const hasCondition = ( + conditions: OperatingSystemConditionKey[], + condition: OperatingSystemConditionKey, +) => conditions.includes(condition); + +const buildOperatingSystemRule = ( + operatingSystem: PostureCheckOsValue, + details: OperatingSystemFormState, +): EditDevicePostureOsRule => { + switch (operatingSystem) { + case PostureCheckOs.Windows: + return { + os_type: PostureCheckOs.Windows, + min_os_version: details.version, + disk_encryption_required: hasCondition(details.conditions, 'disk-encryption') + ? true + : null, + antivirus_required: hasCondition(details.conditions, 'antivirus') ? true : null, + ad_domain_joined_required: hasCondition(details.conditions, 'active-directory') + ? true + : null, + windows_security_update_current: details.securityUpdates ? true : null, + }; + case PostureCheckOs.Macos: + return { + os_type: PostureCheckOs.Macos, + min_os_version: details.version, + disk_encryption_required: hasCondition(details.conditions, 'disk-encryption') + ? true + : null, + device_integrity_required: hasCondition(details.conditions, 'device-integrity') + ? true + : null, + }; + case PostureCheckOs.Linux: + return { + os_type: PostureCheckOs.Linux, + min_kernel_version: details.version, + disk_encryption_required: hasCondition(details.conditions, 'disk-encryption') + ? true + : null, + }; + case PostureCheckOs.Ios: + return { + os_type: PostureCheckOs.Ios, + min_os_version: details.version, + }; + case PostureCheckOs.Android: + return { + os_type: PostureCheckOs.Android, + min_os_version: details.version, + device_integrity_required: hasCondition(details.conditions, 'device-integrity') + ? true + : null, + }; + } +}; + +export const buildAddPostureCheckRequest = ( + input: BuildAddPostureCheckRequestInput, +): EditDevicePostureRequest => ({ + name: input.name, + description: input.description, + min_client_version: input.minimumClientVersion, + allow_prerelease_client: input.allowPrereleaseClient, + os_rules: input.configuredOperatingSystems.map((operatingSystem) => + buildOperatingSystemRule( + operatingSystem, + input.operatingSystemState[operatingSystem], + ), + ), +}); diff --git a/web/src/pages/AddPostureCheckWizardPage/steps/AddPostureCheckClientVersionStep.tsx b/web/src/pages/AddPostureCheckWizardPage/steps/AddPostureCheckClientVersionStep.tsx new file mode 100644 index 000000000..da58f0e43 --- /dev/null +++ b/web/src/pages/AddPostureCheckWizardPage/steps/AddPostureCheckClientVersionStep.tsx @@ -0,0 +1,73 @@ +import { m } from '../../../paraglide/messages'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { AppText } from '../../../shared/defguard-ui/components/AppText/AppText'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { InteractiveBlock } from '../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; +import { Select } from '../../../shared/defguard-ui/components/Select/Select'; +import { TextStyle, ThemeVariable } from '../../../shared/defguard-ui/types'; +import type { PostureCheckVersionValues } from '../../PostureChecksPage/types'; +import { useAddPostureCheckWizardStore } from '../useAddPostureCheckWizardStore'; + +type Props = { + versionValues: PostureCheckVersionValues; +}; + +export const AddPostureCheckClientVersionStep = ({ versionValues }: Props) => { + const back = useAddPostureCheckWizardStore((s) => s.back); + const next = useAddPostureCheckWizardStore((s) => s.next); + const minimumClientVersion = useAddPostureCheckWizardStore( + (s) => s.minimumClientVersion, + ); + const allowPrereleaseClient = useAddPostureCheckWizardStore( + (s) => s.allowPrereleaseClient, + ); + const setMinimumClientVersion = useAddPostureCheckWizardStore( + (s) => s.setMinimumClientVersion, + ); + const setAllowPrereleaseClient = useAddPostureCheckWizardStore( + (s) => s.setAllowPrereleaseClient, + ); + + const versionOptions = versionValues.defguard.map((version) => ({ + key: version, + label: m.posture_checks_wizard_client_version_option({ version }), + value: version, + })); + + const selectedVersion = + versionOptions.find((option) => option.value === minimumClientVersion) ?? + versionOptions[versionOptions.length - 1]; + + return ( + +
+ + {m.posture_checks_wizard_client_version_note()} + + { + setName(String(value ?? '')); + }} + /> +