diff --git a/.sqlx/query-64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af.json b/.sqlx/query-349921ed69dd230404c584f7a63b963b2a4c12cc81d9ae89b4dc084aac8bb1dd.json similarity index 85% rename from .sqlx/query-64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af.json rename to .sqlx/query-349921ed69dd230404c584f7a63b963b2a4c12cc81d9ae89b4dc084aac8bb1dd.json index ab9b1a6d11..03335bca01 100644 --- a/.sqlx/query-64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af.json +++ b/.sqlx/query-349921ed69dd230404c584f7a63b963b2a4c12cc81d9ae89b4dc084aac8bb1dd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"metadata\" FROM \"activity_log_event\" WHERE id = $1", + "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\" WHERE id = $1", "describe": { "columns": [ { @@ -57,6 +57,11 @@ }, { "ordinal": 8, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 9, "name": "metadata", "type_info": "Jsonb" } @@ -75,8 +80,9 @@ false, false, false, + true, true ] }, - "hash": "64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af" + "hash": "349921ed69dd230404c584f7a63b963b2a4c12cc81d9ae89b4dc084aac8bb1dd" } diff --git a/.sqlx/query-2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46.json b/.sqlx/query-59f08f1fa140e1a77881c711928cbde209cee79eb178bd2e51474bcd7c8d315c.json similarity index 79% rename from .sqlx/query-2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46.json rename to .sqlx/query-59f08f1fa140e1a77881c711928cbde209cee79eb178bd2e51474bcd7c8d315c.json index 50e31a5ba3..e79f33a643 100644 --- a/.sqlx/query-2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46.json +++ b/.sqlx/query-59f08f1fa140e1a77881c711928cbde209cee79eb178bd2e51474bcd7c8d315c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"activity_log_event\" SET \"timestamp\" = $2,\"user_id\" = $3,\"username\" = $4,\"ip\" = $5,\"event\" = $6,\"module\" = $7,\"device\" = $8,\"metadata\" = $9 WHERE id = $1", + "query": "UPDATE \"activity_log_event\" SET \"timestamp\" = $2,\"user_id\" = $3,\"username\" = $4,\"ip\" = $5,\"event\" = $6,\"module\" = $7,\"device\" = $8,\"description\" = $9,\"metadata\" = $10 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -25,10 +25,11 @@ } }, "Text", + "Text", "Jsonb" ] }, "nullable": [] }, - "hash": "2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46" + "hash": "59f08f1fa140e1a77881c711928cbde209cee79eb178bd2e51474bcd7c8d315c" } diff --git a/.sqlx/query-a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b.json b/.sqlx/query-606ba16e8c352d0181f26f7ba7535b91f711eb51457f9e53e457a7d48c0b90c7.json similarity index 85% rename from .sqlx/query-a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b.json rename to .sqlx/query-606ba16e8c352d0181f26f7ba7535b91f711eb51457f9e53e457a7d48c0b90c7.json index ec581a84e5..10c5b4dba8 100644 --- a/.sqlx/query-a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b.json +++ b/.sqlx/query-606ba16e8c352d0181f26f7ba7535b91f711eb51457f9e53e457a7d48c0b90c7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"metadata\" FROM \"activity_log_event\"", + "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\"", "describe": { "columns": [ { @@ -57,6 +57,11 @@ }, { "ordinal": 8, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 9, "name": "metadata", "type_info": "Jsonb" } @@ -73,8 +78,9 @@ false, false, false, + true, true ] }, - "hash": "a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b" + "hash": "606ba16e8c352d0181f26f7ba7535b91f711eb51457f9e53e457a7d48c0b90c7" } diff --git a/.sqlx/query-87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24.json b/.sqlx/query-aeef18b50d9c1033ae21298811b22f406e1b45784899d385ad70db3ec76c5a59.json similarity index 81% rename from .sqlx/query-87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24.json rename to .sqlx/query-aeef18b50d9c1033ae21298811b22f406e1b45784899d385ad70db3ec76c5a59.json index 04cd520d81..ad2052277d 100644 --- a/.sqlx/query-87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24.json +++ b/.sqlx/query-aeef18b50d9c1033ae21298811b22f406e1b45784899d385ad70db3ec76c5a59.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"activity_log_event\" (\"timestamp\",\"user_id\",\"username\",\"ip\",\"event\",\"module\",\"device\",\"metadata\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id", + "query": "INSERT INTO \"activity_log_event\" (\"timestamp\",\"user_id\",\"username\",\"ip\",\"event\",\"module\",\"device\",\"description\",\"metadata\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id", "describe": { "columns": [ { @@ -30,6 +30,7 @@ } }, "Text", + "Text", "Jsonb" ] }, @@ -37,5 +38,5 @@ false ] }, - "hash": "87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24" + "hash": "aeef18b50d9c1033ae21298811b22f406e1b45784899d385ad70db3ec76c5a59" } diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index b373fc1dd0..67d8b683c5 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -2,25 +2,41 @@ use chrono::NaiveDateTime; use crate::{ db::{ - Device, Group, Id, MFAMethod, User, WebAuthn, WebHook, WireguardNetwork, + Device, Group, Id, MFAMethod, Settings, User, WebAuthn, WebHook, WireguardNetwork, models::{ authentication_key::{AuthenticationKey, AuthenticationKeyType}, oauth2client::OAuth2Client, + settings::{OpenidUsernameHandling, SmtpEncryption}, }, }, - enterprise::db::models::{ - activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, - api_tokens::ApiToken, - openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider}, + enterprise::{ + db::models::{ + activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, + api_tokens::ApiToken, + openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider}, + snat::UserSnatBinding, + }, + ldap::sync::SyncStatus, }, events::ClientMFAMethod, }; +#[derive(Serialize)] +pub struct LoginFailedMetadata { + pub message: String, +} + #[derive(Serialize)] pub struct MfaLoginMetadata { pub mfa_method: MFAMethod, } +#[derive(Serialize)] +pub struct MfaLoginFailedMetadata { + pub mfa_method: MFAMethod, + pub message: String, +} + #[derive(Serialize)] pub struct UserNoSecrets { pub id: Id, @@ -163,6 +179,14 @@ pub struct VpnClientMfaMetadata { pub method: ClientMFAMethod, } +#[derive(Serialize)] +pub struct VpnClientMfaFailedMetadata { + pub location: WireguardNetwork, + pub device: Device, + pub method: ClientMFAMethod, + pub message: String, +} + #[derive(Serialize)] pub struct EnrollmentDeviceAddedMetadata { pub device: Device, @@ -301,6 +325,130 @@ impl From> for OpenIdProviderNoSecrets { } } +#[derive(Serialize)] +pub struct SettingsUpdateMetadata { + pub before: SettingsNoSecrets, + pub after: SettingsNoSecrets, +} + +#[derive(Serialize)] +pub struct SettingsNoSecrets { + // Modules + pub openid_enabled: bool, + pub wireguard_enabled: bool, + pub webhooks_enabled: bool, + pub worker_enabled: bool, + // MFA + pub challenge_template: String, + // Branding + pub instance_name: String, + pub main_logo_url: String, + pub nav_logo_url: String, + // SMTP + pub smtp_server: Option, + pub smtp_port: Option, + pub smtp_encryption: SmtpEncryption, + pub smtp_user: Option, + pub smtp_sender: Option, + // Enrollment + pub enrollment_vpn_step_optional: bool, + pub enrollment_welcome_message: Option, + pub enrollment_welcome_email: Option, + pub enrollment_welcome_email_subject: Option, + pub enrollment_use_welcome_message_as_email: bool, + // LDAP + pub ldap_url: Option, + pub ldap_bind_username: Option, + pub ldap_group_search_base: Option, + pub ldap_user_search_base: Option, + // The structural user class + pub ldap_user_obj_class: Option, + // The structural group class + pub ldap_group_obj_class: Option, + pub ldap_username_attr: Option, + pub ldap_groupname_attr: Option, + pub ldap_group_member_attr: Option, + pub ldap_member_attr: Option, + pub ldap_use_starttls: bool, + pub ldap_tls_verify_cert: bool, + pub ldap_sync_status: SyncStatus, + pub ldap_enabled: bool, + pub ldap_sync_enabled: bool, + pub ldap_is_authoritative: bool, + pub ldap_uses_ad: bool, + pub ldap_sync_interval: i32, + // Additional object classes for users which determine the added attributes + pub ldap_user_auxiliary_obj_classes: Vec, + // The attribute which is used to map LDAP usernames to Defguard usernames + pub ldap_user_rdn_attr: Option, + pub ldap_sync_groups: Vec, + // Whether to create a new account when users try to log in with external OpenID + pub openid_create_account: bool, + pub openid_username_handling: OpenidUsernameHandling, + pub use_openid_for_mfa: bool, + pub license: Option, + // Gateway disconnect notifications + pub gateway_disconnect_notifications_enabled: bool, + pub gateway_disconnect_notifications_inactivity_threshold: i32, + pub gateway_disconnect_notifications_reconnect_notification_enabled: bool, +} + +impl From for SettingsNoSecrets { + fn from(value: Settings) -> Self { + Self { + openid_enabled: value.openid_enabled, + wireguard_enabled: value.wireguard_enabled, + webhooks_enabled: value.webhooks_enabled, + worker_enabled: value.worker_enabled, + challenge_template: value.challenge_template, + instance_name: value.instance_name, + main_logo_url: value.main_logo_url, + nav_logo_url: value.nav_logo_url, + smtp_server: value.smtp_server, + smtp_port: value.smtp_port, + smtp_encryption: value.smtp_encryption, + smtp_user: value.smtp_user, + smtp_sender: value.smtp_sender, + enrollment_vpn_step_optional: value.enrollment_vpn_step_optional, + enrollment_welcome_message: value.enrollment_welcome_message, + enrollment_welcome_email: value.enrollment_welcome_email, + enrollment_welcome_email_subject: value.enrollment_welcome_email_subject, + enrollment_use_welcome_message_as_email: value.enrollment_use_welcome_message_as_email, + ldap_url: value.ldap_url, + ldap_bind_username: value.ldap_bind_username, + ldap_group_search_base: value.ldap_group_search_base, + ldap_user_search_base: value.ldap_user_search_base, + ldap_user_obj_class: value.ldap_user_obj_class, + ldap_group_obj_class: value.ldap_group_obj_class, + ldap_username_attr: value.ldap_username_attr, + ldap_groupname_attr: value.ldap_groupname_attr, + ldap_group_member_attr: value.ldap_group_member_attr, + ldap_member_attr: value.ldap_member_attr, + ldap_use_starttls: value.ldap_use_starttls, + ldap_tls_verify_cert: value.ldap_tls_verify_cert, + ldap_sync_status: value.ldap_sync_status, + ldap_enabled: value.ldap_enabled, + ldap_sync_enabled: value.ldap_sync_enabled, + ldap_is_authoritative: value.ldap_is_authoritative, + ldap_uses_ad: value.ldap_uses_ad, + ldap_sync_interval: value.ldap_sync_interval, + ldap_user_auxiliary_obj_classes: value.ldap_user_auxiliary_obj_classes, + ldap_user_rdn_attr: value.ldap_user_rdn_attr, + ldap_sync_groups: value.ldap_sync_groups, + openid_create_account: value.openid_create_account, + openid_username_handling: value.openid_username_handling, + use_openid_for_mfa: value.use_openid_for_mfa, + license: value.license, + gateway_disconnect_notifications_enabled: value + .gateway_disconnect_notifications_enabled, + gateway_disconnect_notifications_inactivity_threshold: value + .gateway_disconnect_notifications_inactivity_threshold, + gateway_disconnect_notifications_reconnect_notification_enabled: value + .gateway_disconnect_notifications_reconnect_notification_enabled, + } + } +} + #[derive(Serialize)] pub struct GroupsBulkAssignedMetadata { pub users: Vec, @@ -384,7 +532,24 @@ pub struct PasswordResetMetadata { pub user: UserNoSecrets, } +#[derive(Serialize)] +pub struct UserMfaDisabledMetadata { + pub user: UserNoSecrets, +} + #[derive(Serialize)] pub struct ClientConfigurationTokenMetadata { pub user: UserNoSecrets, } +#[derive(Serialize)] +pub struct UserSnatBindingMetadata { + pub user: UserNoSecrets, + pub binding: UserSnatBinding, +} + +#[derive(Serialize)] +pub struct UserSnatBindingModifiedMetadata { + pub user: UserNoSecrets, + pub before: UserSnatBinding, + pub after: UserSnatBinding, +} diff --git a/crates/defguard_core/src/db/models/activity_log/mod.rs b/crates/defguard_core/src/db/models/activity_log/mod.rs index 5e9acaf278..02c83af448 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -34,6 +34,7 @@ pub enum EventType { UserLogout, // mfa management MfaDisabled, + UserMfaDisabled, MfaTotpDisabled, MfaTotpEnabled, MfaEmailDisabled, @@ -109,6 +110,10 @@ pub enum EventType { AuthenticationKeyAdded, AuthenticationKeyRemoved, AuthenticationKeyRenamed, + // User SNAT bindings management + UserSnatBindingAdded, + UserSnatBindingRemoved, + UserSnatBindingModified, } #[derive(Model, FromRow, Serialize)] @@ -124,5 +129,6 @@ pub struct ActivityLogEvent { #[model(enum)] pub module: ActivityLogModule, pub device: String, + pub description: Option, pub metadata: Option, } diff --git a/crates/defguard_core/src/db/models/authentication_key.rs b/crates/defguard_core/src/db/models/authentication_key.rs index e9271dc0f6..84ddd50183 100644 --- a/crates/defguard_core/src/db/models/authentication_key.rs +++ b/crates/defguard_core/src/db/models/authentication_key.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use model_derive::Model; use sqlx::{Error as SqlxError, PgExecutor, Type, query_as}; @@ -11,16 +13,25 @@ pub enum AuthenticationKeyType { Gpg, } -#[derive(Clone, Deserialize, Model, Serialize)] +impl Display for AuthenticationKeyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthenticationKeyType::Ssh => write!(f, "SSH"), + AuthenticationKeyType::Gpg => write!(f, "GPG"), + } + } +} + +#[derive(Clone, Debug, Deserialize, Model, Serialize)] #[table(authentication_key)] pub struct AuthenticationKey { pub(crate) id: I, pub(crate) yubikey_id: Option, - pub(crate) name: Option, + pub name: Option, pub(crate) user_id: Id, pub(crate) key: String, #[model(enum)] - pub(crate) key_type: AuthenticationKeyType, + pub key_type: AuthenticationKeyType, } impl AuthenticationKey { diff --git a/crates/defguard_core/src/db/models/oauth2client.rs b/crates/defguard_core/src/db/models/oauth2client.rs index b77ac0cb77..535bc8d628 100644 --- a/crates/defguard_core/src/db/models/oauth2client.rs +++ b/crates/defguard_core/src/db/models/oauth2client.rs @@ -7,7 +7,7 @@ use crate::{ random::gen_alphanumeric, }; -#[derive(Clone, Deserialize, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, Model, Serialize)] pub struct OAuth2Client { pub id: I, pub client_id: String, // unique diff --git a/crates/defguard_core/src/db/models/user.rs b/crates/defguard_core/src/db/models/user.rs index d16db8f455..37dbc38044 100644 --- a/crates/defguard_core/src/db/models/user.rs +++ b/crates/defguard_core/src/db/models/user.rs @@ -273,7 +273,7 @@ impl User { /// We assume the user is enrolled if they have a password set /// or they have logged in using an external OIDC. #[must_use] - pub(crate) fn is_enrolled(&self) -> bool { + pub fn is_enrolled(&self) -> bool { self.password_hash.is_some() || self.openid_sub.is_some() || self.from_ldap } @@ -885,6 +885,23 @@ impl User { .await } + /// Attempts to find user by username and then by email + /// of none is initially found + pub async fn find_by_username_or_email( + conn: &mut PgConnection, + username_or_email: &str, + ) -> Result, SqlxError> { + let maybe_user = Self::find_by_username(&mut *conn, username_or_email).await?; + match maybe_user { + Some(user) => Ok(Some(user)), + None => { + debug!( + "Failed to find user by username {username_or_email}. Attempting to find by email" + ); + Ok(Self::find_by_email(&mut *conn, username_or_email).await?) + } + } + } pub(crate) async fn find_many_by_emails<'e, E>( executor: E, emails: &[&str], diff --git a/crates/defguard_core/src/db/models/webauthn.rs b/crates/defguard_core/src/db/models/webauthn.rs index b435f9aebc..64a2c794c9 100644 --- a/crates/defguard_core/src/db/models/webauthn.rs +++ b/crates/defguard_core/src/db/models/webauthn.rs @@ -5,11 +5,11 @@ use webauthn_rs::prelude::Passkey; use super::error::ModelError; use crate::db::{Id, NoId}; -#[derive(Model, Clone)] +#[derive(Model, Clone, Debug)] pub struct WebAuthn { - pub(crate) id: I, - pub(crate) user_id: Id, - pub(crate) name: String, + pub id: I, + pub user_id: Id, + pub name: String, // serialize from/to [`Passkey`] pub passkey: Vec, } diff --git a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs index 4dd0556c83..86f3d8d238 100644 --- a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs @@ -4,7 +4,7 @@ use sqlx::{Error as SqlxError, PgExecutor, query_as}; use crate::db::{Id, NoId}; -#[derive(Clone, Deserialize, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, Model, Serialize)] #[table(api_token)] pub struct ApiToken { pub id: I, diff --git a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs index 524de02b2b..f39fb39a96 100644 --- a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs +++ b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs @@ -85,7 +85,7 @@ impl From for DirectorySyncTarget { } } -#[derive(Clone, Deserialize, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, Model, Serialize)] pub struct OpenIdProvider { pub id: I, pub name: String, diff --git a/crates/defguard_core/src/enterprise/db/models/snat.rs b/crates/defguard_core/src/enterprise/db/models/snat.rs index 6a56fc59cf..19448eed7b 100644 --- a/crates/defguard_core/src/enterprise/db/models/snat.rs +++ b/crates/defguard_core/src/enterprise/db/models/snat.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, query_as}; use utoipa::ToSchema; -#[derive(Debug, Deserialize, Model, Serialize, ToSchema)] +#[derive(Clone, Debug, Deserialize, Model, Serialize, ToSchema)] #[table(user_snat_binding)] pub struct UserSnatBinding { pub id: I, diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index 83d4ecedcf..9897fbd72f 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -83,6 +83,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method, + message: "provided invalid redirect URL".to_string(), }, )), })?; @@ -103,6 +104,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method, + message: format!("user {claims_user} tried to use OIDC MFA for another user: {user}") }, )), })?; @@ -123,6 +125,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method, + message: format!("failed to verify OIDC code: {err:?}"), }, )), })?; diff --git a/crates/defguard_core/src/enterprise/snat/handlers.rs b/crates/defguard_core/src/enterprise/snat/handlers.rs index f6e6cfde69..90b53f2b74 100644 --- a/crates/defguard_core/src/enterprise/snat/handlers.rs +++ b/crates/defguard_core/src/enterprise/snat/handlers.rs @@ -16,6 +16,7 @@ use crate::{ db::models::snat::UserSnatBinding, handlers::LicenseInfo, snat::error::UserSnatBindingError, }, error::WebError, + events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ApiResponse, ApiResult}, }; @@ -99,6 +100,7 @@ pub async fn create_snat_binding( _license: LicenseInfo, _admin_role: AdminRole, session: SessionInfo, + context: ApiRequestContext, Path(location_id): Path, State(appstate): State, Json(data): Json, @@ -109,12 +111,12 @@ pub async fn create_snat_binding( let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) .await? .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; - let _snat_user = User::find_by_id(&appstate.pool, data.user_id) + let snat_user = User::find_by_id(&appstate.pool, data.user_id) .await? .ok_or_else(|| WebError::ObjectNotFound(format!("User {} not found", data.user_id)))?; debug!( - "User {current_user} creating new SNAT binding for WireGuard location {location} with {data:?}" + "User {current_user} creating new SNAT binding for user {snat_user} in WireGuard location {location} with {data:?}" ); let snat_binding = UserSnatBinding::new(data.user_id, location.id, data.public_ip); @@ -124,6 +126,15 @@ pub async fn create_snat_binding( .await .map_err(UserSnatBindingError::from)?; + // emit event + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserSnatBindingAdded { + user: snat_user, + binding: binding.clone(), + }), + })?; + // trigger firewall config update on relevant gateways let mut conn = appstate.pool.acquire().await?; if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location.id).await? { @@ -178,24 +189,46 @@ pub async fn modify_snat_binding( _license: LicenseInfo, _admin_role: AdminRole, session: SessionInfo, + context: ApiRequestContext, Path((location_id, user_id)): Path<(Id, Id)>, State(appstate): State, Json(data): Json, ) -> ApiResult { let current_user = session.user.username; + // fetch relevant location & user + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + let snat_user = User::find_by_id(&appstate.pool, user_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("User {user_id} not found")))?; + debug!( - "User {current_user} updating SNAT binding for user {user_id} and WireGuard location {location_id} with {data:?}" + "User {current_user} updating SNAT binding for user {snat_user} and WireGuard location {location} with {data:?}", ); // fetch existing binding let mut snat_binding = UserSnatBinding::find_binding(&appstate.pool, location_id, user_id).await?; + // clone state before modifications + let before = snat_binding.clone(); + // update public IP snat_binding.update_ip(data.public_ip); snat_binding.save(&appstate.pool).await?; + // emit event + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserSnatBindingModified { + user: snat_user, + before, + after: snat_binding.clone(), + }), + })?; + // trigger firewall config update on relevant gateways let mut conn = appstate.pool.acquire().await?; if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location_id).await? { @@ -241,20 +274,38 @@ pub async fn delete_snat_binding( _license: LicenseInfo, _admin_role: AdminRole, session: SessionInfo, + context: ApiRequestContext, Path((location_id, user_id)): Path<(Id, Id)>, State(appstate): State, ) -> ApiResult { let current_user = session.user.username; + // fetch relevant location & user + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + let snat_user = User::find_by_id(&appstate.pool, user_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("User {user_id} not found")))?; + debug!( - "User {current_user} deleting SNAT binding for user {user_id} and WireGuard location {location_id}" + "User {current_user} deleting SNAT binding for user {snat_user} and WireGuard location {location}" ); // fetch existing binding let snat_binding = UserSnatBinding::find_binding(&appstate.pool, location_id, user_id).await?; // delete binding - snat_binding.delete(&appstate.pool).await?; + snat_binding.clone().delete(&appstate.pool).await?; + + // emit event + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserSnatBindingRemoved { + user: snat_user, + binding: snat_binding, + }), + })?; // trigger firewall config update on relevant gateways let mut conn = appstate.pool.acquire().await?; diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 4932d1422d..d4d6ccdcd3 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -5,12 +5,12 @@ use serde::Serialize; use crate::{ db::{ - Device, Group, Id, MFAMethod, User, WebAuthn, WebHook, WireguardNetwork, + Device, Group, Id, MFAMethod, Settings, User, WebAuthn, WebHook, WireguardNetwork, models::{authentication_key::AuthenticationKey, oauth2client::OAuth2Client}, }, enterprise::db::models::{ activity_log_stream::ActivityLogStream, api_tokens::ApiToken, - openid_provider::OpenIdProvider, + openid_provider::OpenIdProvider, snat::UserSnatBinding, }, grpc::proto::proxy::MfaMethod, }; @@ -75,15 +75,19 @@ impl GrpcRequestContext { } } +#[derive(Debug)] pub enum ApiEventType { UserLogin, + UserLoginFailed { + message: String, + }, UserLogout, - UserLoginFailed, UserMfaLogin { mfa_method: MFAMethod, }, UserMfaLoginFailed { mfa_method: MFAMethod, + message: String, }, RecoveryCodeUsed, PasswordChangedByAdmin { @@ -94,6 +98,9 @@ pub enum ApiEventType { user: User, }, MfaDisabled, + UserMfaDisabled { + user: User, + }, MfaTotpDisabled, MfaTotpEnabled, MfaEmailDisabled, @@ -194,8 +201,14 @@ pub enum ApiEventType { OpenIdProviderRemoved { provider: OpenIdProvider, }, - SettingsUpdated, - SettingsUpdatedPartial, + SettingsUpdated { + before: Settings, + after: Settings, + }, + SettingsUpdatedPartial { + before: Settings, + after: Settings, + }, SettingsDefaultBrandingRestored, GroupsBulkAssigned { users: Vec>, @@ -250,9 +263,23 @@ pub enum ApiEventType { ClientConfigurationTokenAdded { user: User, }, + UserSnatBindingAdded { + user: User, + binding: UserSnatBinding, + }, + UserSnatBindingRemoved { + user: User, + binding: UserSnatBinding, + }, + UserSnatBindingModified { + user: User, + before: UserSnatBinding, + after: UserSnatBinding, + }, } /// Events from Web API +#[derive(Debug)] pub struct ApiEvent { pub context: ApiRequestContext, pub event: Box, @@ -357,6 +384,7 @@ pub enum DesktopClientMfaEvent { device: Device, location: WireguardNetwork, method: ClientMFAMethod, + message: String, }, } diff --git a/crates/defguard_core/src/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/grpc/desktop_client_mfa.rs index faa8ff965c..09c94d03ea 100644 --- a/crates/defguard_core/src/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/grpc/desktop_client_mfa.rs @@ -292,6 +292,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method: *method, + message: "TOTP code not provided in request".to_string(), }, )), })?; @@ -306,6 +307,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method: *method, + message: "invalid TOTP code".to_string(), }, )), })?; @@ -324,6 +326,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method: *method, + message: "email MFA code not provided in request".to_string(), }, )), })?; @@ -338,6 +341,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method: *method, + message: "invalid email MFA code".to_string(), }, )), })?; @@ -356,6 +360,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method: *method, + message: "tried to finish OIDC MFA login but they haven't completed OIDC authentication yet".to_string() }, )), })?; diff --git a/crates/defguard_core/src/handlers/activity_log.rs b/crates/defguard_core/src/handlers/activity_log.rs index 609a8c4ae5..769b691ccb 100644 --- a/crates/defguard_core/src/handlers/activity_log.rs +++ b/crates/defguard_core/src/handlers/activity_log.rs @@ -103,7 +103,7 @@ pub struct ApiActivityLogEvent { pub event: String, pub module: ActivityLogModule, pub device: String, - pub metadata: Option, + pub description: Option, } // TODO: add utoipa API schema @@ -131,7 +131,7 @@ pub async fn get_activity_log_events( // start with base SELECT query // dummy WHERE filter is use to enable composable filtering let mut query_builder: QueryBuilder = QueryBuilder::new( - "SELECT id, timestamp, user_id, username, ip, event, module, device, metadata FROM activity_log_event WHERE 1=1 ", + "SELECT id, timestamp, user_id, username, ip, event, module, device, description FROM activity_log_event WHERE 1=1 ", ); // filter events for non-admin users to show only their own events @@ -224,9 +224,10 @@ fn apply_filters(query_builder: &mut QueryBuilder, filters: &FilterPar // - module // - event // - device + // - description if let Some(search_term) = &filters.search { query_builder - .push(" AND CONCAT(username, ' ', module, ' ', event, ' ', device, ' ') ILIKE ") + .push(" AND CONCAT(username, ' ', module, ' ', event, ' ', device, ' ', description, ' ') ILIKE ") .push_bind(format!("%{search_term}%")) .push(" "); } diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index c755394a86..6924f5c760 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -141,122 +141,81 @@ pub(crate) async fn authenticate( check_failed_logins(&appstate.failed_logins, &username_or_email)?; let settings = Settings::get_current_settings(); - let mut user = match User::find_by_username(&appstate.pool, &username_or_email).await { - Ok(Some(user)) => match user.verify_password(&data.password) { - Ok(()) => user, - Err(err) => { - if settings.ldap_enabled { - match login_through_ldap(&appstate.pool, &username_or_email, &data.password) - .await - { - Ok(user) => user, - Err(err) => { - info!( - "Failed to authenticate user {username_or_email} through LDAP: {err}" - ); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); - appstate.emit_event(ApiEvent { - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: Box::new(ApiEventType::UserLoginFailed), - })?; - return Err(WebError::Authorization(err.to_string())); - } - } - } else { - info!("Failed to authenticate user {username_or_email}: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); - appstate.emit_event(ApiEvent { - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: Box::new(ApiEventType::UserLoginFailed), - })?; - return Err(WebError::Authorization(err.to_string())); - } - } - }, - Ok(None) => { - match User::find_by_email(&appstate.pool, &username_or_email).await { - Ok(Some(user)) => match user.verify_password(&data.password) { - Ok(()) => user, - Err(err) => { - if settings.ldap_enabled { - if let Ok(user) = - login_through_ldap(&appstate.pool, &user.username, &data.password) - .await - { - user - } else { - info!("Failed to authenticate user {username_or_email}: {err}"); + // attempt to find user first by username and then by email + let mut conn = appstate.pool.acquire().await?; + let mut user = match User::find_by_username_or_email(&mut conn, &username_or_email).await? { + Some(user) => { + // user was found, attempt to authenticate by password first + match user.verify_password(&data.password) { + Ok(()) => user, + Err(err) => { + // password authentication failed, try authenticating with LDAP if configured + if settings.ldap_enabled { + match login_through_ldap(&appstate.pool, &username_or_email, &data.password) + .await + { + Ok(user) => user, + Err(ldap_err) => { + warn!( + "Failed to authenticate user {username_or_email} internally and through LDAP. Internal error: {err}, LDAP error: {ldap_err}" + ); + log_failed_login_attempt( &appstate.failed_logins, &username_or_email, ); appstate.emit_event(ApiEvent { - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: Box::new(ApiEventType::UserLoginFailed), - })?; - return Err(WebError::Authorization(err.to_string())); - } - } else { - info!("Failed to authenticate user {username_or_email}: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); - appstate.emit_event(ApiEvent { context: ApiRequestContext::new( user.id, user.username, insecure_ip, user_agent.to_string(), ), - event: Box::new(ApiEventType::UserLoginFailed), + event: Box::new(ApiEventType::UserLoginFailed { + message: format!( + "Internal and LDAP authentication for {username_or_email} failed. Internal error: {err}, LDAP error: {ldap_err}" + ), + }), })?; - return Err(WebError::Authorization(err.to_string())); - } - } - }, - Ok(None) => { - // create user from LDAP - debug!( - "User not found in DB, authenticating user {username_or_email} with LDAP" - ); - match login_through_ldap(&appstate.pool, &username_or_email, &data.password) - .await - { - Ok(user) => user, - Err(err) => { - info!( - "Failed to authenticate user {username_or_email} with LDAP: {err}" - ); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); - return Err(WebError::Authorization(err.to_string())); + return Err(WebError::Authorization(ldap_err.to_string())); + } } + } else { + warn!("Failed to authenticate user {username_or_email}: {err}"); + log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + appstate.emit_event(ApiEvent { + context: ApiRequestContext::new( + user.id, + user.username, + insecure_ip, + user_agent.to_string(), + ), + event: Box::new(ApiEventType::UserLoginFailed { + message: format!( + "Authentication for {username_or_email} failed: {err}" + ), + }), + })?; + return Err(WebError::Authorization(err.to_string())); } } + } + } + None => { + // try to create user from LDAP + debug!("User not found in DB, authenticating user {username_or_email} with LDAP"); + match login_through_ldap(&appstate.pool, &username_or_email, &data.password).await { + Ok(user) => user, Err(err) => { - error!("DB error when authenticating user {username_or_email}: {err}"); - return Err(WebError::DbError(err.to_string())); + info!("Failed to authenticate user {username_or_email} with LDAP: {err}"); + log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + return Err(WebError::Authorization(err.to_string())); } } } - Err(err) => { - error!("DB error when authenticating user {username_or_email}: {err}"); - return Err(WebError::DbError(err.to_string())); - } }; + // check if user account is active if !user.is_active { info!("Failed to authenticate user {username_or_email}: user is disabled"); return Err(WebError::Authorization("user not found".into())); @@ -420,9 +379,9 @@ pub async fn disable_user_mfa( user.disable_mfa(&appstate.pool).await?; appstate.emit_event(ApiEvent { context, - event: Box::new(ApiEventType::MfaDisabled), + event: Box::new(ApiEventType::UserMfaDisabled { user }), })?; - info!("Disabled MFA for user {}", user.username); + info!("Disabled MFA for user {username}"); Ok(ApiResponse::default()) } @@ -563,84 +522,93 @@ pub async fn webauthn_end( Json(pubkey): Json, ) -> Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(passkey_auth) = session.get_passkey_authentication() { - if let Ok(auth_result) = appstate + match appstate .webauthn .finish_passkey_authentication(&pubkey, &passkey_auth) { - if auth_result.needs_update() { - // Find `Passkey` and try to update its credentials - for mut webauthn in WebAuthn::all_for_user(&appstate.pool, session.user_id).await? { - if let Some(true) = webauthn.passkey()?.update_credential(&auth_result) { - webauthn.save(&appstate.pool).await?; + Ok(auth_result) => { + if auth_result.needs_update() { + // Find `Passkey` and try to update its credentials + for mut webauthn in + WebAuthn::all_for_user(&appstate.pool, session.user_id).await? + { + if let Some(true) = webauthn.passkey()?.update_credential(&auth_result) { + webauthn.save(&appstate.pool).await?; + } } } - } - session - .set_state(&appstate.pool, SessionState::MultiFactorVerified) - .await?; - return if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { - let user_info = UserInfo::from_user(&appstate.pool, &user).await?; - appstate.emit_event(ApiEvent { - // User may not be fully authenticated so we can't use - // context extractor in this handler since it requires - // the `SessionInfo` object. - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: Box::new(ApiEventType::UserMfaLogin { - mfa_method: MFAMethod::Webauthn, - }), - })?; - if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { - debug!("Found OpenID session cookie."); - let redirect_url = openid_cookie.value().to_string(); - let private_cookies = private_cookies.remove(openid_cookie); - Ok(( - private_cookies, - ApiResponse { - json: json!(AuthResponse { - user: user_info, - url: Some(redirect_url), - }), - status: StatusCode::OK, - }, - )) + session + .set_state(&appstate.pool, SessionState::MultiFactorVerified) + .await?; + + return if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? + { + let user_info = UserInfo::from_user(&appstate.pool, &user).await?; + appstate.emit_event(ApiEvent { + // User may not be fully authenticated so we can't use + // context extractor in this handler since it requires + // the `SessionInfo` object. + context: ApiRequestContext::new( + user.id, + user.username, + insecure_ip, + user_agent.to_string(), + ), + event: Box::new(ApiEventType::UserMfaLogin { + mfa_method: MFAMethod::Webauthn, + }), + })?; + + if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { + debug!("Found OpenID session cookie."); + let redirect_url = openid_cookie.value().to_string(); + let private_cookies = private_cookies.remove(openid_cookie); + Ok(( + private_cookies, + ApiResponse { + json: json!(AuthResponse { + user: user_info, + url: Some(redirect_url), + }), + status: StatusCode::OK, + }, + )) + } else { + Ok(( + private_cookies, + ApiResponse { + json: json!(AuthResponse { + user: user_info, + url: None, + }), + status: StatusCode::OK, + }, + )) + } } else { - Ok(( - private_cookies, - ApiResponse { - json: json!(AuthResponse { - user: user_info, - url: None, - }), - status: StatusCode::OK, - }, - )) + Ok((private_cookies, ApiResponse::default())) + }; + } + Err(err) => { + // authentication failed, emit relevant event + if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { + appstate.emit_event(ApiEvent { + // User may not be fully authenticated so we can't use + // context extractor in this handler since it requires + // the `SessionInfo` object. + context: ApiRequestContext::new( + user.id, + user.username, + insecure_ip, + user_agent.to_string(), + ), + event: Box::new(ApiEventType::UserMfaLoginFailed { + mfa_method: MFAMethod::Webauthn, + message: format!("Passkey authentication failed: {err}"), + }), + })?; } - } else { - Ok((private_cookies, ApiResponse::default())) - }; - } else { - // authentication failed, emit relevant event - if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { - appstate.emit_event(ApiEvent { - // User may not be fully authenticated so we can't use - // context extractor in this handler since it requires - // the `SessionInfo` object. - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: Box::new(ApiEventType::UserMfaLoginFailed { - mfa_method: MFAMethod::Webauthn, - }), - })?; } } } @@ -774,6 +742,12 @@ pub async fn totp_code( )) } } else { + let message = if user.totp_enabled { + "TOTP code verification failed".to_string() + } else { + format!("TOTP authentication is disabled for {username}") + }; + appstate.emit_event(ApiEvent { // User may not be fully authenticated so we can't use // context extractor in this handler since it requires @@ -786,6 +760,7 @@ pub async fn totp_code( ), event: Box::new(ApiEventType::UserMfaLoginFailed { mfa_method: MFAMethod::OneTimePassword, + message, }), })?; Err(WebError::Authorization("Invalid TOTP code".into())) @@ -949,6 +924,12 @@ pub async fn email_mfa_code( )) } } else { + let message = if user.email_mfa_enabled { + "Email code verification failed".to_string() + } else { + format!("Email code authentication is disabled for {username}") + }; + appstate.emit_event(ApiEvent { // User may not be fully authenticated so we can't use // context extractor in this handler since it requires @@ -961,6 +942,7 @@ pub async fn email_mfa_code( ), event: Box::new(ApiEventType::UserMfaLoginFailed { mfa_method: MFAMethod::Email, + message, }), })?; Err(WebError::Authorization("Invalid email MFA code".into())) diff --git a/crates/defguard_core/src/handlers/settings.rs b/crates/defguard_core/src/handlers/settings.rs index 5b8f9d295f..77e4d754de 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -54,14 +54,20 @@ pub async fn update_settings( ) -> ApiResult { debug!("User {} updating settings", session.user.username); + // fetch current settings for event + let before = Settings::get_current_settings(); + update_cached_license(data.license.as_deref())?; data.validate()?; + // clone for event + let after = data.clone(); + update_current_settings(&appstate.pool, data).await?; info!("User {} updated settings", session.user.username); appstate.emit_event(ApiEvent { context, - event: Box::new(ApiEventType::SettingsUpdated), + event: Box::new(ApiEventType::SettingsUpdated { before, after }), })?; Ok(ApiResponse::default()) @@ -132,6 +138,8 @@ pub async fn patch_settings( session.user.username ); let mut settings = Settings::get_current_settings(); + // prepare clone for emitting an event + let before = settings.clone(); // Handle updating the cached license if let Some(license_key) = &data.license { @@ -159,12 +167,14 @@ pub async fn patch_settings( settings.apply(data); settings.validate()?; + // clone for event + let after = settings.clone(); update_current_settings(&appstate.pool, settings).await?; info!("Admin {} patched settings.", session.user.username); appstate.emit_event(ApiEvent { context, - event: Box::new(ApiEventType::SettingsUpdatedPartial), + event: Box::new(ApiEventType::SettingsUpdatedPartial { before, after }), })?; Ok(ApiResponse::default()) } diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs new file mode 100644 index 0000000000..0d8266955d --- /dev/null +++ b/crates/defguard_event_logger/src/description.rs @@ -0,0 +1,282 @@ +//! Event description generation for activity log. +//! +//! This module provides functions to generate human-readable descriptions for various +//! types of events that occur within the system. These descriptions are used to provide usable +//! context about what happened during each event. +//! +//! Each event type has its own description generator function that takes the event data +//! and returns an optional description string. Some events may not require additional +//! description beyond their event type name, in which case `None` is returned. + +use crate::message::{DefguardEvent, EnrollmentEvent, VpnEvent}; + +pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { + match event { + DefguardEvent::UserLogin => None, + DefguardEvent::UserLoginFailed { message } => { + Some(format!("User login failed with: {message}")) + } + DefguardEvent::UserMfaLogin { mfa_method } => { + Some(format!("User logged in using {mfa_method}")) + } + DefguardEvent::UserMfaLoginFailed { + mfa_method, + message, + } => Some(format!( + "User login using {mfa_method} failed with: {message}" + )), + DefguardEvent::UserLogout => None, + DefguardEvent::RecoveryCodeUsed => None, + DefguardEvent::PasswordChanged => None, + DefguardEvent::MfaDisabled => Some("Disabled own MFA".to_string()), + DefguardEvent::UserMfaDisabled { user } => Some(format!("Disabled MFA for user {user}")), + DefguardEvent::MfaTotpEnabled => Some("User configured TOTP for MFA".to_string()), + DefguardEvent::MfaTotpDisabled => Some("User disabled TOTP for MFA".to_string()), + DefguardEvent::MfaEmailEnabled => Some("User configured email for MFA".to_string()), + DefguardEvent::MfaEmailDisabled => Some("User disabled email for MFA".to_string()), + DefguardEvent::PasswordChangedByAdmin { user } => { + Some(format!("Password for user {user} was changed by an admin")) + } + DefguardEvent::PasswordReset { user } => { + Some(format!("Password for user {user} was reset")) + } + DefguardEvent::MfaSecurityKeyAdded { key } => { + Some(format!("Added MFA security key {}", key.name)) + } + DefguardEvent::MfaSecurityKeyRemoved { key } => { + Some(format!("Removed MFA security key {}", key.name)) + } + DefguardEvent::UserAdded { user } => { + let self_enrollment_enabled = !user.is_enrolled(); + let enrollment_flag_text = if self_enrollment_enabled { + "enabled" + } else { + "disabled" + }; + Some(format!( + "Added user {user} with email {} and self-enrollment {enrollment_flag_text}", + user.email + )) + } + DefguardEvent::UserRemoved { user } => Some(format!("Removed user {user}")), + DefguardEvent::UserModified { before, after } => { + let mut description = format!("Modified user {after}"); + + // check if status has changed + if before.is_active != after.is_active { + let status_change_text = if after.is_active { + "enabled" + } else { + "disabled" + }; + description = format!("{description}, status changed to {status_change_text}"); + }; + Some(description) + } + DefguardEvent::UserDeviceAdded { owner, device } => { + Some(format!("Added device {device} for user {owner}")) + } + DefguardEvent::UserDeviceRemoved { owner, device } => { + Some(format!("Removed device {device} owned by user {owner}")) + } + DefguardEvent::UserDeviceModified { + owner, + before: _, + after, + } => Some(format!("Modified device {after} owned by user {owner}")), + DefguardEvent::NetworkDeviceAdded { device, location } => Some(format!( + "Added network device {device} to location {location}" + )), + DefguardEvent::NetworkDeviceRemoved { device, location } => Some(format!( + "Removed network device {device} from location {location}" + )), + DefguardEvent::NetworkDeviceModified { + before: _, + after, + location, + } => Some(format!( + "Modified network device {after} in location {location}" + )), + DefguardEvent::ActivityLogStreamCreated { stream } => Some(format!( + "Created {} activity log stream {}", + stream.stream_type, stream.name + )), + DefguardEvent::ActivityLogStreamModified { before: _, after } => Some(format!( + "Modified {} activity log stream {}", + after.stream_type, after.name + )), + DefguardEvent::ActivityLogStreamRemoved { stream } => Some(format!( + "Removed {} activity log stream {}", + stream.stream_type, stream.name + )), + DefguardEvent::VpnLocationAdded { location } => { + Some(format!("Added VPN location {location}")) + } + DefguardEvent::VpnLocationRemoved { location } => { + Some(format!("Removed VPN location {location}")) + } + DefguardEvent::VpnLocationModified { before: _, after } => { + Some(format!("VPN location {after} was modified")) + } + DefguardEvent::ApiTokenAdded { owner, token } => { + Some(format!("Added API token {} for user {owner}", token.name)) + } + DefguardEvent::ApiTokenRemoved { owner, token } => Some(format!( + "Removed API token {} owned by user {owner}", + token.name + )), + DefguardEvent::ApiTokenRenamed { + owner, + token: _, + old_name, + new_name, + } => Some(format!( + "API token owned by user {owner} was renamed from {old_name} to {new_name}", + )), + DefguardEvent::OpenIdAppAdded { app } => { + Some(format!("Added OpenID application {}", app.name)) + } + DefguardEvent::OpenIdAppRemoved { app } => { + Some(format!("Removed OpenID application {}", app.name)) + } + DefguardEvent::OpenIdAppModified { before: _, after } => { + Some(format!("Modified OpenID application {}", after.name)) + } + DefguardEvent::OpenIdAppStateChanged { app, enabled } => { + let state = if *enabled { "Enabled" } else { "Disabled" }; + Some(format!("{} OpenID application {}", state, app.name)) + } + DefguardEvent::OpenIdProviderModified { provider } => { + Some(format!("Modified OpenID provider {}", provider.name)) + } + DefguardEvent::OpenIdProviderRemoved { provider } => { + Some(format!("Removed OpenID provider {}", provider.name)) + } + DefguardEvent::SettingsUpdated { + before: _, + after: _, + } => None, + DefguardEvent::SettingsUpdatedPartial { + before: _, + after: _, + } => None, + DefguardEvent::SettingsDefaultBrandingRestored => { + Some("Restored default branding settings".to_string()) + } + DefguardEvent::GroupsBulkAssigned { users, groups } => Some(format!( + "Assigned {} users to {} groups", + users.len(), + groups.len() + )), + DefguardEvent::GroupAdded { group } => Some(format!("Added group {}", group.name)), + DefguardEvent::GroupModified { before: _, after } => { + Some(format!("Modified group {}", after.name)) + } + DefguardEvent::GroupRemoved { group } => Some(format!("Removed group {}", group.name)), + DefguardEvent::GroupMemberAdded { group, user } => { + Some(format!("Added user {user} to group {}", group.name)) + } + DefguardEvent::GroupMemberRemoved { group, user } => { + Some(format!("Removed user {user} from group {}", group.name)) + } + DefguardEvent::WebHookAdded { webhook } => { + Some(format!("Added webhook with URL {}", webhook.url)) + } + DefguardEvent::WebHookModified { before: _, after } => { + Some(format!("Modified webhook with URL {}", after.url)) + } + DefguardEvent::WebHookRemoved { webhook } => { + Some(format!("Removed webhook with ULR {}", webhook.url)) + } + DefguardEvent::WebHookStateChanged { webhook, enabled } => { + let state = if *enabled { "Enabled" } else { "Disabled" }; + Some(format!("{} webhook with URL {}", state, webhook.url)) + } + DefguardEvent::AuthenticationKeyAdded { key } => Some(format!( + "Added {} authentication key {}", + key.key_type, + key.name.clone().unwrap_or_default() + )), + DefguardEvent::AuthenticationKeyRemoved { key } => Some(format!( + "Removed {} authentication key {}", + key.key_type, + key.name.clone().unwrap_or_default() + )), + DefguardEvent::AuthenticationKeyRenamed { + key, + old_name, + new_name, + } => Some(format!( + "Renamed {} authentication key from {} to {}", + key.key_type, + old_name.clone().unwrap_or_default(), + new_name.clone().unwrap_or_default() + )), + DefguardEvent::ClientConfigurationTokenAdded { user } => { + Some(format!("Added client configuration token for user {user}",)) + } + DefguardEvent::UserSnatBindingAdded { user, binding } => Some(format!( + "Devices owned by user {user} bound to public IP {}", + binding.public_ip + )), + DefguardEvent::UserSnatBindingRemoved { user, binding } => Some(format!( + "Removed public IP {} binding for user {user}", + binding.public_ip + )), + DefguardEvent::UserSnatBindingModified { + user, + before, + after, + } => Some(format!( + "Public IP bound to devices owned by user {user} changed from {} to {}", + before.public_ip, after.public_ip + )), + } +} + +pub fn get_vpn_event_description(event: &VpnEvent) -> Option { + match event { + VpnEvent::ConnectedToMfaLocation { + location, + device, + method, + } => Some(format!( + "Device {device} connected to MFA location {location} using {method}" + )), + VpnEvent::DisconnectedFromMfaLocation { location, device } => Some(format!( + "Device {device} disconnected from MFA location {location}" + )), + VpnEvent::MfaFailed { + location, + device, + method, + message, + } => Some(format!( + "Device {device} failed to connect to MFA location {location} using {method} with: {message}" + )), + VpnEvent::ConnectedToLocation { location, device } => { + Some(format!("Device {device} connected to location {location}")) + } + VpnEvent::DisconnectedFromLocation { location, device } => Some(format!( + "Device {device} disconnected from location {location}" + )), + } +} + +pub fn get_enrollment_event_description(event: &EnrollmentEvent) -> Option { + match event { + EnrollmentEvent::EnrollmentStarted => Some("User started enrollment process".to_string()), + EnrollmentEvent::EnrollmentDeviceAdded { device } => { + Some(format!("Added device {} during enrollment", device.name)) + } + EnrollmentEvent::EnrollmentCompleted => { + Some("User completed enrollment process".to_string()) + } + EnrollmentEvent::PasswordResetRequested => None, + EnrollmentEvent::PasswordResetStarted => None, + EnrollmentEvent::PasswordResetCompleted => None, + EnrollmentEvent::TokenAdded { user } => { + Some(format!("Added enrollment token for user {user}")) + } + } +} diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 55b0b58e2e..1164e5949c 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -8,16 +8,21 @@ use defguard_core::db::{ ApiTokenRenamedMetadata, AuthenticationKeyMetadata, AuthenticationKeyRenamedMetadata, ClientConfigurationTokenMetadata, DeviceMetadata, DeviceModifiedMetadata, EnrollmentDeviceAddedMetadata, EnrollmentTokenMetadata, GroupAssignedMetadata, - GroupMetadata, GroupModifiedMetadata, GroupsBulkAssignedMetadata, MfaLoginMetadata, - MfaSecurityKeyMetadata, NetworkDeviceMetadata, NetworkDeviceModifiedMetadata, - OpenIdAppMetadata, OpenIdAppModifiedMetadata, OpenIdAppStateChangedMetadata, - OpenIdProviderMetadata, PasswordChangedByAdminMetadata, PasswordResetMetadata, - UserMetadata, UserModifiedMetadata, VpnClientMetadata, VpnClientMfaMetadata, - VpnLocationMetadata, VpnLocationModifiedMetadata, WebHookMetadata, - WebHookModifiedMetadata, WebHookStateChangedMetadata, + GroupMetadata, GroupModifiedMetadata, GroupsBulkAssignedMetadata, LoginFailedMetadata, + MfaLoginFailedMetadata, MfaLoginMetadata, MfaSecurityKeyMetadata, + NetworkDeviceMetadata, NetworkDeviceModifiedMetadata, OpenIdAppMetadata, + OpenIdAppModifiedMetadata, OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, + PasswordChangedByAdminMetadata, PasswordResetMetadata, SettingsUpdateMetadata, + UserMetadata, UserMfaDisabledMetadata, UserModifiedMetadata, UserSnatBindingMetadata, + UserSnatBindingModifiedMetadata, VpnClientMetadata, VpnClientMfaFailedMetadata, + VpnClientMfaMetadata, VpnLocationMetadata, VpnLocationModifiedMetadata, + WebHookMetadata, WebHookModifiedMetadata, WebHookStateChangedMetadata, }, }, }; +use description::{ + get_defguard_event_description, get_enrollment_event_description, get_vpn_event_description, +}; use error::EventLoggerError; use message::{ DefguardEvent, EnrollmentEvent, EventContext, EventLoggerMessage, LoggerEvent, VpnEvent, @@ -26,6 +31,7 @@ use sqlx::PgPool; use tokio::sync::mpsc::UnboundedReceiver; use tracing::{debug, error, info, trace}; +pub mod description; pub mod error; pub mod message; @@ -68,20 +74,31 @@ pub async fn run_event_logger( // Convert each message to a related activity log event let activity_log_event = { - let (module, event, metadata) = match message.event { + let (module, event, description, metadata) = match message.event { LoggerEvent::Defguard(event) => { let module = ActivityLogModule::Defguard; + let description = get_defguard_event_description(&event); let (event_type, metadata) = match *event { DefguardEvent::UserLogin => (EventType::UserLogin, None), - DefguardEvent::UserLoginFailed => (EventType::UserLoginFailed, None), + DefguardEvent::UserLoginFailed { message } => ( + EventType::UserLoginFailed, + serde_json::to_value(LoginFailedMetadata { message }).ok(), + ), DefguardEvent::UserMfaLogin { mfa_method } => ( EventType::UserMfaLogin, serde_json::to_value(MfaLoginMetadata { mfa_method }).ok(), ), - DefguardEvent::UserMfaLoginFailed { mfa_method } => ( + DefguardEvent::UserMfaLoginFailed { + mfa_method, + message, + } => ( EventType::UserMfaLoginFailed, - serde_json::to_value(MfaLoginMetadata { mfa_method }).ok(), + serde_json::to_value(MfaLoginFailedMetadata { + mfa_method, + message, + }) + .ok(), ), DefguardEvent::UserLogout => (EventType::UserLogout, None), DefguardEvent::UserDeviceAdded { owner, device } => ( @@ -123,6 +140,11 @@ pub async fn run_event_logger( .ok(), ), DefguardEvent::MfaDisabled => (EventType::MfaDisabled, None), + DefguardEvent::UserMfaDisabled { user } => ( + EventType::UserMfaDisabled, + serde_json::to_value(UserMfaDisabledMetadata { user: user.into() }) + .ok(), + ), DefguardEvent::MfaTotpEnabled => (EventType::MfaTotpEnabled, None), DefguardEvent::MfaTotpDisabled => (EventType::MfaTotpDisabled, None), DefguardEvent::MfaEmailEnabled => (EventType::MfaEmailEnabled, None), @@ -281,10 +303,22 @@ pub async fn run_event_logger( }) .ok(), ), - DefguardEvent::SettingsUpdated => (EventType::SettingsUpdated, None), - DefguardEvent::SettingsUpdatedPartial => { - (EventType::SettingsUpdatedPartial, None) - } + DefguardEvent::SettingsUpdatedPartial { before, after } => ( + EventType::SettingsUpdatedPartial, + serde_json::to_value(SettingsUpdateMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), + DefguardEvent::SettingsUpdated { before, after } => ( + EventType::SettingsUpdated, + serde_json::to_value(SettingsUpdateMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), DefguardEvent::SettingsDefaultBrandingRestored => { (EventType::SettingsDefaultBrandingRestored, None) } @@ -379,27 +413,55 @@ pub async fn run_event_logger( }) .ok(), ), - DefguardEvent::EnrollmentTokenAdded { user } => ( - EventType::EnrollmentTokenAdded, - serde_json::to_value(EnrollmentTokenMetadata { user: user.into() }) - .ok(), + DefguardEvent::UserSnatBindingAdded { user, binding } => ( + EventType::UserSnatBindingAdded, + serde_json::to_value(UserSnatBindingMetadata { + user: user.into(), + binding, + }) + .ok(), + ), + DefguardEvent::UserSnatBindingRemoved { user, binding } => ( + EventType::UserSnatBindingRemoved, + serde_json::to_value(UserSnatBindingMetadata { + user: user.into(), + binding, + }) + .ok(), + ), + DefguardEvent::UserSnatBindingModified { + user, + before, + after, + } => ( + EventType::UserSnatBindingModified, + serde_json::to_value(UserSnatBindingModifiedMetadata { + user: user.into(), + before, + after, + }) + .ok(), ), }; - (module, event_type, metadata) + (module, event_type, description, metadata) } LoggerEvent::Vpn(event) => { let module = ActivityLogModule::Vpn; + let description = get_vpn_event_description(&event); + let (event_type, metadata) = match *event { VpnEvent::MfaFailed { location, device, method, + message, } => ( EventType::VpnClientMfaFailed, - serde_json::to_value(VpnClientMfaMetadata { + serde_json::to_value(VpnClientMfaFailedMetadata { location, device, method, + message, }) .ok(), ), @@ -429,10 +491,12 @@ pub async fn run_event_logger( serde_json::to_value(VpnClientMetadata { location, device }).ok(), ), }; - (module, event_type, metadata) + (module, event_type, description, metadata) } LoggerEvent::Enrollment(event) => { let module = ActivityLogModule::Enrollment; + let description = get_enrollment_event_description(&event); + let (event_type, metadata) = match *event { EnrollmentEvent::EnrollmentStarted => { (EventType::EnrollmentStarted, None) @@ -459,7 +523,7 @@ pub async fn run_event_logger( .ok(), ), }; - (module, event_type, metadata) + (module, event_type, description, metadata) } }; @@ -472,6 +536,7 @@ pub async fn run_event_logger( event, module, device, + description, metadata, } }; diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index ae8fb58802..e8beadc036 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -3,12 +3,12 @@ use std::net::IpAddr; use chrono::NaiveDateTime; use defguard_core::{ db::{ - Device, Group, Id, MFAMethod, User, WebAuthn, WebHook, WireguardNetwork, + Device, Group, Id, MFAMethod, Settings, User, WebAuthn, WebHook, WireguardNetwork, models::{authentication_key::AuthenticationKey, oauth2client::OAuth2Client}, }, enterprise::db::models::{ activity_log_stream::ActivityLogStream, api_tokens::ApiToken, - openid_provider::OpenIdProvider, + openid_provider::OpenIdProvider, snat::UserSnatBinding, }, events::{ ApiRequestContext, BidiRequestContext, ClientMFAMethod, GrpcRequestContext, @@ -95,13 +95,16 @@ impl From for EventContext { /// Represents activity log events related to actions performed in Web UI pub enum DefguardEvent { UserLogin, + UserLoginFailed { + message: String, + }, UserLogout, - UserLoginFailed, UserMfaLogin { mfa_method: MFAMethod, }, UserMfaLoginFailed { mfa_method: MFAMethod, + message: String, }, RecoveryCodeUsed, PasswordChangedByAdmin { @@ -112,6 +115,9 @@ pub enum DefguardEvent { user: User, }, MfaDisabled, + UserMfaDisabled { + user: User, + }, MfaTotpDisabled, MfaTotpEnabled, MfaEmailDisabled, @@ -212,8 +218,14 @@ pub enum DefguardEvent { OpenIdProviderRemoved { provider: OpenIdProvider, }, - SettingsUpdated, - SettingsUpdatedPartial, + SettingsUpdated { + before: Settings, + after: Settings, + }, + SettingsUpdatedPartial { + before: Settings, + after: Settings, + }, SettingsDefaultBrandingRestored, GroupsBulkAssigned { users: Vec>, @@ -262,11 +274,21 @@ pub enum DefguardEvent { old_name: Option, new_name: Option, }, - EnrollmentTokenAdded { + ClientConfigurationTokenAdded { user: User, }, - ClientConfigurationTokenAdded { + UserSnatBindingAdded { + user: User, + binding: UserSnatBinding, + }, + UserSnatBindingRemoved { + user: User, + binding: UserSnatBinding, + }, + UserSnatBindingModified { user: User, + before: UserSnatBinding, + after: UserSnatBinding, }, } @@ -291,6 +313,7 @@ pub enum VpnEvent { location: WireguardNetwork, device: Device, method: ClientMFAMethod, + message: String, }, ConnectedToLocation { location: WireguardNetwork, diff --git a/crates/defguard_event_router/src/handlers/api.rs b/crates/defguard_event_router/src/handlers/api.rs index 10a22b2aaf..ed0dfeab0d 100644 --- a/crates/defguard_event_router/src/handlers/api.rs +++ b/crates/defguard_event_router/src/handlers/api.rs @@ -6,18 +6,22 @@ use crate::{EventRouter, error::EventRouterError}; impl EventRouter { pub(crate) fn handle_api_event(&self, event: ApiEvent) -> Result<(), EventRouterError> { - debug!("Processing API event"); + debug!("Processing API event: {event:?}"); let logger_event = match *event.event { ApiEventType::UserLogin => LoggerEvent::Defguard(Box::new(DefguardEvent::UserLogin)), - ApiEventType::UserLoginFailed => { - LoggerEvent::Defguard(Box::new(DefguardEvent::UserLoginFailed)) + ApiEventType::UserLoginFailed { message } => { + LoggerEvent::Defguard(Box::new(DefguardEvent::UserLoginFailed { message })) } ApiEventType::UserMfaLogin { mfa_method } => { LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaLogin { mfa_method })) } - ApiEventType::UserMfaLoginFailed { mfa_method } => { - LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaLoginFailed { mfa_method })) - } + ApiEventType::UserMfaLoginFailed { + mfa_method, + message, + } => LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaLoginFailed { + mfa_method, + message, + })), ApiEventType::RecoveryCodeUsed => { LoggerEvent::Defguard(Box::new(DefguardEvent::RecoveryCodeUsed)) } @@ -34,6 +38,9 @@ impl EventRouter { ApiEventType::MfaDisabled => { LoggerEvent::Defguard(Box::new(DefguardEvent::MfaDisabled)) } + ApiEventType::UserMfaDisabled { user } => { + LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaDisabled { user })) + } ApiEventType::MfaTotpDisabled => { LoggerEvent::Defguard(Box::new(DefguardEvent::MfaTotpDisabled)) } @@ -156,11 +163,14 @@ impl EventRouter { ApiEventType::OpenIdProviderModified { provider } => { LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdProviderModified { provider })) } - ApiEventType::SettingsUpdated => { - LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsUpdated)) + ApiEventType::SettingsUpdated { before, after } => { + LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsUpdated { before, after })) } - ApiEventType::SettingsUpdatedPartial => { - LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsUpdatedPartial)) + ApiEventType::SettingsUpdatedPartial { before, after } => { + LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsUpdatedPartial { + before, + after, + })) } ApiEventType::SettingsDefaultBrandingRestored => { LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsDefaultBrandingRestored)) @@ -233,6 +243,27 @@ impl EventRouter { user, })) } + ApiEventType::UserSnatBindingAdded { user, binding } => { + LoggerEvent::Defguard(Box::new(DefguardEvent::UserSnatBindingAdded { + user, + binding, + })) + } + ApiEventType::UserSnatBindingRemoved { user, binding } => { + LoggerEvent::Defguard(Box::new(DefguardEvent::UserSnatBindingRemoved { + user, + binding, + })) + } + ApiEventType::UserSnatBindingModified { + user, + before, + after, + } => LoggerEvent::Defguard(Box::new(DefguardEvent::UserSnatBindingModified { + user, + before, + after, + })), }; self.log_event(event.context.into(), logger_event) } diff --git a/crates/defguard_event_router/src/handlers/bidi.rs b/crates/defguard_event_router/src/handlers/bidi.rs index b5b9fbb690..2068315684 100644 --- a/crates/defguard_event_router/src/handlers/bidi.rs +++ b/crates/defguard_event_router/src/handlers/bidi.rs @@ -52,10 +52,12 @@ impl EventRouter { location, device, method, + message, } => LoggerEvent::Vpn(Box::new(VpnEvent::MfaFailed { location, device, method, + message, })), }, }; diff --git a/migrations/20250627111713_add_activity_log_description.down.sql b/migrations/20250627111713_add_activity_log_description.down.sql new file mode 100644 index 0000000000..4cc2d283b7 --- /dev/null +++ b/migrations/20250627111713_add_activity_log_description.down.sql @@ -0,0 +1 @@ +ALTER TABLE activity_log_event DROP COLUMN "description"; diff --git a/migrations/20250627111713_add_activity_log_description.up.sql b/migrations/20250627111713_add_activity_log_description.up.sql new file mode 100644 index 0000000000..8095217ee3 --- /dev/null +++ b/migrations/20250627111713_add_activity_log_description.up.sql @@ -0,0 +1 @@ +ALTER TABLE activity_log_event ADD COLUMN "description" TEXT; diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 1a642b7a66..6db570f9e8 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -2590,6 +2590,7 @@ This alias is currently in use by the following rule(s) and cannot be deleted. T event: 'Event', module: 'Module', device: 'Device', + description: 'Description', }, noData: { data: 'No activities present', @@ -2610,6 +2611,7 @@ This alias is currently in use by the following rule(s) and cannot be deleted. T user_modified: 'User modified', mfa_enabled: 'MFA enabled', mfa_disabled: 'MFA disabled', + user_mfa_disabled: 'User MFA disabled', mfa_totp_enabled: 'MFA TOTP enabled', mfa_totp_disabled: 'MFA TOTP disabled', mfa_email_enabled: 'MFA email enabled', @@ -2668,6 +2670,9 @@ This alias is currently in use by the following rule(s) and cannot be deleted. T password_changed_by_admin: 'Password changed by admin', password_reset: 'Password reset', client_configuration_token_added: 'Client configuration token added', + user_snat_binding_added: 'User SNAT binding added', + user_snat_binding_modified: 'User SNAT binding modified', + user_snat_binding_removed: 'User SNAT binding removed', }, activityLogModule: { defguard: 'Defguard', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 7dc36906c0..946ebae518 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -6222,6 +6222,10 @@ type RootTranslation = { * D​e​v​i​c​e */ device: string + /** + * D​e​s​c​r​i​p​t​i​o​n + */ + description: string } noData: { /** @@ -6281,6 +6285,10 @@ type RootTranslation = { * M​F​A​ ​d​i​s​a​b​l​e​d */ mfa_disabled: string + /** + * U​s​e​r​ ​M​F​A​ ​d​i​s​a​b​l​e​d + */ + user_mfa_disabled: string /** * M​F​A​ ​T​O​T​P​ ​e​n​a​b​l​e​d */ @@ -6513,6 +6521,18 @@ type RootTranslation = { * C​l​i​e​n​t​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​t​o​k​e​n​ ​a​d​d​e​d */ client_configuration_token_added: string + /** + * U​s​e​r​ ​S​N​A​T​ ​b​i​n​d​i​n​g​ ​a​d​d​e​d + */ + user_snat_binding_added: string + /** + * U​s​e​r​ ​S​N​A​T​ ​b​i​n​d​i​n​g​ ​m​o​d​i​f​i​e​d + */ + user_snat_binding_modified: string + /** + * U​s​e​r​ ​S​N​A​T​ ​b​i​n​d​i​n​g​ ​r​e​m​o​v​e​d + */ + user_snat_binding_removed: string } activityLogModule: { /** @@ -12679,6 +12699,10 @@ export type TranslationFunctions = { * Device */ device: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString } noData: { /** @@ -12738,6 +12762,10 @@ export type TranslationFunctions = { * MFA disabled */ mfa_disabled: () => LocalizedString + /** + * User MFA disabled + */ + user_mfa_disabled: () => LocalizedString /** * MFA TOTP enabled */ @@ -12970,6 +12998,18 @@ export type TranslationFunctions = { * Client configuration token added */ client_configuration_token_added: () => LocalizedString + /** + * User SNAT binding added + */ + user_snat_binding_added: () => LocalizedString + /** + * User SNAT binding modified + */ + user_snat_binding_modified: () => LocalizedString + /** + * User SNAT binding removed + */ + user_snat_binding_removed: () => LocalizedString } activityLogModule: { /** diff --git a/web/src/pages/activity-log/ActivityLogPage.tsx b/web/src/pages/activity-log/ActivityLogPage.tsx index dab0f2b3ca..0534d86373 100644 --- a/web/src/pages/activity-log/ActivityLogPage.tsx +++ b/web/src/pages/activity-log/ActivityLogPage.tsx @@ -20,6 +20,7 @@ import { NoData } from '../../shared/defguard-ui/components/Layout/NoData/NoData import { Search } from '../../shared/defguard-ui/components/Layout/Search/Search'; import { ListSortDirection } from '../../shared/defguard-ui/components/Layout/VirtualizedList/types'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; +import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; import useApi from '../../shared/hooks/useApi'; import { ActivityLogSortKey } from '../../shared/types'; import { ActivityList } from './components/ActivityList'; @@ -77,6 +78,7 @@ const PageContent = () => { const [sortDirection, setSortDirection] = useState( ListSortDirection.DESC, ); + const isAdmin = useAuthStore((s) => s.user?.is_admin ?? false); const activeFiltersCount = useMemo( () => Object.values(activeFilters).flat().length, @@ -94,6 +96,7 @@ const PageContent = () => { const { data: users } = useQuery({ queryFn: getUsers, queryKey: ['user'], + enabled: isAdmin, }); const queryKey = useMemo( diff --git a/web/src/pages/activity-log/components/ActivityList.tsx b/web/src/pages/activity-log/components/ActivityList.tsx index 8e1cf9b0f5..cd937ab5fc 100644 --- a/web/src/pages/activity-log/components/ActivityList.tsx +++ b/web/src/pages/activity-log/components/ActivityList.tsx @@ -84,6 +84,10 @@ export const ActivityList = ({ label: headersLL.device(), key: 'device', }, + { + label: headersLL.description(), + key: 'description', + }, ], [headersLL], ); @@ -144,6 +148,9 @@ export const ActivityList = ({
+
+ +
); })} diff --git a/web/src/pages/activity-log/style.scss b/web/src/pages/activity-log/style.scss index 8f24a18b78..30d0af2acd 100644 --- a/web/src/pages/activity-log/style.scss +++ b/web/src/pages/activity-log/style.scss @@ -37,7 +37,7 @@ } @mixin list-sizing() { - grid-template-columns: 250px 150px 150px 300px 100px minmax(200px, 1fr); + grid-template-columns: 150px 120px 150px 300px 100px 200px minmax(300px, 1fr); justify-content: space-between; column-gap: var(--spacing-xs); diff --git a/web/src/pages/activity-log/types.ts b/web/src/pages/activity-log/types.ts index 58815f12f4..03ef1d68e6 100644 --- a/web/src/pages/activity-log/types.ts +++ b/web/src/pages/activity-log/types.ts @@ -18,6 +18,7 @@ export type ActivityLogEventType = | 'user_modified' | 'user_removed' | 'mfa_disabled' + | 'user_mfa_disabled' | 'mfa_totp_enabled' | 'mfa_totp_disabled' | 'mfa_email_enabled' @@ -75,7 +76,10 @@ export type ActivityLogEventType = | 'password_changed' | 'password_changed_by_admin' | 'password_reset' - | 'client_configuration_token_added'; + | 'client_configuration_token_added' + | 'user_snat_binding_added' + | 'user_snat_binding_modified' + | 'user_snat_binding_removed'; export const activityLogEventTypeValues: ActivityLogEventType[] = [ 'user_login', @@ -88,6 +92,7 @@ export const activityLogEventTypeValues: ActivityLogEventType[] = [ 'user_modified', 'user_removed', 'mfa_disabled', + 'user_mfa_disabled', 'mfa_totp_enabled', 'mfa_totp_disabled', 'mfa_email_enabled', @@ -146,4 +151,7 @@ export const activityLogEventTypeValues: ActivityLogEventType[] = [ 'password_changed_by_admin', 'password_reset', 'client_configuration_token_added', + 'user_snat_binding_added', + 'user_snat_binding_modified', + 'user_snat_binding_removed', ]; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index e7ac51897e..8df27ffd43 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -508,7 +508,7 @@ export type ActivityLogEvent = { event: ActivityLogEventType; module: ActivityLogModule; device: string; - metadata?: unknown; + description?: string; }; export type PaginationParams = {