From 3bd27bdc549de9b5e61c154f50c20d86ada876af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 07:18:40 +0100 Subject: [PATCH 1/6] make IP field on event struct optional --- .../src/db/models/activity_log/mod.rs | 3 +- crates/defguard_core/src/events.rs | 28 +++++++++++++------ .../src/handlers/activity_log.rs | 2 +- ..._[2.0.0]_activity_log_optional_ip.down.sql | 5 ++++ ...00_[2.0.0]_activity_log_optional_ip.up.sql | 1 + 5 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 migrations/20260317100000_[2.0.0]_activity_log_optional_ip.down.sql create mode 100644 migrations/20260317100000_[2.0.0]_activity_log_optional_ip.up.sql 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 0938d190b6..c67714bed6 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -130,7 +130,8 @@ pub struct ActivityLogEvent { pub user_id: Id, pub username: String, pub location: Option, - pub ip: IpNetwork, + #[model(option)] + pub ip: Option, #[model(enum)] pub event: EventType, #[model(enum)] diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index fbd458762f..73a374e62f 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -28,19 +28,24 @@ pub struct ApiRequestContext { pub timestamp: NaiveDateTime, pub user_id: Id, pub username: String, - pub ip: IpAddr, + pub ip: Option, pub device: String, } impl ApiRequestContext { #[must_use] - pub fn new(user_id: Id, username: String, ip: IpAddr, device: String) -> Self { + pub fn new( + user_id: Id, + username: String, + ip: impl Into>, + device: String, + ) -> Self { let timestamp = Utc::now().naive_utc(); Self { timestamp, user_id, username, - ip, + ip: ip.into(), device, } } @@ -54,7 +59,7 @@ pub struct GrpcRequestContext { pub timestamp: NaiveDateTime, pub user_id: Id, pub username: String, - pub ip: IpAddr, + pub ip: Option, pub device_id: Id, pub device_name: String, pub location: WireguardNetwork, @@ -65,7 +70,7 @@ impl GrpcRequestContext { pub fn new( user_id: Id, username: String, - ip: IpAddr, + ip: impl Into>, device_id: Id, device_name: String, location: WireguardNetwork, @@ -75,7 +80,7 @@ impl GrpcRequestContext { timestamp, user_id, username, - ip, + ip: ip.into(), device_id, device_name, location, @@ -328,19 +333,24 @@ pub struct BidiRequestContext { pub timestamp: NaiveDateTime, pub user_id: Id, pub username: String, - pub ip: IpAddr, + pub ip: Option, pub device_name: String, } impl BidiRequestContext { #[must_use] - pub fn new(user_id: Id, username: String, ip: IpAddr, device_name: String) -> Self { + pub fn new( + user_id: Id, + username: String, + ip: impl Into>, + device_name: String, + ) -> Self { let timestamp = Utc::now().naive_utc(); Self { timestamp, user_id, username, - ip, + ip: ip.into(), device_name, } } diff --git a/crates/defguard_core/src/handlers/activity_log.rs b/crates/defguard_core/src/handlers/activity_log.rs index 0bd0296688..6ff1d04127 100644 --- a/crates/defguard_core/src/handlers/activity_log.rs +++ b/crates/defguard_core/src/handlers/activity_log.rs @@ -105,7 +105,7 @@ pub struct ApiActivityLogEvent { pub user_id: Id, pub username: String, pub location: Option, - pub ip: IpNetwork, + pub ip: Option, pub event: String, pub module: ActivityLogModule, pub device: String, diff --git a/migrations/20260317100000_[2.0.0]_activity_log_optional_ip.down.sql b/migrations/20260317100000_[2.0.0]_activity_log_optional_ip.down.sql new file mode 100644 index 0000000000..32e7a33db6 --- /dev/null +++ b/migrations/20260317100000_[2.0.0]_activity_log_optional_ip.down.sql @@ -0,0 +1,5 @@ +UPDATE activity_log_event +SET ip = '0.0.0.0'::inet +WHERE ip IS NULL; + +ALTER TABLE activity_log_event ALTER COLUMN ip SET NOT NULL; diff --git a/migrations/20260317100000_[2.0.0]_activity_log_optional_ip.up.sql b/migrations/20260317100000_[2.0.0]_activity_log_optional_ip.up.sql new file mode 100644 index 0000000000..0614b13db2 --- /dev/null +++ b/migrations/20260317100000_[2.0.0]_activity_log_optional_ip.up.sql @@ -0,0 +1 @@ +ALTER TABLE activity_log_event ALTER COLUMN ip DROP NOT NULL; From 08cacc88c30293f194e4ec8767599ed1f1566169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 07:31:12 +0100 Subject: [PATCH 2/6] update struct usage --- crates/defguard_event_logger/src/lib.rs | 2 +- crates/defguard_event_logger/src/message.rs | 2 +- crates/defguard_session_manager/src/events.rs | 2 +- crates/defguard_session_manager/src/lib.rs | 5 +---- crates/defguard_session_manager/src/session_state.rs | 2 +- 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 3d954d2dd8..da89c0617d 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -573,7 +573,7 @@ pub async fn run_event_logger( user_id, username, location, - ip: ip.into(), + ip: ip.map(Into::into), event, module, device, diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index 94626063d2..d5d9177c5a 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -44,7 +44,7 @@ pub struct EventContext { pub user_id: Id, pub username: String, pub location: Option, - pub ip: IpAddr, + pub ip: Option, pub device: String, } diff --git a/crates/defguard_session_manager/src/events.rs b/crates/defguard_session_manager/src/events.rs index e22495fa41..42fa07652c 100644 --- a/crates/defguard_session_manager/src/events.rs +++ b/crates/defguard_session_manager/src/events.rs @@ -18,7 +18,7 @@ pub struct SessionManagerEventContext { pub location: WireguardNetwork, pub user: User, pub device: Device, - pub public_ip: IpAddr, + pub public_ip: Option, } #[derive(Debug)] diff --git a/crates/defguard_session_manager/src/lib.rs b/crates/defguard_session_manager/src/lib.rs index 9ee8e4a463..5cf5b2e997 100644 --- a/crates/defguard_session_manager/src/lib.rs +++ b/crates/defguard_session_manager/src/lib.rs @@ -1,5 +1,3 @@ -use std::net::{IpAddr, Ipv4Addr}; - use chrono::Utc; use defguard_common::{ db::{ @@ -312,8 +310,7 @@ impl SessionManager { location: location.clone(), user, device, - // FIXME: this is a workaround since we require an IP for each audit log event - public_ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + public_ip: None, }; let event = SessionManagerEvent { context, diff --git a/crates/defguard_session_manager/src/session_state.rs b/crates/defguard_session_manager/src/session_state.rs index f225814f0a..57133be1a4 100644 --- a/crates/defguard_session_manager/src/session_state.rs +++ b/crates/defguard_session_manager/src/session_state.rs @@ -337,7 +337,7 @@ impl ActiveSessionsMap { location, user, device, - public_ip, + public_ip: Some(public_ip), }; let event = SessionManagerEvent { context, From 449fd167df4f6dda2b13c08ddd6608417492bb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 07:32:30 +0100 Subject: [PATCH 3/6] update existing tests --- .../tests/session_manager/sessions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/defguard_session_manager/tests/session_manager/sessions.rs b/crates/defguard_session_manager/tests/session_manager/sessions.rs index 77bd99d639..5dfc359b8e 100644 --- a/crates/defguard_session_manager/tests/session_manager/sessions.rs +++ b/crates/defguard_session_manager/tests/session_manager/sessions.rs @@ -144,7 +144,7 @@ async fn test_duplicate_stats_in_same_batch_reuse_existing_session( assert_eq!(connected_event.context.location.id, location.id); assert_eq!(connected_event.context.user.id, user.id); assert_eq!(connected_event.context.device.id, device.id); - assert_eq!(connected_event.context.public_ip, endpoint.ip()); + assert_eq!(connected_event.context.public_ip, Some(endpoint.ip())); assert_no_session_manager_events(&mut harness); assert_no_gateway_events(&mut harness); @@ -221,7 +221,7 @@ async fn test_duplicate_stats_across_iterations_reuse_existing_session( assert_eq!(connected_event.context.location.id, location.id); assert_eq!(connected_event.context.user.id, user.id); assert_eq!(connected_event.context.device.id, device.id); - assert_eq!(connected_event.context.public_ip, endpoint.ip()); + assert_eq!(connected_event.context.public_ip, Some(endpoint.ip())); assert_no_session_manager_events(&mut harness); assert_no_gateway_events(&mut harness); From 5abc0fd75a6cae006d2fdba39c664480d83a6581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 07:32:37 +0100 Subject: [PATCH 4/6] add backend tests --- .../tests/integration/api/activity_log.rs | 72 +++++++++++++++++++ .../tests/integration/api/mod.rs | 1 + crates/defguard_event_logger/src/lib.rs | 30 ++++++++ .../tests/session_manager/event_flow.rs | 3 +- 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 crates/defguard_core/tests/integration/api/activity_log.rs diff --git a/crates/defguard_core/tests/integration/api/activity_log.rs b/crates/defguard_core/tests/integration/api/activity_log.rs new file mode 100644 index 0000000000..a375cd6b37 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/activity_log.rs @@ -0,0 +1,72 @@ +use chrono::{TimeDelta, Utc}; +use defguard_common::db::{NoId, setup_pool}; +use defguard_core::db::models::activity_log::{ActivityLogEvent, ActivityLogModule, EventType}; +use reqwest::StatusCode; +use serde::Deserialize; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::common::{get_db_user, make_client_with_db}; + +#[derive(Deserialize)] +struct PaginatedResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct ApiActivityLogEvent { + id: i64, + ip: Option, + description: Option, +} + +#[sqlx::test] +async fn test_activity_log_persists_and_returns_null_ip( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, db) = make_client_with_db(pool.clone()).await; + let admin = get_db_user(&db, "admin").await; + + client.login_user("admin", "pass123").await; + + let marker = format!( + "nullable-ip-{}", + Utc::now().timestamp_nanos_opt().unwrap_or_default() + ); + let saved_event = ActivityLogEvent { + id: NoId, + timestamp: Utc::now().naive_utc() + TimeDelta::seconds(5), + user_id: admin.id, + username: admin.username, + location: None, + ip: None, + event: EventType::UserLogout, + module: ActivityLogModule::Defguard, + device: "integration-test".to_string(), + description: Some(marker.clone()), + metadata: None, + } + .save(&db) + .await + .expect("activity log event with null ip should persist"); + + let fetched_event = ActivityLogEvent::find_by_id(&db, saved_event.id) + .await + .expect("activity log event query should succeed") + .expect("saved activity log event should exist"); + assert_eq!(fetched_event.ip, None); + + let response = client.get("/api/v1/activity_log").send().await; + assert_eq!(response.status(), StatusCode::OK); + + let payload: PaginatedResponse = response.json().await; + let api_event = payload + .data + .into_iter() + .find(|event| event.id == saved_event.id) + .expect("activity log endpoint should return saved event"); + + assert_eq!(api_event.description.as_deref(), Some(marker.as_str())); + assert_eq!(api_event.ip, None); +} diff --git a/crates/defguard_core/tests/integration/api/mod.rs b/crates/defguard_core/tests/integration/api/mod.rs index 7989933e61..a212df4ae5 100644 --- a/crates/defguard_core/tests/integration/api/mod.rs +++ b/crates/defguard_core/tests/integration/api/mod.rs @@ -1,4 +1,5 @@ mod acl; +mod activity_log; mod api_tokens; mod auth; mod common; diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index da89c0617d..400ca1866f 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -610,3 +610,33 @@ pub async fn run_event_logger( transaction.commit().await?; } } + +#[cfg(test)] +mod tests { + use chrono::Utc; + use defguard_common::db::NoId; + use serde_json::Value; + + use super::*; + + #[test] + fn activity_log_event_serialization_supports_null_ip() { + let event = ActivityLogEvent { + id: NoId, + timestamp: Utc::now().naive_utc(), + user_id: 1, + username: "admin".to_string(), + location: None, + ip: None, + event: EventType::UserLogin, + module: ActivityLogModule::Defguard, + device: "test-device".to_string(), + description: None, + metadata: None, + }; + + let serialized = serde_json::to_value(event).expect("activity log event should serialize"); + + assert_eq!(serialized.get("ip"), Some(&Value::Null)); + } +} diff --git a/crates/defguard_session_manager/tests/session_manager/event_flow.rs b/crates/defguard_session_manager/tests/session_manager/event_flow.rs index 744ddd0893..a73f1f74f2 100644 --- a/crates/defguard_session_manager/tests/session_manager/event_flow.rs +++ b/crates/defguard_session_manager/tests/session_manager/event_flow.rs @@ -55,7 +55,7 @@ async fn test_session_manager_emits_connected_event_for_first_stats( assert_eq!(event.context.location.id, location.id); assert_eq!(event.context.user.id, user.id); assert_eq!(event.context.device.id, device.id); - assert_eq!(event.context.public_ip, endpoint.ip()); + assert_eq!(event.context.public_ip, Some(endpoint.ip())); } #[sqlx::test] @@ -149,6 +149,7 @@ async fn test_session_manager_emits_disconnect_event_for_inactive_standard_sessi assert_eq!(event.context.location.id, location.id); assert_eq!(event.context.user.id, user.id); assert_eq!(event.context.device.id, device.id); + assert_eq!(event.context.public_ip, None); let disconnected_session = VpnClientSession::find_by_id(&pool, session.id) .await From 04912dc69ec6b32a50a9c7dce80d431aea16c2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 07:33:06 +0100 Subject: [PATCH 5/6] handle null value display in frontend --- .../ActivityLogPage/ActivityLogTable.tsx | 25 ++++++++++++++----- web/src/shared/api/types.ts | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/web/src/pages/ActivityLogPage/ActivityLogTable.tsx b/web/src/pages/ActivityLogPage/ActivityLogTable.tsx index df7f5d2cf0..03513b27df 100644 --- a/web/src/pages/ActivityLogPage/ActivityLogTable.tsx +++ b/web/src/pages/ActivityLogPage/ActivityLogTable.tsx @@ -21,6 +21,18 @@ import { displayDate } from '../../shared/utils/displayDate'; type RowData = ActivityLogEvent; const columnHelper = createColumnHelper(); +const missingValuePlaceholder = '—'; + +const renderOptionalTableValue = ( + value: string | null | undefined, + missingValueLabel: string, +) => { + if (!isPresent(value)) { + return {missingValuePlaceholder}; + } + + return {value}; +}; interface Props { data: RowData[]; @@ -73,11 +85,12 @@ export const ActivityLogTable = ({ columnHelper.accessor('ip', { header: 'IP', minSize: 150, - cell: (info) => ( - - {info.getValue()} - - ), + cell: (info) => { + const value = info.getValue(); + return ( + {renderOptionalTableValue(value, 'No IP recorded')} + ); + }, }), columnHelper.accessor('location', { header: 'Location', @@ -86,7 +99,7 @@ export const ActivityLogTable = ({ const value = info.getValue(); return ( - {isPresent(value) ? {value} : {`~`}} + {renderOptionalTableValue(value, 'No location recorded')} ); }, diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 4a74897b19..61124dd54a 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -1191,7 +1191,7 @@ export interface ActivityLogEvent { user_id: number; username: string; location?: string; - ip: string; + ip: string | null; event: ActivityLogEventTypeValue; module: ActivityLogModuleValue; device: string; From 9974997926f2b0f1b50c4b35e0c5ec3f0fc2aabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 08:41:28 +0100 Subject: [PATCH 6/6] update query data --- ...ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json} | 6 +++--- ...745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json} | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) rename .sqlx/{query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json => query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json} (86%) rename .sqlx/{query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json => query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json} (86%) diff --git a/.sqlx/query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json b/.sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json similarity index 86% rename from .sqlx/query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json rename to .sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json index 18cee1391d..0e36c94cdb 100644 --- a/.sqlx/query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json +++ b/.sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"location\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\" WHERE id = $1", + "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"location\",\"ip\" \"ip?: _\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\" WHERE id = $1", "describe": { "columns": [ { @@ -30,7 +30,7 @@ }, { "ordinal": 5, - "name": "ip", + "name": "ip?: _", "type_info": "Inet" }, { @@ -90,5 +90,5 @@ true ] }, - "hash": "1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f" + "hash": "47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845" } diff --git a/.sqlx/query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json b/.sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json similarity index 86% rename from .sqlx/query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json rename to .sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json index 0083e58323..d62e957d33 100644 --- a/.sqlx/query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json +++ b/.sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"location\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\"", + "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"location\",\"ip\" \"ip?: _\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\"", "describe": { "columns": [ { @@ -30,7 +30,7 @@ }, { "ordinal": 5, - "name": "ip", + "name": "ip?: _", "type_info": "Inet" }, { @@ -88,5 +88,5 @@ true ] }, - "hash": "32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b" + "hash": "59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e" }