From 29c8020ddb7191f37cdb0d4543dbaadb819f8a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 14:58:43 +0200 Subject: [PATCH 01/36] add base migration --- .../20260811125537_[2.2.0]_mfa_flow.down.sql | 2 ++ .../20260811125537_[2.2.0]_mfa_flow.up.sql | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 migrations/20260811125537_[2.2.0]_mfa_flow.down.sql create mode 100644 migrations/20260811125537_[2.2.0]_mfa_flow.up.sql diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql new file mode 100644 index 000000000..5656a2c99 --- /dev/null +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS mfa_flow_step; +DROP TABLE IF EXISTS mfa_flow; diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql new file mode 100644 index 000000000..4425a69ca --- /dev/null +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql @@ -0,0 +1,18 @@ +-- MFA Flow config: entity table +CREATE TABLE mfa_flow ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- MFA Flow step: ordered per-flow, methods as PG array +CREATE TABLE mfa_flow_step ( + id BIGSERIAL PRIMARY KEY, + flow_id BIGINT NOT NULL REFERENCES mfa_flow(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + methods vpn_client_mfa_method[] NOT NULL, + CONSTRAINT mfa_flow_step_methods_nonempty CHECK (array_length(methods, 1) >= 1), + CONSTRAINT mfa_flow_step_position_nonneg CHECK (position >= 0) +); +CREATE INDEX idx_mfa_flow_step_flow_id ON mfa_flow_step(flow_id); From 22f2dccabcbe2ffe912299dd30002c9aa172e360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 15:13:25 +0200 Subject: [PATCH 02/36] add basic MFA flow structs --- ...59c7c7a7987e7c12b31e7a79e71f08ef73985.json | 20 +++ ...788d6a0a7ab1f085219e5a942f324c0ba2129.json | 40 +++++ ...41a500a1842308bd0c59d9769e163b2d0a211.json | 38 +++++ ...903e392dc0692f83309e69efb839ced77adb9.json | 44 ++++++ ...361e91fdb169a8f9c2ce7b222abed5970e69b.json | 60 ++++++++ ...00273a3bdf350b1b29197214b22f2592d9107.json | 14 ++ ...48274929c4c3be8d9821de27beb4667fdb6c8.json | 24 +++ ...185a8570aa43a48028d54944beafcc18b5348.json | 41 +++++ ...c2af6b2da42bdc3dcb88406c96f40024c56ab.json | 14 ++ ...90d4ee41cee64088959625e31c0d532ae77cd.json | 17 +++ ...174798bc280b6a3c2237d574dba90ecd3342c.json | 44 ++++++ .../defguard_common/src/db/models/mfa_flow.rs | 142 ++++++++++++++++++ crates/defguard_common/src/db/models/mod.rs | 1 + .../src/db/models/vpn_client_session.rs | 5 +- 14 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985.json create mode 100644 .sqlx/query-1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129.json create mode 100644 .sqlx/query-3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211.json create mode 100644 .sqlx/query-5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9.json create mode 100644 .sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json create mode 100644 .sqlx/query-62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107.json create mode 100644 .sqlx/query-cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8.json create mode 100644 .sqlx/query-d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348.json create mode 100644 .sqlx/query-e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab.json create mode 100644 .sqlx/query-fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd.json create mode 100644 .sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json create mode 100644 crates/defguard_common/src/db/models/mfa_flow.rs diff --git a/.sqlx/query-15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985.json b/.sqlx/query-15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985.json new file mode 100644 index 000000000..71e64bfb8 --- /dev/null +++ b/.sqlx/query-15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM \"mfa_flow\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985" +} diff --git a/.sqlx/query-1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129.json b/.sqlx/query-1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129.json new file mode 100644 index 000000000..457d26eb6 --- /dev/null +++ b/.sqlx/query-1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"title\",\"created_at\",\"updated_at\" FROM \"mfa_flow\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129" +} diff --git a/.sqlx/query-3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211.json b/.sqlx/query-3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211.json new file mode 100644 index 000000000..1479d7b46 --- /dev/null +++ b/.sqlx/query-3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"title\",\"created_at\",\"updated_at\" FROM \"mfa_flow\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211" +} diff --git a/.sqlx/query-5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9.json b/.sqlx/query-5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9.json new file mode 100644 index 000000000..3da298c2e --- /dev/null +++ b/.sqlx/query-5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT mf.id, mf.title, mf.created_at, mf.updated_at, COALESCE(s.step_count, 0) AS \"step_count!: i64\" FROM mfa_flow mf LEFT JOIN ( SELECT flow_id, COUNT(*) AS step_count FROM mfa_flow_step GROUP BY flow_id ) s ON s.flow_id = mf.id ORDER BY mf.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "step_count!: i64", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + null + ] + }, + "hash": "5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9" +} diff --git a/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json b/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json new file mode 100644 index 000000000..46819b2dd --- /dev/null +++ b/.sqlx/query-5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, flow_id, position, methods AS \"methods: Vec\" FROM mfa_flow_step WHERE flow_id = $1 ORDER BY position", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "flow_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "position", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "methods: Vec", + "type_info": { + "Custom": { + "name": "vpn_client_mfa_method[]", + "kind": { + "Array": { + "Custom": { + "name": "vpn_client_mfa_method", + "kind": { + "Enum": [ + "totp", + "email", + "oidc", + "biometric", + "mobileapprove" + ] + } + } + } + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "5da675e723a05e1367bdd91c174361e91fdb169a8f9c2ce7b222abed5970e69b" +} diff --git a/.sqlx/query-62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107.json b/.sqlx/query-62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107.json new file mode 100644 index 000000000..b906bb97b --- /dev/null +++ b/.sqlx/query-62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mfa_flow_step WHERE flow_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107" +} diff --git a/.sqlx/query-cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8.json b/.sqlx/query-cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8.json new file mode 100644 index 000000000..9827ed6cc --- /dev/null +++ b/.sqlx/query-cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"mfa_flow\" (\"title\",\"created_at\",\"updated_at\") VALUES ($1,$2,$3) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8" +} diff --git a/.sqlx/query-d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348.json b/.sqlx/query-d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348.json new file mode 100644 index 000000000..7b28ed2ee --- /dev/null +++ b/.sqlx/query-d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"title\",\"created_at\",\"updated_at\" FROM \"mfa_flow\" LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348" +} diff --git a/.sqlx/query-e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab.json b/.sqlx/query-e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab.json new file mode 100644 index 000000000..83904330b --- /dev/null +++ b/.sqlx/query-e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"mfa_flow\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab" +} diff --git a/.sqlx/query-fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd.json b/.sqlx/query-fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd.json new file mode 100644 index 000000000..e3fd5bfee --- /dev/null +++ b/.sqlx/query-fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"mfa_flow\" SET \"title\" = $2,\"created_at\" = $3,\"updated_at\" = $4 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd" +} diff --git a/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json b/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json new file mode 100644 index 000000000..ee7293c36 --- /dev/null +++ b/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mfa_flow_step (flow_id, position, methods) VALUES ($1, $2, $3::vpn_client_mfa_method[]) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int4", + { + "Custom": { + "name": "vpn_client_mfa_method[]", + "kind": { + "Array": { + "Custom": { + "name": "vpn_client_mfa_method", + "kind": { + "Enum": [ + "totp", + "email", + "oidc", + "biometric", + "mobileapprove" + ] + } + } + } + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c" +} diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs new file mode 100644 index 000000000..81b2fed83 --- /dev/null +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -0,0 +1,142 @@ +use chrono::{DateTime, Utc}; +use model_derive::Model; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgConnection, PgExecutor, query, query_as, query_scalar}; +use utoipa::ToSchema; + +use crate::db::{Id, NoId, models::vpn_client_session::VpnClientMfaMethod}; + +/// An MFA flow is a named, ordered list of MFA steps. +#[derive(Clone, Debug, Deserialize, FromRow, Model, PartialEq, Serialize, ToSchema)] +#[table(mfa_flow)] +pub struct MfaFlow { + pub id: I, + pub title: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A single step within an MFA flow. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct MfaFlowStep { + pub id: I, + pub flow_id: Id, + pub position: i32, + pub methods: Vec, +} + +/// DB query result: a flow row plus its server-computed `step_count`. +#[derive(Clone, Debug, Serialize)] +pub struct MfaFlowWithStepCount { + pub id: Id, + pub title: String, + pub step_count: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl MfaFlow { + /// Creates a new flow with its steps in a single transaction. + /// `step_methods` is one `Vec` per step; positions are assigned 0-based + /// from the outer array order. + pub async fn create( + conn: &mut PgConnection, + title: String, + step_methods: Vec>, + ) -> sqlx::Result<(MfaFlow, Vec>)> { + let now = Utc::now(); + let flow = MfaFlow { + id: NoId, + title, + created_at: now, + updated_at: now, + } + .save(&mut *conn) + .await?; + + let steps = MfaFlowStep::insert_batch(&mut *conn, flow.id, &step_methods).await?; + + Ok((flow, steps)) + } +} + +impl MfaFlow { + /// Lists all flows enriched with `step_count`. + pub async fn list_with_step_count<'e, E: PgExecutor<'e>>( + executor: E, + ) -> sqlx::Result> { + query_as!( + MfaFlowWithStepCount, + "SELECT mf.id, mf.title, mf.created_at, mf.updated_at, \ + COALESCE(s.step_count, 0) AS \"step_count!: i64\" \ + FROM mfa_flow mf \ + LEFT JOIN ( \ + SELECT flow_id, COUNT(*) AS step_count \ + FROM mfa_flow_step \ + GROUP BY flow_id \ + ) s ON s.flow_id = mf.id \ + ORDER BY mf.id" + ) + .fetch_all(executor) + .await + } +} + +impl MfaFlowStep { + /// Inserts a batch of steps for a flow, assigning contiguous 0-based positions + /// from the outer array order. + pub async fn insert_batch( + conn: &mut PgConnection, + flow_id: Id, + step_methods: &[Vec], + ) -> sqlx::Result>> { + let mut steps = Vec::with_capacity(step_methods.len()); + for (i, methods) in step_methods.iter().enumerate() { + let id = query_scalar!( + "INSERT INTO mfa_flow_step (flow_id, position, methods) \ + VALUES ($1, $2, $3::vpn_client_mfa_method[]) RETURNING id", + flow_id, + i as i32, + methods as &[VpnClientMfaMethod], + ) + .fetch_one(&mut *conn) + .await?; + + steps.push(MfaFlowStep { + id, + flow_id, + position: i as i32, + methods: methods.clone(), + }); + } + Ok(steps) + } +} + +impl MfaFlowStep { + /// Returns all steps for a given flow, ordered by position. + pub async fn find_by_flow<'e, E: PgExecutor<'e>>( + executor: E, + flow_id: Id, + ) -> sqlx::Result>> { + query_as!( + MfaFlowStep, + "SELECT id, flow_id, position, \ + methods AS \"methods: Vec\" \ + FROM mfa_flow_step \ + WHERE flow_id = $1 \ + ORDER BY position", + flow_id + ) + .fetch_all(executor) + .await + } + + /// Deletes all steps for a given flow. + pub async fn delete_by_flow(conn: &mut PgConnection, flow_id: Id) -> sqlx::Result<()> { + query!("DELETE FROM mfa_flow_step WHERE flow_id = $1", flow_id) + .execute(&mut *conn) + .await?; + Ok(()) + } +} diff --git a/crates/defguard_common/src/db/models/mod.rs b/crates/defguard_common/src/db/models/mod.rs index e6de50e7b..4823d4c5f 100644 --- a/crates/defguard_common/src/db/models/mod.rs +++ b/crates/defguard_common/src/db/models/mod.rs @@ -8,6 +8,7 @@ pub mod error; pub mod gateway; pub mod group; pub mod initial_setup_wizard; +pub mod mfa_flow; pub mod mfa_info; pub mod migration_wizard; pub mod oauth2authorizedapp; diff --git a/crates/defguard_common/src/db/models/vpn_client_session.rs b/crates/defguard_common/src/db/models/vpn_client_session.rs index 6ca6dcc88..ef9d47e8d 100644 --- a/crates/defguard_common/src/db/models/vpn_client_session.rs +++ b/crates/defguard_common/src/db/models/vpn_client_session.rs @@ -1,6 +1,8 @@ use chrono::{NaiveDateTime, Utc}; use model_derive::Model; +use serde::{Deserialize, Serialize}; use sqlx::{Type, query_as}; +use utoipa::ToSchema; use crate::db::{ Id, NoId, @@ -16,8 +18,9 @@ pub enum VpnClientSessionState { Disconnected, } -#[derive(Debug, Type)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize, ToSchema, Type)] #[sqlx(type_name = "vpn_client_mfa_method", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] pub enum VpnClientMfaMethod { Totp, Email, From 43c65d99634e916fc59a305081e32f1cfafde18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 18:20:46 +0200 Subject: [PATCH 03/36] implement step update logic --- .../defguard_common/src/db/models/mfa_flow.rs | 103 +++++++++++ .../src/db/models/mfa_flow/tests.rs | 167 ++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 crates/defguard_common/src/db/models/mfa_flow/tests.rs diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 81b2fed83..254cb5fa9 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -80,6 +80,106 @@ impl MfaFlow { .fetch_all(executor) .await } + + /// Updates a flow's title and reconciles its steps in one operation. + /// + /// `step_updates` is the full ordered list the caller wants after the + /// update. Each entry is `(Option, methods)`: `Some(id)` indicates + /// an existing step to UPDATE (position derived from its index), `None` + /// indicates a new step to INSERT. + /// + /// Steps in the DB that are absent from `step_updates` are DELETEd. + /// Position swaps are handled by offsetting existing steps into a + /// disjoint range before moving them to final positions, avoiding + /// transient UNIQUE conflicts. + pub async fn update_with_steps( + conn: &mut PgConnection, + flow_id: Id, + title: String, + step_updates: Vec<(Option, Vec)>, + ) -> sqlx::Result<(MfaFlow, Vec>)> { + const OFFSET: i32 = 10_000; + + let now = Utc::now(); + query!( + "UPDATE mfa_flow SET title = $1, updated_at = $2 WHERE id = $3", + title, + now, + flow_id, + ) + .execute(&mut *conn) + .await?; + + let incoming_ids: Vec = step_updates.iter().filter_map(|(id, _)| *id).collect(); + + if incoming_ids.is_empty() { + query!("DELETE FROM mfa_flow_step WHERE flow_id = $1", flow_id,) + .execute(&mut *conn) + .await?; + } else { + query!( + "DELETE FROM mfa_flow_step \ + WHERE flow_id = $1 AND id != ALL($2::bigint[])", + flow_id, + &incoming_ids, + ) + .execute(&mut *conn) + .await?; + + query!( + "UPDATE mfa_flow_step \ + SET position = position + $2 \ + WHERE flow_id = $1 AND id = ANY($3::bigint[])", + flow_id, + OFFSET, + &incoming_ids, + ) + .execute(&mut *conn) + .await?; + } + + let mut resulting_steps = Vec::with_capacity(step_updates.len()); + for (index, (maybe_id, methods)) in step_updates.into_iter().enumerate() { + let position = index as i32; + let id = if let Some(step_id) = maybe_id { + query!( + "UPDATE mfa_flow_step \ + SET position = $1, methods = $2::vpn_client_mfa_method[] \ + WHERE id = $3", + position, + &methods as &[VpnClientMfaMethod], + step_id, + ) + .execute(&mut *conn) + .await?; + step_id + } else { + let new_id = query_scalar!( + "INSERT INTO mfa_flow_step (flow_id, position, methods) \ + VALUES ($1, $2, $3::vpn_client_mfa_method[]) RETURNING id", + flow_id, + position, + &methods as &[VpnClientMfaMethod], + ) + .fetch_one(&mut *conn) + .await?; + new_id + }; + + resulting_steps.push(MfaFlowStep { + id, + flow_id, + position, + methods, + }); + } + + let flow = MfaFlow::find_by_id(&mut *conn, flow_id) + .await? + .expect("flow was just updated"); + + Ok((flow, resulting_steps)) + } } impl MfaFlowStep { @@ -140,3 +240,6 @@ impl MfaFlowStep { Ok(()) } } + +#[cfg(test)] +mod tests; diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs new file mode 100644 index 000000000..8b759a063 --- /dev/null +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -0,0 +1,167 @@ +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::*; +use crate::db::setup_pool; + +/// Helper: create a flow with two steps and return its (flow, steps). +async fn create_flow(pool: &sqlx::PgPool) -> (MfaFlow, Vec>) { + let mut tx = pool.begin().await.unwrap(); + let (flow, steps) = MfaFlow::create( + &mut *tx, + "Test Flow".into(), + vec![ + vec![VpnClientMfaMethod::Totp], + vec![VpnClientMfaMethod::Email], + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (flow, steps) +} + +#[sqlx::test] +async fn test_insert_new_step(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, original_steps) = create_flow(&pool).await; + assert_eq!(original_steps.len(), 2); + + let mut tx = pool.begin().await.unwrap(); + let (_, updated_steps) = MfaFlow::update_with_steps( + &mut *tx, + flow.id, + "Test Flow".into(), + vec![ + (Some(original_steps[0].id), vec![VpnClientMfaMethod::Totp]), + (Some(original_steps[1].id), vec![VpnClientMfaMethod::Email]), + (None, vec![VpnClientMfaMethod::Oidc]), + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert_eq!(updated_steps.len(), 3); + assert_eq!(updated_steps[0].methods, vec![VpnClientMfaMethod::Totp]); + assert_eq!(updated_steps[1].methods, vec![VpnClientMfaMethod::Email]); + assert_eq!(updated_steps[2].methods, vec![VpnClientMfaMethod::Oidc]); + assert_eq!(updated_steps[0].position, 0); + assert_eq!(updated_steps[1].position, 1); + assert_eq!(updated_steps[2].position, 2); +} + +#[sqlx::test] +async fn test_update_kept_step(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, original_steps) = create_flow(&pool).await; + assert_eq!(original_steps.len(), 2); + + let mut tx = pool.begin().await.unwrap(); + let (_, updated_steps) = MfaFlow::update_with_steps( + &mut *tx, + flow.id, + "Renamed Flow".into(), + vec![ + (Some(original_steps[0].id), vec![VpnClientMfaMethod::Totp]), + ( + Some(original_steps[1].id), + vec![ + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove, + ], + ), + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert_eq!(updated_steps.len(), 2); + assert_eq!(updated_steps[0].methods, vec![VpnClientMfaMethod::Totp]); + assert_eq!( + updated_steps[1].methods, + vec![ + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove + ] + ); + + let flow = MfaFlow::find_by_id(&pool, flow.id).await.unwrap().unwrap(); + assert_eq!(flow.title, "Renamed Flow"); +} + +#[sqlx::test] +async fn test_delete_removed_step(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, original_steps) = create_flow(&pool).await; + assert_eq!(original_steps.len(), 2); + + // Add a third step + let mut tx = pool.begin().await.unwrap(); + MfaFlowStep::insert_batch(&mut *tx, flow.id, &[vec![VpnClientMfaMethod::Oidc]]) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let all_steps = MfaFlowStep::find_by_flow(&pool, flow.id).await.unwrap(); + assert_eq!(all_steps.len(), 3); + + // Update: keep steps 0 and 2, delete step 1 + let mut tx = pool.begin().await.unwrap(); + let (_, updated_steps) = MfaFlow::update_with_steps( + &mut *tx, + flow.id, + "Test Flow".into(), + vec![ + (Some(all_steps[0].id), all_steps[0].methods.clone()), + (Some(all_steps[2].id), all_steps[2].methods.clone()), + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert_eq!(updated_steps.len(), 2); + assert_eq!(updated_steps[0].id, all_steps[0].id); + assert_eq!(updated_steps[1].id, all_steps[2].id); + assert_eq!(updated_steps[0].position, 0); + assert_eq!(updated_steps[1].position, 1); + + let db_steps = MfaFlowStep::find_by_flow(&pool, flow.id).await.unwrap(); + assert_eq!(db_steps.len(), 2); + let db_ids: Vec = db_steps.iter().map(|s| s.id).collect(); + assert!(!db_ids.contains(&all_steps[1].id)); +} + +#[sqlx::test] +async fn test_position_swap(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, original_steps) = create_flow(&pool).await; + assert_eq!(original_steps.len(), 2); + let step0_id = original_steps[0].id; + let step0_methods = original_steps[0].methods.clone(); + let step1_id = original_steps[1].id; + let step1_methods = original_steps[1].methods.clone(); + + let mut tx = pool.begin().await.unwrap(); + let (_, updated_steps) = MfaFlow::update_with_steps( + &mut *tx, + flow.id, + "Test Flow".into(), + vec![ + (Some(step1_id), step1_methods.clone()), + (Some(step0_id), step0_methods.clone()), + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert_eq!(updated_steps.len(), 2); + assert_eq!(updated_steps[0].id, step1_id); + assert_eq!(updated_steps[0].methods, step1_methods); + assert_eq!(updated_steps[0].position, 0); + assert_eq!(updated_steps[1].id, step0_id); + assert_eq!(updated_steps[1].methods, step0_methods); + assert_eq!(updated_steps[1].position, 1); +} From b6e6e26ee42b6ce2530b48748a685d739a53f044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:12:10 +0200 Subject: [PATCH 04/36] add flow validation --- .../defguard_common/src/db/models/mfa_flow.rs | 54 ++++++++++++++++ .../src/db/models/mfa_flow/tests.rs | 61 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 254cb5fa9..67f99df67 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use chrono::{DateTime, Utc}; use model_derive::Model; use serde::{Deserialize, Serialize}; @@ -35,6 +37,58 @@ pub struct MfaFlowWithStepCount { pub updated_at: DateTime, } +/// A single structured validation error for an MFA flow input. +#[derive(Clone, Debug)] +pub struct MfaFlowValidationField { + pub field: String, + pub code: String, +} + +/// Validates the structural rules for an MFA flow input (title + step methods). +/// License, SMTP and OIDC checks are applied separately by the handler. +pub fn validate_flow_input( + title: &str, + step_methods: &[Vec], +) -> Vec { + let mut errors = Vec::new(); + + if title.trim().is_empty() { + errors.push(MfaFlowValidationField { + field: "title".into(), + code: "required".into(), + }); + } + + if step_methods.is_empty() { + errors.push(MfaFlowValidationField { + field: "steps".into(), + code: "min_items".into(), + }); + } + + for (i, methods) in step_methods.iter().enumerate() { + if methods.is_empty() { + errors.push(MfaFlowValidationField { + field: format!("steps[{i}].methods"), + code: "min_items".into(), + }); + } + + let mut seen = HashSet::new(); + for method in methods { + if !seen.insert(*method) { + errors.push(MfaFlowValidationField { + field: format!("steps[{i}].methods"), + code: "duplicate".into(), + }); + break; + } + } + } + + errors +} + impl MfaFlow { /// Creates a new flow with its steps in a single transaction. /// `step_methods` is one `Vec` per step; positions are assigned 0-based diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index 8b759a063..3aa397378 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -165,3 +165,64 @@ async fn test_position_swap(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(updated_steps[1].methods, step0_methods); assert_eq!(updated_steps[1].position, 1); } + +#[sqlx::test] +async fn test_validation_empty_title(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let errors = validate_flow_input(" ", &[vec![VpnClientMfaMethod::Totp]]); + assert!( + errors + .iter() + .any(|e| e.field == "title" && e.code == "required") + ); +} + +#[sqlx::test] +async fn test_validation_zero_steps(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let errors = validate_flow_input("Test", &[]); + assert!( + errors + .iter() + .any(|e| e.field == "steps" && e.code == "min_items") + ); +} + +#[sqlx::test] +async fn test_validation_zero_method_step(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let errors = validate_flow_input("Test", &[vec![], vec![VpnClientMfaMethod::Totp]]); + assert!( + errors + .iter() + .any(|e| e.field == "steps[0].methods" && e.code == "min_items") + ); + // The valid step should not produce errors + assert!( + !errors + .iter() + .any(|e| e.field == "steps[1].methods" && e.code == "min_items") + ); +} + +#[sqlx::test] +async fn test_validation_duplicate_method(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let errors = validate_flow_input( + "Test", + &[vec![ + VpnClientMfaMethod::Totp, + VpnClientMfaMethod::Email, + VpnClientMfaMethod::Totp, + ]], + ); + assert!( + errors + .iter() + .any(|e| e.field == "steps[0].methods" && e.code == "duplicate") + ); +} From cc50147a07255ed237abe7dfdb490c8ceb4020f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:17:47 +0200 Subject: [PATCH 05/36] delegate update to helper methods --- .../defguard_common/src/db/models/mfa_flow.rs | 158 ++++++++++++------ 1 file changed, 104 insertions(+), 54 deletions(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 67f99df67..868b2f55a 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -89,6 +89,10 @@ pub fn validate_flow_input( errors } +/// Offset applied to existing step positions during a swap so that +/// intermediate positions never conflict. +pub const POSITION_SWAP_OFFSET: i32 = 10_000; + impl MfaFlow { /// Creates a new flow with its steps in a single transaction. /// `step_methods` is one `Vec` per step; positions are assigned 0-based @@ -115,6 +119,22 @@ impl MfaFlow { } impl MfaFlow { + /// Updates the title and `updated_at` for a flow row. + pub async fn update_title( + conn: &mut PgConnection, + flow_id: Id, + title: &str, + ) -> sqlx::Result<()> { + query!( + "UPDATE mfa_flow SET title = $1, updated_at = now() WHERE id = $2", + title, + flow_id, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + /// Lists all flows enriched with `step_count`. pub async fn list_with_step_count<'e, E: PgExecutor<'e>>( executor: E, @@ -152,72 +172,26 @@ impl MfaFlow { title: String, step_updates: Vec<(Option, Vec)>, ) -> sqlx::Result<(MfaFlow, Vec>)> { - const OFFSET: i32 = 10_000; - - let now = Utc::now(); - query!( - "UPDATE mfa_flow SET title = $1, updated_at = $2 WHERE id = $3", - title, - now, - flow_id, - ) - .execute(&mut *conn) - .await?; - let incoming_ids: Vec = step_updates.iter().filter_map(|(id, _)| *id).collect(); + Self::update_title(&mut *conn, flow_id, &title).await?; + if incoming_ids.is_empty() { - query!("DELETE FROM mfa_flow_step WHERE flow_id = $1", flow_id,) - .execute(&mut *conn) - .await?; + MfaFlowStep::delete_by_flow(&mut *conn, flow_id).await?; } else { - query!( - "DELETE FROM mfa_flow_step \ - WHERE flow_id = $1 AND id != ALL($2::bigint[])", - flow_id, - &incoming_ids, - ) - .execute(&mut *conn) - .await?; - - query!( - "UPDATE mfa_flow_step \ - SET position = position + $2 \ - WHERE flow_id = $1 AND id = ANY($3::bigint[])", - flow_id, - OFFSET, - &incoming_ids, - ) - .execute(&mut *conn) - .await?; + MfaFlowStep::delete_by_flow_except(&mut *conn, flow_id, &incoming_ids).await?; + MfaFlowStep::offset_positions(&mut *conn, flow_id, POSITION_SWAP_OFFSET, &incoming_ids) + .await?; } let mut resulting_steps = Vec::with_capacity(step_updates.len()); for (index, (maybe_id, methods)) in step_updates.into_iter().enumerate() { let position = index as i32; let id = if let Some(step_id) = maybe_id { - query!( - "UPDATE mfa_flow_step \ - SET position = $1, methods = $2::vpn_client_mfa_method[] \ - WHERE id = $3", - position, - &methods as &[VpnClientMfaMethod], - step_id, - ) - .execute(&mut *conn) - .await?; + MfaFlowStep::update_single(&mut *conn, step_id, position, &methods).await?; step_id } else { - let new_id = query_scalar!( - "INSERT INTO mfa_flow_step (flow_id, position, methods) \ - VALUES ($1, $2, $3::vpn_client_mfa_method[]) RETURNING id", - flow_id, - position, - &methods as &[VpnClientMfaMethod], - ) - .fetch_one(&mut *conn) - .await?; - new_id + MfaFlowStep::insert_single(&mut *conn, flow_id, position, &methods).await? }; resulting_steps.push(MfaFlowStep { @@ -237,6 +211,25 @@ impl MfaFlow { } impl MfaFlowStep { + /// Inserts a single step and returns its assigned id. + pub async fn insert_single( + conn: &mut PgConnection, + flow_id: Id, + position: i32, + methods: &[VpnClientMfaMethod], + ) -> sqlx::Result { + let id = query_scalar!( + "INSERT INTO mfa_flow_step (flow_id, position, methods) \ + VALUES ($1, $2, $3::vpn_client_mfa_method[]) RETURNING id", + flow_id, + position, + methods as &[VpnClientMfaMethod], + ) + .fetch_one(&mut *conn) + .await?; + Ok(id) + } + /// Inserts a batch of steps for a flow, assigning contiguous 0-based positions /// from the outer array order. pub async fn insert_batch( @@ -286,6 +279,63 @@ impl MfaFlowStep { .await } + /// Deletes all steps for a given flow except those whose id is in `keep_ids`. + pub async fn delete_by_flow_except( + conn: &mut PgConnection, + flow_id: Id, + keep_ids: &[Id], + ) -> sqlx::Result<()> { + query!( + "DELETE FROM mfa_flow_step \ + WHERE flow_id = $1 AND id != ALL($2::bigint[])", + flow_id, + keep_ids, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + + /// Offsets the position of the given steps by `offset` to make room for a swap. + pub async fn offset_positions( + conn: &mut PgConnection, + flow_id: Id, + offset: i32, + step_ids: &[Id], + ) -> sqlx::Result<()> { + query!( + "UPDATE mfa_flow_step \ + SET position = position + $2 \ + WHERE flow_id = $1 AND id = ANY($3::bigint[])", + flow_id, + offset, + step_ids, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + + /// Updates the position and methods of a single step. + pub async fn update_single( + conn: &mut PgConnection, + step_id: Id, + position: i32, + methods: &[VpnClientMfaMethod], + ) -> sqlx::Result<()> { + query!( + "UPDATE mfa_flow_step \ + SET position = $1, methods = $2::vpn_client_mfa_method[] \ + WHERE id = $3", + position, + methods as &[VpnClientMfaMethod], + step_id, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + /// Deletes all steps for a given flow. pub async fn delete_by_flow(conn: &mut PgConnection, flow_id: Id) -> sqlx::Result<()> { query!("DELETE FROM mfa_flow_step WHERE flow_id = $1", flow_id) From 1b76afced19ed44dcdb9f9193f0c922610deeba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:25:33 +0200 Subject: [PATCH 06/36] add CRUD handlers --- crates/defguard_core/src/handlers/mfa_flow.rs | 367 ++++++++++++++++++ crates/defguard_core/src/handlers/mod.rs | 1 + crates/defguard_core/src/lib.rs | 13 + 3 files changed, 381 insertions(+) create mode 100644 crates/defguard_core/src/handlers/mfa_flow.rs diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs new file mode 100644 index 000000000..24bde5464 --- /dev/null +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -0,0 +1,367 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use defguard_common::db::{ + Id, + models::{ + mfa_flow::{ + MfaFlow, MfaFlowStep, MfaFlowValidationField, MfaFlowWithStepCount, validate_flow_input, + }, + vpn_client_session::VpnClientMfaMethod, + }, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use utoipa::ToSchema; + +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + error::WebError, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, +}; + +/// Enriched list item returned by `GET /mfa-flow`. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct MfaFlowListItemResponse { + pub id: Id, + pub title: String, + pub step_count: i64, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +impl From for MfaFlowListItemResponse { + fn from(f: MfaFlowWithStepCount) -> Self { + Self { + id: f.id, + title: f.title, + step_count: f.step_count, + created_at: f.created_at, + updated_at: f.updated_at, + } + } +} + +/// Full flow detail returned by `GET /mfa-flow/{id}`, `POST`, and `PUT`. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct MfaFlowDetailResponse { + pub id: Id, + pub title: String, + pub steps: Vec, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// A single step in a flow detail response. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct MfaFlowStepResponse { + pub id: Id, + pub position: i32, + pub methods: Vec, +} + +impl From> for MfaFlowStepResponse { + fn from(s: MfaFlowStep) -> Self { + Self { + id: s.id, + position: s.position, + methods: s.methods, + } + } +} + +/// Request body for creating an MFA flow. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateMfaFlowRequest { + pub title: String, + pub steps: Vec, +} + +/// A step within a create request: the server derives contiguous 0-based +/// positions from array order, so `position` is accepted but ignored. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateMfaFlowStep { + #[serde(default)] + pub position: i32, + pub methods: Vec, +} + +/// Request body for updating an MFA flow. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct UpdateMfaFlowRequest { + pub title: String, + pub steps: Vec, +} + +/// A step within an update request: existing steps carry `id` for +/// reconciliation; new steps omit `id` and are INSERTed. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct UpdateMfaFlowStep { + #[serde(default)] + pub id: Option, + pub position: i32, + pub methods: Vec, +} + +// Helpers + +/// Build a `400` response with structured `fields[]` errors. +fn validation_error_response(errors: Vec) -> ApiResponse { + let fields: Vec = errors + .iter() + .map(|e| json!({"field": e.field, "code": e.code})) + .collect(); + ApiResponse::new( + json!({"error": "validation_failed", "fields": fields}), + StatusCode::BAD_REQUEST, + ) +} + +/// Extract step methods from create request, deriving positions from array order. +fn extract_create_step_methods(steps: &[CreateMfaFlowStep]) -> Vec> { + let mut sorted: Vec<&CreateMfaFlowStep> = steps.iter().collect(); + sorted.sort_by_key(|s| s.position); + sorted.into_iter().map(|s| s.methods.clone()).collect() +} + +/// Extract step updates from update request, deriving positions from array order. +fn extract_update_step_updates( + steps: &[UpdateMfaFlowStep], +) -> Vec<(Option, Vec)> { + let mut sorted: Vec<&UpdateMfaFlowStep> = steps.iter().collect(); + sorted.sort_by_key(|s| s.position); + sorted + .into_iter() + .map(|s| (s.id, s.methods.clone())) + .collect() +} + +// Handlers + +/// List all MFA flows +#[utoipa::path( + get, + path = "/api/v1/mfa-flow", + tag = "mfa flow", + responses( + (status = 200, description = "List of MFA flows.", body = [MfaFlowListItemResponse]), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 500, description = "Unable to list MFA flows.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn list_mfa_flows( + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, +) -> ApiResult { + debug!("User {} listing MFA flows", session.user.username); + + let items = MfaFlow::list_with_step_count(&appstate.pool).await?; + let response: Vec = items.into_iter().map(Into::into).collect(); + + Ok(ApiResponse::json(response, StatusCode::OK)) +} + +/// Create an MFA flow +#[utoipa::path( + post, + path = "/api/v1/mfa-flow", + tag = "mfa flow", + request_body = CreateMfaFlowRequest, + responses( + (status = 201, description = "MFA flow created.", body = MfaFlowDetailResponse), + (status = 400, description = "Invalid request data.", body = ApiErrorResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 500, description = "Unable to create MFA flow.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn create_mfa_flow( + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + debug!( + "User {} creating MFA flow {:?}", + session.user.username, data.title + ); + + let step_methods = extract_create_step_methods(&data.steps); + let errors = validate_flow_input(&data.title, &step_methods); + if !errors.is_empty() { + return Ok(validation_error_response(errors)); + } + + let mut tx = appstate.pool.begin().await?; + let (flow, steps) = MfaFlow::create(&mut *tx, data.title, step_methods).await?; + tx.commit().await?; + + debug!("Created MFA flow {}", flow.id); + + let response = MfaFlowDetailResponse { + id: flow.id, + title: flow.title, + steps: steps.into_iter().map(Into::into).collect(), + created_at: flow.created_at, + updated_at: flow.updated_at, + }; + + Ok(ApiResponse::json(response, StatusCode::CREATED)) +} + +/// Get a single MFA flow +#[utoipa::path( + get, + path = "/api/v1/mfa-flow/{id}", + tag = "mfa flow", + params( + ("id" = i64, Path, description = "ID of the MFA flow.") + ), + responses( + (status = 200, description = "MFA flow details.", body = MfaFlowDetailResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 404, description = "MFA flow not found.", body = ApiErrorResponse, example = json!({"msg": "MFA flow 1 not found"})), + (status = 500, description = "Unable to get MFA flow.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn get_mfa_flow( + _admin: AdminRole, + session: SessionInfo, + Path(id): Path, + State(appstate): State, +) -> ApiResult { + debug!("User {} fetching MFA flow {id}", session.user.username); + + let flow = MfaFlow::find_by_id(&appstate.pool, id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("MFA flow {id} not found")))?; + let steps = MfaFlowStep::find_by_flow(&appstate.pool, id).await?; + + let response = MfaFlowDetailResponse { + id: flow.id, + title: flow.title, + steps: steps.into_iter().map(Into::into).collect(), + created_at: flow.created_at, + updated_at: flow.updated_at, + }; + + Ok(ApiResponse::json(response, StatusCode::OK)) +} + +/// Update an MFA flow +#[utoipa::path( + put, + path = "/api/v1/mfa-flow/{id}", + tag = "mfa flow", + params( + ("id" = i64, Path, description = "ID of the MFA flow.") + ), + request_body = UpdateMfaFlowRequest, + responses( + (status = 200, description = "MFA flow updated.", body = MfaFlowDetailResponse), + (status = 400, description = "Invalid request data.", body = ApiErrorResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 404, description = "MFA flow not found.", body = ApiErrorResponse, example = json!({"msg": "MFA flow 1 not found"})), + (status = 500, description = "Unable to update MFA flow.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn update_mfa_flow( + _admin: AdminRole, + session: SessionInfo, + Path(id): Path, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + debug!("User {} updating MFA flow {id}", session.user.username); + + // Ensure the flow exists + let existing = MfaFlow::find_by_id(&appstate.pool, id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("MFA flow {id} not found")))?; + + let step_updates = extract_update_step_updates(&data.steps); + let step_methods: Vec> = + data.steps.iter().map(|s| s.methods.clone()).collect(); + + let errors = validate_flow_input(&data.title, &step_methods); + if !errors.is_empty() { + return Ok(validation_error_response(errors)); + } + + let mut tx = appstate.pool.begin().await?; + let (flow, steps) = + MfaFlow::update_with_steps(&mut *tx, existing.id, data.title, step_updates).await?; + tx.commit().await?; + + let response = MfaFlowDetailResponse { + id: flow.id, + title: flow.title, + steps: steps.into_iter().map(Into::into).collect(), + created_at: flow.created_at, + updated_at: flow.updated_at, + }; + + Ok(ApiResponse::json(response, StatusCode::OK)) +} + +/// Delete an MFA flow +#[utoipa::path( + delete, + path = "/api/v1/mfa-flow/{id}", + tag = "mfa flow", + params( + ("id" = i64, Path, description = "ID of the MFA flow.") + ), + responses( + (status = 200, description = "MFA flow deleted."), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 404, description = "MFA flow not found.", body = ApiErrorResponse, example = json!({"msg": "MFA flow 1 not found"})), + (status = 500, description = "Unable to delete MFA flow.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn delete_mfa_flow( + _admin: AdminRole, + session: SessionInfo, + Path(id): Path, + State(appstate): State, +) -> ApiResult { + debug!("User {} deleting MFA flow {id}", session.user.username); + + let flow = MfaFlow::find_by_id(&appstate.pool, id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("MFA flow {id} not found")))?; + + flow.delete(&appstate.pool).await?; + + debug!("Deleted MFA flow {id}"); + + Ok(ApiResponse::default()) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 6bbb4d080..882fa43b7 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -43,6 +43,7 @@ pub(crate) mod group; pub mod license; pub(crate) mod location_stats; pub mod mail; +pub(crate) mod mfa_flow; pub mod network_devices; pub mod openid_clients; pub mod openid_flow; diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index d1d7b0aae..425bce6d4 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -49,6 +49,7 @@ use handlers::{ auth::disable_user_mfa, component_setup::{setup_proxy_tls_stream, stream_proxy_acme}, group::{bulk_assign_to_groups, list_groups_info}, + mfa_flow::{create_mfa_flow, delete_mfa_flow, get_mfa_flow, list_mfa_flows, update_mfa_flow}, network_devices::{ add_network_device, check_ip_availability, find_available_ips, get_network_device, list_network_devices, modify_network_device, network_device_configs, @@ -566,6 +567,18 @@ pub fn build_webapp( .route("/destination/apply", put(apply_acl_destinations)), ); + let api_router = api_router.nest( + "/api/v1/mfa-flow", + Router::new() + .route("/", get(list_mfa_flows).post(create_mfa_flow)) + .route( + "/{id}", + get(get_mfa_flow) + .put(update_mfa_flow) + .delete(delete_mfa_flow), + ), + ); + let api_router = api_router.nest( "/api/v1", Router::new() From f7b4d68289a979f51b024de227dff2b51bb8218c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:35:13 +0200 Subject: [PATCH 07/36] add activity log events --- .../defguard_common/src/db/models/mfa_flow.rs | 8 ++++ .../src/db/models/activity_log/mod.rs | 4 ++ crates/defguard_core/src/events.rs | 13 +++++- crates/defguard_core/src/handlers/mfa_flow.rs | 43 ++++++++++++++++++- .../defguard_event_logger/src/description.rs | 9 ++++ crates/defguard_event_logger/src/lib.rs | 13 ++++++ 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 868b2f55a..6c8664bff 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -37,6 +37,14 @@ pub struct MfaFlowWithStepCount { pub updated_at: DateTime, } +/// A point-in-time snapshot of an MFA flow and its steps, used as the +/// payload for audit events. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MfaFlowSnapshot { + pub flow: MfaFlow, + pub steps: Vec>, +} + /// A single structured validation error for an MFA flow input. #[derive(Clone, Debug)] pub struct MfaFlowValidationField { 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 0132c728e..a40f6d80c 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -144,6 +144,10 @@ pub enum EventType { DevicePostureDuplicated, DevicePostureLocationsAssigned, LocationPosturesAssigned, + // MFA flow management + MfaFlowCreated, + MfaFlowUpdated, + MfaFlowDeleted, DevicePostureCheckPassed, DevicePostureCheckFailed, // LDAP sync events diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 776bcb033..7402c15d6 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -5,7 +5,8 @@ use defguard_common::db::{ Id, models::{ AuthenticationKey, Device, MFAMethod, Settings, User, WebAuthn, WireguardNetwork, - gateway::Gateway, group::Group, oauth2client::OAuth2Client, proxy::Proxy, + gateway::Gateway, group::Group, mfa_flow::MfaFlowSnapshot, oauth2client::OAuth2Client, + proxy::Proxy, }, }; use defguard_proto::{client_types::MfaMethod, enterprise::posture::DevicePostureData}; @@ -364,6 +365,16 @@ pub enum ApiEventType { location: WireguardNetwork, posture_ids: Vec, }, + MfaFlowCreated { + snapshot: MfaFlowSnapshot, + }, + MfaFlowUpdated { + before: MfaFlowSnapshot, + after: MfaFlowSnapshot, + }, + MfaFlowDeleted { + snapshot: MfaFlowSnapshot, + }, } /// Events from Web API diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs index 24bde5464..0c5b4bce3 100644 --- a/crates/defguard_core/src/handlers/mfa_flow.rs +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -7,7 +7,8 @@ use defguard_common::db::{ Id, models::{ mfa_flow::{ - MfaFlow, MfaFlowStep, MfaFlowValidationField, MfaFlowWithStepCount, validate_flow_input, + MfaFlow, MfaFlowSnapshot, MfaFlowStep, MfaFlowValidationField, MfaFlowWithStepCount, + validate_flow_input, }, vpn_client_session::VpnClientMfaMethod, }, @@ -20,6 +21,7 @@ use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, error::WebError, + events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; @@ -191,6 +193,7 @@ pub async fn list_mfa_flows( pub async fn create_mfa_flow( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Json(data): Json, ) -> ApiResult { @@ -211,6 +214,16 @@ pub async fn create_mfa_flow( debug!("Created MFA flow {}", flow.id); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaFlowCreated { + snapshot: MfaFlowSnapshot { + flow: flow.clone(), + steps: steps.clone(), + }, + }), + })?; + let response = MfaFlowDetailResponse { id: flow.id, title: flow.title, @@ -291,6 +304,7 @@ pub async fn get_mfa_flow( pub async fn update_mfa_flow( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, Path(id): Path, State(appstate): State, Json(data): Json, @@ -301,6 +315,7 @@ pub async fn update_mfa_flow( let existing = MfaFlow::find_by_id(&appstate.pool, id) .await? .ok_or_else(|| WebError::ObjectNotFound(format!("MFA flow {id} not found")))?; + let before_steps = MfaFlowStep::find_by_flow(&appstate.pool, id).await?; let step_updates = extract_update_step_updates(&data.steps); let step_methods: Vec> = @@ -316,6 +331,20 @@ pub async fn update_mfa_flow( MfaFlow::update_with_steps(&mut *tx, existing.id, data.title, step_updates).await?; tx.commit().await?; + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaFlowUpdated { + before: MfaFlowSnapshot { + flow: existing, + steps: before_steps, + }, + after: MfaFlowSnapshot { + flow: flow.clone(), + steps: steps.clone(), + }, + }), + })?; + let response = MfaFlowDetailResponse { id: flow.id, title: flow.title, @@ -350,6 +379,7 @@ pub async fn update_mfa_flow( pub async fn delete_mfa_flow( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, Path(id): Path, State(appstate): State, ) -> ApiResult { @@ -358,10 +388,21 @@ pub async fn delete_mfa_flow( let flow = MfaFlow::find_by_id(&appstate.pool, id) .await? .ok_or_else(|| WebError::ObjectNotFound(format!("MFA flow {id} not found")))?; + let steps = MfaFlowStep::find_by_flow(&appstate.pool, id).await?; + + let snapshot = MfaFlowSnapshot { + flow: flow.clone(), + steps, + }; flow.delete(&appstate.pool).await?; debug!("Deleted MFA flow {id}"); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaFlowDeleted { snapshot }), + })?; + Ok(ApiResponse::default()) } diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index b58e27608..3f1b6786c 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -306,6 +306,15 @@ pub fn get_api_event_description(event: &ApiEventType) -> Option { posture_ids.len(), location.id )), + ApiEventType::MfaFlowCreated { snapshot } => { + Some(format!("Created MFA flow '{}'", snapshot.flow.title)) + } + ApiEventType::MfaFlowUpdated { after, .. } => { + Some(format!("Updated MFA flow '{}'", after.flow.title)) + } + ApiEventType::MfaFlowDeleted { snapshot } => { + Some(format!("Deleted MFA flow '{}'", snapshot.flow.title)) + } ApiEventType::EnrollmentTokenAdded { 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 3cf68f0d6..7618dc5da 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -635,6 +635,19 @@ fn map_to_activity_log_event(message: EventLoggerMessage) -> ActivityLogEvent ( + EventType::MfaFlowCreated, + serde_json::to_value(snapshot).ok(), + ), + ApiEventType::MfaFlowUpdated { before, after } => ( + EventType::MfaFlowUpdated, + serde_json::to_value(serde_json::json!({ "before": before, "after": after })) + .ok(), + ), + ApiEventType::MfaFlowDeleted { snapshot } => ( + EventType::MfaFlowDeleted, + serde_json::to_value(snapshot).ok(), + ), ApiEventType::DevicePostureDuplicated { original, duplicate, From 1481bf81bf0091b904c7114fe69749f61b30a3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:36:00 +0200 Subject: [PATCH 08/36] update query data --- ...5581761f270279e228566ef4731ef273ed6af.json | 15 ++++++++ ...b18a27caf969112e19f06026b13a43061cd68.json | 16 +++++++++ ...11b01e650621fc50950a05af7a94b9ec3cb19.json | 15 ++++++++ ...ce9807a4a82d95e92aa5ddfdef1d61a57ba42.json | 36 +++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 .sqlx/query-32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af.json create mode 100644 .sqlx/query-644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68.json create mode 100644 .sqlx/query-89f3d7afe2e412ae75b773e4e5e11b01e650621fc50950a05af7a94b9ec3cb19.json create mode 100644 .sqlx/query-dc7afcf8c0641b999d8244db937ce9807a4a82d95e92aa5ddfdef1d61a57ba42.json diff --git a/.sqlx/query-32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af.json b/.sqlx/query-32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af.json new file mode 100644 index 000000000..722e81582 --- /dev/null +++ b/.sqlx/query-32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mfa_flow_step WHERE flow_id = $1 AND id != ALL($2::bigint[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af" +} diff --git a/.sqlx/query-644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68.json b/.sqlx/query-644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68.json new file mode 100644 index 000000000..81092fc2c --- /dev/null +++ b/.sqlx/query-644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mfa_flow_step SET position = position + $2 WHERE flow_id = $1 AND id = ANY($3::bigint[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68" +} diff --git a/.sqlx/query-89f3d7afe2e412ae75b773e4e5e11b01e650621fc50950a05af7a94b9ec3cb19.json b/.sqlx/query-89f3d7afe2e412ae75b773e4e5e11b01e650621fc50950a05af7a94b9ec3cb19.json new file mode 100644 index 000000000..526c161a6 --- /dev/null +++ b/.sqlx/query-89f3d7afe2e412ae75b773e4e5e11b01e650621fc50950a05af7a94b9ec3cb19.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mfa_flow SET title = $1, updated_at = now() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "89f3d7afe2e412ae75b773e4e5e11b01e650621fc50950a05af7a94b9ec3cb19" +} diff --git a/.sqlx/query-dc7afcf8c0641b999d8244db937ce9807a4a82d95e92aa5ddfdef1d61a57ba42.json b/.sqlx/query-dc7afcf8c0641b999d8244db937ce9807a4a82d95e92aa5ddfdef1d61a57ba42.json new file mode 100644 index 000000000..df7c76d70 --- /dev/null +++ b/.sqlx/query-dc7afcf8c0641b999d8244db937ce9807a4a82d95e92aa5ddfdef1d61a57ba42.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mfa_flow_step SET position = $1, methods = $2::vpn_client_mfa_method[] WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + { + "Custom": { + "name": "vpn_client_mfa_method[]", + "kind": { + "Array": { + "Custom": { + "name": "vpn_client_mfa_method", + "kind": { + "Enum": [ + "totp", + "email", + "oidc", + "biometric", + "mobileapprove" + ] + } + } + } + } + } + }, + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dc7afcf8c0641b999d8244db937ce9807a4a82d95e92aa5ddfdef1d61a57ba42" +} From d75f55bf15994100863f23088dd7d28f442686e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:39:37 +0200 Subject: [PATCH 09/36] update migration to setup flow-to-location assignments --- .../20260811125537_[2.2.0]_mfa_flow.down.sql | 2 ++ .../20260811125537_[2.2.0]_mfa_flow.up.sql | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql index 5656a2c99..926c9aa28 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql @@ -1,2 +1,4 @@ +DROP TABLE IF EXISTS location_mfa_flow_group; +DROP TABLE IF EXISTS location_mfa_flow; DROP TABLE IF EXISTS mfa_flow_step; DROP TABLE IF EXISTS mfa_flow; diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql index 4425a69ca..9f95e45db 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql @@ -16,3 +16,22 @@ CREATE TABLE mfa_flow_step ( CONSTRAINT mfa_flow_step_position_nonneg CHECK (position >= 0) ); CREATE INDEX idx_mfa_flow_step_flow_id ON mfa_flow_step(flow_id); + +-- Location-to-flow assignment with ordered first-match precedence +CREATE TABLE location_mfa_flow ( + location_id BIGINT NOT NULL REFERENCES wireguard_network(id) ON DELETE CASCADE, + flow_id BIGINT NOT NULL REFERENCES mfa_flow(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (location_id, flow_id) +); + +-- Group scoping per assignment +CREATE TABLE location_mfa_flow_group ( + location_id BIGINT NOT NULL, + flow_id BIGINT NOT NULL, + group_id BIGINT NOT NULL REFERENCES "group"(id) ON DELETE CASCADE, + PRIMARY KEY (location_id, flow_id, group_id), + FOREIGN KEY (location_id, flow_id) + REFERENCES location_mfa_flow(location_id, flow_id) ON DELETE CASCADE +); From 443c8fc48acbdba91757288caa16e45147c54f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:43:19 +0200 Subject: [PATCH 10/36] add assignment model & helper methods --- .../defguard_common/src/db/models/mfa_flow.rs | 94 ++++++++++++++ .../src/db/models/mfa_flow/tests.rs | 122 +++++++++++++++++- 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 6c8664bff..cfaf195bc 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -45,6 +45,25 @@ pub struct MfaFlowSnapshot { pub steps: Vec>, } +/// Assignment of an MFA flow to a location, enriched for API consumption. +#[derive(Clone, Debug, Serialize)] +pub struct LocationMfaFlowItem { + pub id: Id, + pub title: String, + pub step_count: i64, + pub group_names: Vec, + pub position: i32, + pub is_default: bool, +} + +/// Input for a single flow assignment to a location. +#[derive(Clone, Debug)] +pub struct LocationMfaFlowAssignment { + pub flow_id: Id, + pub is_default: bool, + pub group_ids: Vec, +} + /// A single structured validation error for an MFA flow input. #[derive(Clone, Debug)] pub struct MfaFlowValidationField { @@ -216,6 +235,81 @@ impl MfaFlow { Ok((flow, resulting_steps)) } + + /// Replaces all MFA flow assignments for a location. + pub async fn assign_to_location( + conn: &mut PgConnection, + location_id: Id, + assignments: &[LocationMfaFlowAssignment], + ) -> sqlx::Result<()> { + query!( + "DELETE FROM location_mfa_flow WHERE location_id = $1", + location_id, + ) + .execute(&mut *conn) + .await?; + + for (i, a) in assignments.iter().enumerate() { + let position = i as i32; + query!( + "INSERT INTO location_mfa_flow (location_id, flow_id, position, is_default) \ + VALUES ($1, $2, $3, $4)", + location_id, + a.flow_id, + position, + a.is_default, + ) + .execute(&mut *conn) + .await?; + + if !a.group_ids.is_empty() { + query!( + "INSERT INTO location_mfa_flow_group (location_id, flow_id, group_id) \ + SELECT $1, $2, unnest($3::bigint[])", + location_id, + a.flow_id, + &a.group_ids, + ) + .execute(&mut *conn) + .await?; + } + } + + Ok(()) + } + + /// Returns the enriched assignment list for a location, ordered by position. + pub async fn for_location<'e, E: PgExecutor<'e>>( + executor: E, + location_id: Id, + ) -> sqlx::Result> { + query_as!( + LocationMfaFlowItem, + "SELECT mf.id, mf.title, \ + COALESCE(s.step_count, 0) AS \"step_count!: i64\", \ + COALESCE(array_agg(g.name ORDER BY g.name) \ + FILTER (WHERE g.name IS NOT NULL), '{}') \ + AS \"group_names!: Vec\", \ + lmf.position, lmf.is_default \ + FROM location_mfa_flow lmf \ + JOIN mfa_flow mf ON mf.id = lmf.flow_id \ + LEFT JOIN ( \ + SELECT flow_id, COUNT(*) AS step_count \ + FROM mfa_flow_step \ + GROUP BY flow_id \ + ) s ON s.flow_id = mf.id \ + LEFT JOIN location_mfa_flow_group lmfg \ + ON lmfg.location_id = lmf.location_id \ + AND lmfg.flow_id = lmf.flow_id \ + LEFT JOIN \"group\" g ON g.id = lmfg.group_id \ + WHERE lmf.location_id = $1 \ + GROUP BY mf.id, mf.title, s.step_count, lmf.position, lmf.is_default \ + ORDER BY lmf.position", + location_id + ) + .fetch_all(executor) + .await + } } impl MfaFlowStep { diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index 3aa397378..bf483a514 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -1,7 +1,7 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; -use crate::db::setup_pool; +use crate::db::{models::wireguard::WireguardNetwork, setup_pool}; /// Helper: create a flow with two steps and return its (flow, steps). async fn create_flow(pool: &sqlx::PgPool) -> (MfaFlow, Vec>) { @@ -166,6 +166,126 @@ async fn test_position_swap(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(updated_steps[1].position, 1); } +#[sqlx::test] +async fn test_assign_to_location(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut *tx, + "Second Flow".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let network = WireguardNetwork::default() + .try_set_address("10.0.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + // Assign two flows to the location + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[ + LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: false, + group_ids: vec![], + }, + LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: true, + group_ids: vec![], + }, + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let items = MfaFlow::for_location(&pool, network.id).await.unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].id, flow1.id); + assert_eq!(items[0].position, 0); + assert!(!items[0].is_default); + assert_eq!(items[0].group_names.len(), 0); + assert_eq!(items[1].id, flow2.id); + assert_eq!(items[1].position, 1); + assert!(items[1].is_default); + assert_eq!(items[0].step_count, 2); + assert_eq!(items[1].step_count, 1); +} + +#[sqlx::test] +async fn test_assign_to_location_full_replace(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut *tx, + "Second Flow".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let network = WireguardNetwork::default() + .try_set_address("10.0.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + // First assignment: flow1 only + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Second assignment replaces: flow2 only + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let items = MfaFlow::for_location(&pool, network.id).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, flow2.id); +} + #[sqlx::test] async fn test_validation_empty_title(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 3eb577feb553593de64613e02e50e8e141e99e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:46:52 +0200 Subject: [PATCH 11/36] enforce default flow --- .../defguard_common/src/db/models/mfa_flow.rs | 22 +++++++- .../src/db/models/mfa_flow/tests.rs | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index cfaf195bc..dc155320c 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; use model_derive::Model; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgConnection, PgExecutor, query, query_as, query_scalar}; +use thiserror::Error; use utoipa::ToSchema; use crate::db::{Id, NoId, models::vpn_client_session::VpnClientMfaMethod}; @@ -64,6 +65,15 @@ pub struct LocationMfaFlowAssignment { pub group_ids: Vec, } +/// Errors that can occur during MFA flow assignment. +#[derive(Debug, Error)] +pub enum MfaFlowAssignmentError { + #[error("No default MFA flow designated for this location")] + NoDefaultDesignated, + #[error(transparent)] + Sqlx(#[from] sqlx::Error), +} + /// A single structured validation error for an MFA flow input. #[derive(Clone, Debug)] pub struct MfaFlowValidationField { @@ -241,7 +251,17 @@ impl MfaFlow { conn: &mut PgConnection, location_id: Id, assignments: &[LocationMfaFlowAssignment], - ) -> sqlx::Result<()> { + ) -> Result<(), MfaFlowAssignmentError> { + let default_count = assignments.iter().filter(|a| a.is_default).count(); + if default_count != 1 { + return Err(MfaFlowAssignmentError::NoDefaultDesignated); + } + if let Some(default) = assignments.iter().find(|a| a.is_default) { + if !default.group_ids.is_empty() { + return Err(MfaFlowAssignmentError::NoDefaultDesignated); + } + } + query!( "DELETE FROM location_mfa_flow WHERE location_id = $1", location_id, diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index bf483a514..ae68aa4a5 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -286,6 +286,62 @@ async fn test_assign_to_location_full_replace(_: PgPoolOptions, options: PgConne assert_eq!(items[0].id, flow2.id); } +#[sqlx::test] +async fn test_assign_no_default_rejected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow1, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.2.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let result = MfaFlow::assign_to_location( + &mut *pool.acquire().await.unwrap(), + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: false, + group_ids: vec![], + }], + ) + .await; + assert!(matches!( + result, + Err(MfaFlowAssignmentError::NoDefaultDesignated) + )); +} + +#[sqlx::test] +async fn test_assign_default_with_groups_rejected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow1, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.3.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let result = MfaFlow::assign_to_location( + &mut *pool.acquire().await.unwrap(), + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: true, + group_ids: vec![flow1.id], // default must have empty groups + }], + ) + .await; + assert!(matches!( + result, + Err(MfaFlowAssignmentError::NoDefaultDesignated) + )); +} + #[sqlx::test] async fn test_validation_empty_title(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 2bab922ac4d3f9c14b534ed81d0fff8f3b46545b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 19:54:15 +0200 Subject: [PATCH 12/36] validate delete operations --- .../defguard_common/src/db/models/mfa_flow.rs | 52 ++++++++++++ .../src/db/models/mfa_flow/tests.rs | 85 +++++++++++++++++++ crates/defguard_core/src/handlers/mfa_flow.rs | 33 ++++++- 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index dc155320c..5b8b5c982 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -74,6 +74,17 @@ pub enum MfaFlowAssignmentError { Sqlx(#[from] sqlx::Error), } +/// Errors that can occur when deleting an MFA flow. +#[derive(Debug, Error)] +pub enum MfaFlowDeleteError { + #[error("MFA flow is the only assignment for location(s): {}", .0.join(", "))] + LocationRequiresFlow(Vec), + #[error("MFA flow is the designated default for location(s): {}", .0.join(", "))] + FlowIsDefault(Vec), + #[error(transparent)] + Sqlx(#[from] sqlx::Error), +} + /// A single structured validation error for an MFA flow input. #[derive(Clone, Debug)] pub struct MfaFlowValidationField { @@ -330,6 +341,47 @@ impl MfaFlow { .fetch_all(executor) .await } + + /// Checks whether a flow can be deleted, returning an error naming the + /// affected locations if it cannot. + pub async fn check_deletable<'e, E: PgExecutor<'e> + Copy>( + executor: E, + flow_id: Id, + ) -> Result<(), MfaFlowDeleteError> { + // Flow is the only assignment for any location? + let orphaned: Vec = query_scalar!( + "SELECT wn.name \ + FROM location_mfa_flow lmf \ + JOIN wireguard_network wn ON wn.id = lmf.location_id \ + WHERE lmf.flow_id = $1 \ + AND (SELECT COUNT(*) FROM location_mfa_flow \ + WHERE location_id = lmf.location_id) = 1", + flow_id + ) + .fetch_all(executor) + .await?; + + if !orphaned.is_empty() { + return Err(MfaFlowDeleteError::LocationRequiresFlow(orphaned)); + } + + // Flow is the designated default for any location? + let defaults: Vec = query_scalar!( + "SELECT wn.name \ + FROM location_mfa_flow lmf \ + JOIN wireguard_network wn ON wn.id = lmf.location_id \ + WHERE lmf.flow_id = $1 AND lmf.is_default = true", + flow_id + ) + .fetch_all(executor) + .await?; + + if !defaults.is_empty() { + return Err(MfaFlowDeleteError::FlowIsDefault(defaults)); + } + + Ok(()) + } } impl MfaFlowStep { diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index ae68aa4a5..81f6189de 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -342,6 +342,91 @@ async fn test_assign_default_with_groups_rejected(_: PgPoolOptions, options: PgC )); } +#[sqlx::test] +async fn test_check_deletable_location_requires_flow(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow1, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.4.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let result = MfaFlow::check_deletable(&pool, flow1.id).await; + assert!(matches!( + result, + Err(MfaFlowDeleteError::LocationRequiresFlow(_)) + )); +} + +#[sqlx::test] +async fn test_check_deletable_flow_is_default(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut *tx, + "Second".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let network = WireguardNetwork::default() + .try_set_address("10.0.5.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[ + LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: true, + group_ids: vec![], + }, + LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: false, + group_ids: vec![], + }, + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // flow2 is not the only assignment and not default → OK + assert!(MfaFlow::check_deletable(&pool, flow2.id).await.is_ok()); + // flow1 is the default → refused + let result = MfaFlow::check_deletable(&pool, flow1.id).await; + assert!(matches!(result, Err(MfaFlowDeleteError::FlowIsDefault(_)))); +} + #[sqlx::test] async fn test_validation_empty_title(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs index 0c5b4bce3..2432b16a0 100644 --- a/crates/defguard_core/src/handlers/mfa_flow.rs +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -7,8 +7,8 @@ use defguard_common::db::{ Id, models::{ mfa_flow::{ - MfaFlow, MfaFlowSnapshot, MfaFlowStep, MfaFlowValidationField, MfaFlowWithStepCount, - validate_flow_input, + MfaFlow, MfaFlowDeleteError, MfaFlowSnapshot, MfaFlowStep, MfaFlowValidationField, + MfaFlowWithStepCount, validate_flow_input, }, vpn_client_session::VpnClientMfaMethod, }, @@ -388,6 +388,35 @@ pub async fn delete_mfa_flow( let flow = MfaFlow::find_by_id(&appstate.pool, id) .await? .ok_or_else(|| WebError::ObjectNotFound(format!("MFA flow {id} not found")))?; + + MfaFlow::check_deletable(&appstate.pool, id) + .await + .map_err(|e| match e { + MfaFlowDeleteError::LocationRequiresFlow(locations) => WebError::BadRequest( + serde_json::json!({ + "error": "validation_failed", + "fields": [{ + "field": "id", + "code": "location_requires_flow", + "locations": locations, + }] + }) + .to_string(), + ), + MfaFlowDeleteError::FlowIsDefault(locations) => WebError::BadRequest( + serde_json::json!({ + "error": "validation_failed", + "fields": [{ + "field": "id", + "code": "flow_is_default", + "locations": locations, + }] + }) + .to_string(), + ), + MfaFlowDeleteError::Sqlx(e) => WebError::from(e), + })?; + let steps = MfaFlowStep::find_by_flow(&appstate.pool, id).await?; let snapshot = MfaFlowSnapshot { From 2f8b10af47ec0167bff67ac0b7a00634bf0e58e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 20:19:50 +0200 Subject: [PATCH 13/36] add assignment endpoints & activity log event --- .../src/db/models/activity_log/mod.rs | 1 + crates/defguard_core/src/events.rs | 5 + crates/defguard_core/src/handlers/mfa_flow.rs | 154 +++++++++++++++++- crates/defguard_core/src/lib.rs | 13 +- .../defguard_event_logger/src/description.rs | 7 + crates/defguard_event_logger/src/lib.rs | 13 ++ 6 files changed, 191 insertions(+), 2 deletions(-) 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 a40f6d80c..5ac452fd0 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -148,6 +148,7 @@ pub enum EventType { MfaFlowCreated, MfaFlowUpdated, MfaFlowDeleted, + LocationMfaFlowsAssigned, DevicePostureCheckPassed, DevicePostureCheckFailed, // LDAP sync events diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 7402c15d6..dd3e7931f 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -375,6 +375,11 @@ pub enum ApiEventType { MfaFlowDeleted { snapshot: MfaFlowSnapshot, }, + LocationMfaFlowsAssigned { + location_id: Id, + location_name: String, + assignment_count: i64, + }, } /// Events from Web API diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs index 2432b16a0..a5e7fd5b1 100644 --- a/crates/defguard_core/src/handlers/mfa_flow.rs +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -7,7 +7,8 @@ use defguard_common::db::{ Id, models::{ mfa_flow::{ - MfaFlow, MfaFlowDeleteError, MfaFlowSnapshot, MfaFlowStep, MfaFlowValidationField, + LocationMfaFlowAssignment, LocationMfaFlowItem, MfaFlow, MfaFlowAssignmentError, + MfaFlowDeleteError, MfaFlowSnapshot, MfaFlowStep, MfaFlowValidationField, MfaFlowWithStepCount, validate_flow_input, }, vpn_client_session::VpnClientMfaMethod, @@ -108,6 +109,45 @@ pub struct UpdateMfaFlowStep { pub methods: Vec, } +/// Request body for assigning flows to a location. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct AssignMfaFlowsRequest { + pub assignments: Vec, +} + +/// A single entry in an assignment list. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct AssignMfaFlowEntry { + pub flow_id: Id, + pub is_default: bool, + #[serde(default)] + pub group_ids: Vec, +} + +/// Assignment item returned by `GET /location/{id}/mfa-flows`. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct LocationMfaFlowResponse { + pub id: Id, + pub title: String, + pub step_count: i64, + pub group_names: Vec, + pub position: i32, + pub is_default: bool, +} + +impl From for LocationMfaFlowResponse { + fn from(item: LocationMfaFlowItem) -> Self { + Self { + id: item.id, + title: item.title, + step_count: item.step_count, + group_names: item.group_names, + position: item.position, + is_default: item.is_default, + } + } +} + // Helpers /// Build a `400` response with structured `fields[]` errors. @@ -435,3 +475,115 @@ pub async fn delete_mfa_flow( Ok(ApiResponse::default()) } + +/// Get MFA flows assigned to a location +#[utoipa::path( + get, + path = "/api/v1/location/{id}/mfa-flows", + tag = "mfa flow", + params( + ("id" = i64, Path, description = "ID of the location.") + ), + responses( + (status = 200, description = "MFA flows assigned to the location.", body = [LocationMfaFlowResponse]), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse), + (status = 500, description = "Unable to list assigned flows.", body = ApiErrorResponse) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn get_location_mfa_flows( + _admin: AdminRole, + session: SessionInfo, + Path(id): Path, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} getting MFA flows for location {id}", + session.user.username + ); + + let items = MfaFlow::for_location(&appstate.pool, id).await?; + let response: Vec = items.into_iter().map(Into::into).collect(); + + Ok(ApiResponse::json(response, StatusCode::OK)) +} + +/// Assign MFA flows to a location (full replace) +#[utoipa::path( + put, + path = "/api/v1/location/{id}/mfa-flows", + tag = "mfa flow", + params( + ("id" = i64, Path, description = "ID of the location.") + ), + request_body = AssignMfaFlowsRequest, + responses( + (status = 200, description = "MFA flows assigned to the location.", body = [LocationMfaFlowResponse]), + (status = 400, description = "Invalid assignment.", body = ApiErrorResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse), + (status = 500, description = "Unable to assign flows.", body = ApiErrorResponse) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn set_location_mfa_flows( + _admin: AdminRole, + session: SessionInfo, + context: ApiRequestContext, + Path(location_id): Path, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + debug!( + "User {} assigning MFA flows to location {location_id}", + session.user.username + ); + + let assignments: Vec = data + .assignments + .into_iter() + .map(|a| LocationMfaFlowAssignment { + flow_id: a.flow_id, + is_default: a.is_default, + group_ids: a.group_ids, + }) + .collect(); + + let mut tx = appstate.pool.begin().await?; + MfaFlow::assign_to_location(&mut *tx, location_id, &assignments) + .await + .map_err(|e| match e { + MfaFlowAssignmentError::NoDefaultDesignated => { + let fields: Vec = vec![json!({ + "field": "mfa_flows", + "code": "no_default_designated" + })]; + WebError::BadRequest( + json!({"error": "validation_failed", "fields": fields}).to_string(), + ) + } + MfaFlowAssignmentError::Sqlx(e) => WebError::from(e), + })?; + tx.commit().await?; + + let items = MfaFlow::for_location(&appstate.pool, location_id).await?; + let response: Vec = items.into_iter().map(Into::into).collect(); + + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::LocationMfaFlowsAssigned { + location_id, + location_name: String::new(), // populated by event logger + assignment_count: response.len() as i64, + }), + })?; + + Ok(ApiResponse::json(response, StatusCode::OK)) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 425bce6d4..010318f6a 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -49,7 +49,10 @@ use handlers::{ auth::disable_user_mfa, component_setup::{setup_proxy_tls_stream, stream_proxy_acme}, group::{bulk_assign_to_groups, list_groups_info}, - mfa_flow::{create_mfa_flow, delete_mfa_flow, get_mfa_flow, list_mfa_flows, update_mfa_flow}, + mfa_flow::{ + create_mfa_flow, delete_mfa_flow, get_location_mfa_flows, get_mfa_flow, list_mfa_flows, + set_location_mfa_flows, update_mfa_flow, + }, network_devices::{ add_network_device, check_ip_availability, find_available_ips, get_network_device, list_network_devices, modify_network_device, network_device_configs, @@ -579,6 +582,14 @@ pub fn build_webapp( ), ); + let api_router = api_router.nest( + "/api/v1", + Router::new().route( + "/location/{id}/mfa-flows", + get(get_location_mfa_flows).put(set_location_mfa_flows), + ), + ); + let api_router = api_router.nest( "/api/v1", Router::new() diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index 3f1b6786c..f762d0260 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -315,6 +315,13 @@ pub fn get_api_event_description(event: &ApiEventType) -> Option { ApiEventType::MfaFlowDeleted { snapshot } => { Some(format!("Deleted MFA flow '{}'", snapshot.flow.title)) } + ApiEventType::LocationMfaFlowsAssigned { + location_name, + assignment_count, + .. + } => Some(format!( + "Assigned {assignment_count} MFA flow(s) to location '{location_name}'" + )), ApiEventType::EnrollmentTokenAdded { 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 7618dc5da..0e052af20 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -648,6 +648,19 @@ fn map_to_activity_log_event(message: EventLoggerMessage) -> ActivityLogEvent ( + EventType::LocationMfaFlowsAssigned, + serde_json::to_value(serde_json::json!({ + "location_id": location_id, + "location_name": location_name, + "assignment_count": assignment_count, + })) + .ok(), + ), ApiEventType::DevicePostureDuplicated { original, duplicate, From c17f8e22c01497e43ea21d7d64e06927980dc71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 20:30:19 +0200 Subject: [PATCH 14/36] add helper for resolving flow for user --- .../defguard_common/src/db/models/mfa_flow.rs | 75 ++++++++++ .../src/db/models/mfa_flow/tests.rs | 131 +++++++++++++++++- 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 5b8b5c982..7e5989db8 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -382,6 +382,81 @@ impl MfaFlow { Ok(()) } + + /// Resolves the first matching MFA flow for a user at a location. + pub async fn resolve_for_user<'e>( + executor: impl PgExecutor<'e> + Copy, + location_id: Id, + user_id: Id, + ) -> sqlx::Result, Vec>)>> { + use std::collections::HashSet; + + let assignments = query_as!( + ResolveAssignmentRow, + "SELECT lmf.flow_id, lmf.is_default, \ + COALESCE(array_agg(lmfg.group_id) \ + FILTER (WHERE lmfg.group_id IS NOT NULL), '{}') \ + AS \"group_ids!: Vec\" \ + FROM location_mfa_flow lmf \ + LEFT JOIN location_mfa_flow_group lmfg \ + ON lmfg.location_id = lmf.location_id \ + AND lmfg.flow_id = lmf.flow_id \ + WHERE lmf.location_id = $1 \ + GROUP BY lmf.flow_id, lmf.position, lmf.is_default \ + ORDER BY lmf.position", + location_id + ) + .fetch_all(executor) + .await?; + + if assignments.is_empty() { + return Ok(None); + } + + let user_groups: HashSet = query_scalar!( + "SELECT group_id FROM group_user WHERE user_id = $1", + user_id + ) + .fetch_all(executor) + .await? + .into_iter() + .flatten() + .collect(); + + let mut default_flow_id: Option = None; + for assignment in &assignments { + if assignment.is_default { + default_flow_id = Some(assignment.flow_id); + } else if assignment + .group_ids + .iter() + .any(|group_id| user_groups.contains(group_id)) + { + let flow = MfaFlow::find_by_id(executor, assignment.flow_id) + .await? + .expect("flow referenced by assignment must exist"); + let steps = MfaFlowStep::find_by_flow(executor, assignment.flow_id).await?; + return Ok(Some((flow, steps))); + } + } + + if let Some(flow_id) = default_flow_id { + let flow = MfaFlow::find_by_id(executor, flow_id) + .await? + .expect("default flow must exist"); + let steps = MfaFlowStep::find_by_flow(executor, flow_id).await?; + return Ok(Some((flow, steps))); + } + + Ok(None) + } +} + +/// Internal row type for the resolve_for_user query. +struct ResolveAssignmentRow { + flow_id: Id, + is_default: bool, + group_ids: Vec, } impl MfaFlowStep { diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index 81f6189de..447736c9f 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -1,7 +1,10 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; -use crate::db::{models::wireguard::WireguardNetwork, setup_pool}; +use crate::db::{ + models::{group::Group, user::User, wireguard::WireguardNetwork}, + setup_pool, +}; /// Helper: create a flow with two steps and return its (flow, steps). async fn create_flow(pool: &sqlx::PgPool) -> (MfaFlow, Vec>) { @@ -427,6 +430,132 @@ async fn test_check_deletable_flow_is_default(_: PgPoolOptions, options: PgConne assert!(matches!(result, Err(MfaFlowDeleteError::FlowIsDefault(_)))); } +#[sqlx::test] +async fn test_resolve_group_match(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut *tx, + "Default".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let user = User::new("resolver", None, "Ln", "Fn", "r@t.com", None) + .save(&pool) + .await + .unwrap(); + let group = Group::new("resolver-group").save(&pool).await.unwrap(); + sqlx::query!( + "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2)", + group.id, + user.id, + ) + .execute(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.0.6.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[ + LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: false, + group_ids: vec![group.id], + }, + LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: true, + group_ids: vec![], + }, + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let result = MfaFlow::resolve_for_user(&pool, network.id, user.id) + .await + .unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().0.id, flow1.id); +} + +#[sqlx::test] +async fn test_resolve_fallback_to_default(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut *tx, + "Default".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let user = User::new("fallback", None, "Ln", "Fn", "f@t.com", None) + .save(&pool) + .await + .unwrap(); + let group = Group::new("fb-group").save(&pool).await.unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.0.7.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[ + LocationMfaFlowAssignment { + flow_id: flow1.id, + is_default: false, + group_ids: vec![group.id], + }, + LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: true, + group_ids: vec![], + }, + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let result = MfaFlow::resolve_for_user(&pool, network.id, user.id) + .await + .unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().0.id, flow2.id); +} + #[sqlx::test] async fn test_validation_empty_title(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 2c1bf3117fbe85ef75c8456b6bc3507580318b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 20:32:58 +0200 Subject: [PATCH 15/36] add and backfill mfa_enabled column --- migrations/20260811125537_[2.2.0]_mfa_flow.down.sql | 1 + migrations/20260811125537_[2.2.0]_mfa_flow.up.sql | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql index 926c9aa28..a07b6bae1 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql @@ -2,3 +2,4 @@ DROP TABLE IF EXISTS location_mfa_flow_group; DROP TABLE IF EXISTS location_mfa_flow; DROP TABLE IF EXISTS mfa_flow_step; DROP TABLE IF EXISTS mfa_flow; +ALTER TABLE wireguard_network DROP COLUMN IF EXISTS mfa_enabled; diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql index 9f95e45db..010eecdd2 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql @@ -35,3 +35,7 @@ CREATE TABLE location_mfa_flow_group ( FOREIGN KEY (location_id, flow_id) REFERENCES location_mfa_flow(location_id, flow_id) ON DELETE CASCADE ); + +-- Stored MFA toggle, independent of assignment presence +ALTER TABLE wireguard_network ADD COLUMN mfa_enabled BOOLEAN NOT NULL DEFAULT false; +UPDATE wireguard_network SET mfa_enabled = (location_mfa_mode <> 'disabled'); From 46fe739fbe61266b25fb3d7bc3bfa9db7b449d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 20:35:16 +0200 Subject: [PATCH 16/36] backfill legacy-compatible flows --- .../20260811125537_[2.2.0]_mfa_flow.up.sql | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql index 010eecdd2..9d2ebb238 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql @@ -39,3 +39,35 @@ CREATE TABLE location_mfa_flow_group ( -- Stored MFA toggle, independent of assignment presence ALTER TABLE wireguard_network ADD COLUMN mfa_enabled BOOLEAN NOT NULL DEFAULT false; UPDATE wireguard_network SET mfa_enabled = (location_mfa_mode <> 'disabled'); + +-- Backfill: create shared default flows for existing MFA-enabled locations + +-- "Default Internal MFA": one step with all internal methods +INSERT INTO mfa_flow (title) +SELECT 'Default Internal MFA' +WHERE EXISTS (SELECT 1 FROM wireguard_network WHERE location_mfa_mode = 'internal'); + +INSERT INTO mfa_flow_step (flow_id, position, methods) +SELECT mf.id, 0, ARRAY['totp','email','biometric','mobileapprove']::vpn_client_mfa_method[] +FROM mfa_flow mf +WHERE mf.title = 'Default Internal MFA'; + +INSERT INTO location_mfa_flow (location_id, flow_id, position, is_default) +SELECT wn.id, mf.id, 0, true +FROM wireguard_network wn, mfa_flow mf +WHERE wn.location_mfa_mode = 'internal' AND mf.title = 'Default Internal MFA'; + +-- "Default External MFA": one step with OIDC +INSERT INTO mfa_flow (title) +SELECT 'Default External MFA' +WHERE EXISTS (SELECT 1 FROM wireguard_network WHERE location_mfa_mode = 'external'); + +INSERT INTO mfa_flow_step (flow_id, position, methods) +SELECT mf.id, 0, ARRAY['oidc']::vpn_client_mfa_method[] +FROM mfa_flow mf +WHERE mf.title = 'Default External MFA'; + +INSERT INTO location_mfa_flow (location_id, flow_id, position, is_default) +SELECT wn.id, mf.id, 0, true +FROM wireguard_network wn, mfa_flow mf +WHERE wn.location_mfa_mode = 'external' AND mf.title = 'Default External MFA'; From 31731bdfae9ca0cae71570d6274877bce517b993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 20:40:00 +0200 Subject: [PATCH 17/36] derive legacy MFA mode if possible --- .../defguard_common/src/db/models/mfa_flow.rs | 47 ++++- .../src/db/models/mfa_flow/tests.rs | 166 +++++++++++++++++- 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 7e5989db8..5d246dc00 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -7,7 +7,10 @@ use sqlx::{FromRow, PgConnection, PgExecutor, query, query_as, query_scalar}; use thiserror::Error; use utoipa::ToSchema; -use crate::db::{Id, NoId, models::vpn_client_session::VpnClientMfaMethod}; +use crate::db::{ + Id, NoId, + models::{vpn_client_session::VpnClientMfaMethod, wireguard::LocationMfaMode}, +}; /// An MFA flow is a named, ordered list of MFA steps. #[derive(Clone, Debug, Deserialize, FromRow, Model, PartialEq, Serialize, ToSchema)] @@ -450,6 +453,48 @@ impl MfaFlow { Ok(None) } + + /// Derives the legacy `LocationMfaMode` for a location if the current + /// flow configuration is backward-compatible. Returns `None` when the + /// location uses multi-flow, multi-step, or subset-of-internal-methods + /// configurations that legacy clients cannot represent. + pub async fn derive_legacy_mode<'e>( + executor: impl PgExecutor<'e> + Copy, + location_id: Id, + ) -> sqlx::Result> { + use std::collections::HashSet; + + let assignments = Self::for_location(executor, location_id).await?; + if assignments.len() != 1 { + return Ok(None); + } + + let steps = MfaFlowStep::find_by_flow(executor, assignments[0].id).await?; + if steps.len() != 1 { + return Ok(None); + } + + let methods = &steps[0].methods; + let set: HashSet = methods.iter().copied().collect(); + + let all_internal: HashSet = [ + VpnClientMfaMethod::Totp, + VpnClientMfaMethod::Email, + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove, + ] + .into(); + + if set == all_internal { + return Ok(Some(LocationMfaMode::Internal)); + } + + if set == HashSet::from([VpnClientMfaMethod::Oidc]) { + return Ok(Some(LocationMfaMode::External)); + } + + Ok(None) + } } /// Internal row type for the resolve_for_user query. diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index 447736c9f..66f1a265b 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -2,7 +2,11 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; use crate::db::{ - models::{group::Group, user::User, wireguard::WireguardNetwork}, + models::{ + group::Group, + user::User, + wireguard::{LocationMfaMode, WireguardNetwork}, + }, setup_pool, }; @@ -556,6 +560,166 @@ async fn test_resolve_fallback_to_default(_: PgPoolOptions, options: PgConnectOp assert_eq!(result.unwrap().0.id, flow2.id); } +#[sqlx::test] +async fn test_derive_legacy_internal(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (flow, _) = MfaFlow::create( + &mut *tx, + "Internal".into(), + vec![vec![ + VpnClientMfaMethod::Totp, + VpnClientMfaMethod::Email, + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove, + ]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mode = MfaFlow::derive_legacy_mode(&pool, network.id) + .await + .unwrap(); + assert_eq!(mode, Some(LocationMfaMode::Internal)); +} + +#[sqlx::test] +async fn test_derive_legacy_external(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (flow, _) = MfaFlow::create( + &mut *tx, + "External".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mode = MfaFlow::derive_legacy_mode(&pool, network.id) + .await + .unwrap(); + assert_eq!(mode, Some(LocationMfaMode::External)); +} + +#[sqlx::test] +async fn test_derive_legacy_multi_step_omitted(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, _) = create_flow(&pool).await; // 2 steps + + let network = WireguardNetwork::default() + .try_set_address("10.1.2.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mode = MfaFlow::derive_legacy_mode(&pool, network.id) + .await + .unwrap(); + assert_eq!(mode, None); +} + +#[sqlx::test] +async fn test_derive_legacy_internal_subset_omitted(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (flow, _) = MfaFlow::create( + &mut *tx, + "Subset".into(), + vec![vec![VpnClientMfaMethod::Totp]], // only TOTP, not all four + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.3.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut *tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mode = MfaFlow::derive_legacy_mode(&pool, network.id) + .await + .unwrap(); + assert_eq!(mode, None); +} + #[sqlx::test] async fn test_validation_empty_title(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 061f4cf27688f7a41ab3aa49df30fdc1e3c54e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 21:21:54 +0200 Subject: [PATCH 18/36] use derived mode when generating device config --- .../defguard_common/src/db/models/mfa_flow.rs | 24 ++++++++++++------- crates/defguard_core/src/device_access/mod.rs | 12 +++++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 5d246dc00..452be1359 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -458,18 +458,26 @@ impl MfaFlow { /// flow configuration is backward-compatible. Returns `None` when the /// location uses multi-flow, multi-step, or subset-of-internal-methods /// configurations that legacy clients cannot represent. - pub async fn derive_legacy_mode<'e>( - executor: impl PgExecutor<'e> + Copy, + pub async fn derive_legacy_mode<'e, E: PgExecutor<'e>>( + executor: E, location_id: Id, ) -> sqlx::Result> { - use std::collections::HashSet; - - let assignments = Self::for_location(executor, location_id).await?; - if assignments.len() != 1 { - return Ok(None); + struct StepRow { + methods: Vec, } - let steps = MfaFlowStep::find_by_flow(executor, assignments[0].id).await?; + let steps = query_as!( + StepRow, + "SELECT mfs.methods AS \"methods: Vec\" \ + FROM location_mfa_flow lmf \ + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id \ + WHERE lmf.location_id = $1 \ + ORDER BY lmf.position, mfs.position", + location_id + ) + .fetch_all(executor) + .await?; + if steps.len() != 1 { return Ok(None); } diff --git a/crates/defguard_core/src/device_access/mod.rs b/crates/defguard_core/src/device_access/mod.rs index 6eb453a23..046cefdc5 100644 --- a/crates/defguard_core/src/device_access/mod.rs +++ b/crates/defguard_core/src/device_access/mod.rs @@ -9,8 +9,9 @@ use defguard_common::{ models::{ Device, DeviceConfig, DeviceError, WireguardNetwork, device::{DeviceNetworkInfo, WireguardNetworkDevice}, + mfa_flow::MfaFlow, user::User, - wireguard::WireguardNetworkError, + wireguard::{LocationMfaMode, WireguardNetworkError}, }, }, device_config_gen::create_wireguard_config, @@ -37,7 +38,12 @@ pub async fn build_device_config( let has_postures = network .has_postures(&mut *conn) .await - .map_err(|e| DeviceError::Unexpected(e.to_string()))?; + .map_err(|err| DeviceError::Unexpected(err.to_string()))?; + + let location_mfa_mode = MfaFlow::derive_legacy_mode(&mut *conn, network.id) + .await + .map_err(|err| DeviceError::Unexpected(err.to_string()))? + .unwrap_or(LocationMfaMode::Disabled); Ok(DeviceConfig { network_id: network.id, @@ -49,7 +55,7 @@ pub async fn build_device_config( pubkey: network.pubkey.clone(), dns: network.dns.clone(), keepalive_interval: network.keepalive_interval, - location_mfa_mode: network.location_mfa_mode.clone(), + location_mfa_mode, service_location_mode: network.service_location_mode.clone(), posture_check_required: has_postures, }) From 5dd91c87c9e6090bbedb37cdc008f3f5f0c37ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 12 Aug 2026 07:13:55 +0200 Subject: [PATCH 19/36] drop the mfa mode column --- .../defguard_common/src/db/models/device.rs | 18 +++---- .../src/db/models/wireguard.rs | 28 +++++----- .../src/db/models/wireguard/tests.rs | 4 +- crates/defguard_common/src/types/user_info.rs | 24 ++++----- .../src/enterprise/db/models/acl/tests.rs | 9 ++-- .../src/enterprise/directory_sync/tests.rs | 5 +- .../enterprise/handlers/openid_providers.rs | 3 +- .../src/enterprise/posture/tests.rs | 7 +-- .../src/grpc/proxy/client_mfa.rs | 18 +++++-- crates/defguard_core/src/grpc/utils.rs | 4 +- .../src/handlers/network_devices.rs | 4 +- .../defguard_core/src/handlers/wireguard.rs | 52 ++++-------------- crates/defguard_core/src/lib.rs | 8 +-- .../src/location_management/allowed_peers.rs | 19 +++---- crates/defguard_core/src/wg_config.rs | 4 +- .../tests/integration/api/acl/mod.rs | 2 +- .../tests/integration/api/acl/rules.rs | 4 +- .../tests/integration/api/common/mod.rs | 2 +- .../tests/integration/api/device_posture.rs | 2 +- .../integration/api/enterprise_settings.rs | 8 +-- .../tests/integration/api/wireguard.rs | 54 +++++++++---------- .../api/wireguard_network_allowed_groups.rs | 28 +++++----- .../api/wireguard_network_devices.rs | 6 +-- .../api/wireguard_network_import.rs | 6 +-- crates/defguard_event_logger/src/tests/mod.rs | 11 ++-- .../defguard_gateway_manager/src/handler.rs | 22 ++++---- .../tests/gateway_manager/handler/support.rs | 4 +- .../src/servers/enrollment.rs | 2 +- .../tests/proxy_manager/handler/support.rs | 8 +-- .../src/session_state.rs | 3 +- .../tests/common/mod.rs | 8 +-- .../tests/session_manager/mfa.rs | 15 +++--- crates/defguard_setup/src/auto_adoption.rs | 4 +- .../src/handlers/auto_wizard.rs | 7 ++- .../tests/integration/auto_adoption_wizard.rs | 6 +-- .../integration/auto_wizard_url_settings.rs | 4 +- .../tests/integration/wizard_state.rs | 4 +- .../20260811125537_[2.2.0]_mfa_flow.down.sql | 1 + .../20260811125537_[2.2.0]_mfa_flow.up.sql | 2 + 39 files changed, 186 insertions(+), 234 deletions(-) diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 9a56447a6..7029b14cb 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -179,11 +179,11 @@ impl DeviceInfo { "SELECT wnd.wireguard_network_id network_id, \ wnd.wireguard_ips \"device_wireguard_ips: Vec\", \ CASE \ - WHEN n.location_mfa_mode = 'disabled'::location_mfa_mode THEN NULL::text \ + WHEN NOT n.mfa_enabled THEN NULL::text \ ELSE active_session.preshared_key \ END \"preshared_key?\", \ CASE \ - WHEN n.location_mfa_mode = 'disabled'::location_mfa_mode THEN TRUE \ + WHEN NOT n.mfa_enabled THEN TRUE \ ELSE active_session.preshared_key IS NOT NULL \ END \"is_authorized!\" \ FROM wireguard_network_device wnd \ @@ -241,7 +241,7 @@ impl UserDevice { latest_successful_stats.endpoint \"device_endpoint?\", \ latest_successful_session.connected_at \"last_connected_at?\", \ latest_successful_session.state \"state?: VpnClientSessionState\", \ - n.location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ + n.mfa_enabled FROM wireguard_network_device wnd \ JOIN wireguard_network n ON n.id = wnd.wireguard_network_id \ LEFT JOIN LATERAL ( \ @@ -296,7 +296,7 @@ impl UserDevice { last_connected_ip: device_ip, last_connected_at: r.last_connected_at, is_active, - location_mfa_mode: r.location_mfa_mode, + location_mfa_mode: LocationMfaMode::default(), } }) .collect::>(); @@ -1366,7 +1366,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1417,7 +1417,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1495,7 +1495,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1570,7 +1570,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1651,7 +1651,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index e08c920f2..22e014b35 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -128,7 +128,7 @@ pub struct WireguardNetwork { pub keepalive_interval: i32, pub peer_disconnect_threshold: i32, #[model(enum)] - pub location_mfa_mode: LocationMfaMode, + pub mfa_enabled: bool, #[model(enum)] pub service_location_mode: ServiceLocationMode, } @@ -169,7 +169,6 @@ impl fmt::Debug for WireguardNetwork { .field("allowed_ips_from_acl", &self.allowed_ips_from_acl) .field("keepalive_interval", &self.keepalive_interval) .field("peer_disconnect_threshold", &self.peer_disconnect_threshold) - .field("location_mfa_mode", &self.location_mfa_mode) .field("service_location_mode", &self.service_location_mode) .finish() } @@ -226,7 +225,7 @@ impl WireguardNetwork { acl_enabled: bool, acl_default_allow: bool, allowed_ips_from_acl: bool, - location_mfa_mode: LocationMfaMode, + mfa_enabled: bool, service_location_mode: ServiceLocationMode, ) -> Self where @@ -253,7 +252,7 @@ impl WireguardNetwork { acl_enabled, acl_default_allow, allowed_ips_from_acl, - location_mfa_mode, + mfa_enabled, service_location_mode, } } @@ -352,7 +351,7 @@ impl WireguardNetwork { allowed_ips, allow_all_groups, connected_at, keepalive_interval, \ peer_disconnect_threshold, acl_enabled, acl_default_allow, \ allowed_ips_from_acl, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + mfa_enabled \"mfa_enabled!: bool\", \ service_location_mode \"service_location_mode: ServiceLocationMode\" \ FROM wireguard_network WHERE name = $1", name @@ -381,7 +380,7 @@ impl WireguardNetwork { allowed_ips, allow_all_groups, connected_at, keepalive_interval, \ peer_disconnect_threshold, acl_enabled, acl_default_allow, \ allowed_ips_from_acl, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + mfa_enabled \"mfa_enabled!: bool\", \ service_location_mode \"service_location_mode: ServiceLocationMode\" \ FROM wireguard_network WHERE id IN \ (SELECT wireguard_network_id FROM wireguard_network_device \ @@ -408,12 +407,12 @@ impl WireguardNetwork { allowed_ips, allow_all_groups, connected_at, keepalive_interval, \ peer_disconnect_threshold, acl_enabled, acl_default_allow, \ allowed_ips_from_acl, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + mfa_enabled \"mfa_enabled!: bool\", \ service_location_mode \"service_location_mode: ServiceLocationMode\" \ FROM wireguard_network WHERE id IN \ (SELECT wireguard_network_id FROM wireguard_network_device \ WHERE device_id = $1) \ - AND location_mfa_mode = 'disabled'", + AND NOT mfa_enabled", device_id ) .fetch_all(executor) @@ -431,7 +430,7 @@ impl WireguardNetwork { allowed_ips, allow_all_groups, connected_at, keepalive_interval, \ peer_disconnect_threshold, acl_enabled, acl_default_allow, \ allowed_ips_from_acl, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + mfa_enabled \"mfa_enabled!: bool\", \ service_location_mode \"service_location_mode: ServiceLocationMode\" \ FROM aclrulenetwork r \ JOIN wireguard_network n ON n.id = r.network_id \ @@ -1317,10 +1316,7 @@ impl WireguardNetwork { #[must_use] pub fn mfa_enabled(&self) -> bool { - match self.location_mfa_mode { - LocationMfaMode::Internal | LocationMfaMode::External => true, - LocationMfaMode::Disabled => false, - } + self.mfa_enabled } /// Fetch all locations using external MFA. @@ -1334,9 +1330,9 @@ impl WireguardNetwork { allowed_ips, allow_all_groups, connected_at, keepalive_interval, \ peer_disconnect_threshold, acl_enabled, acl_default_allow, \ allowed_ips_from_acl, \ - location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ + mfa_enabled \"mfa_enabled!: bool\", \ service_location_mode \"service_location_mode: ServiceLocationMode\" \ - FROM wireguard_network WHERE location_mfa_mode = 'external'::location_mfa_mode", + FROM wireguard_network WHERE mfa_enabled = true", ) .fetch_all(executor) .await?; @@ -1546,7 +1542,7 @@ impl Default for WireguardNetwork { acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::default(), + mfa_enabled: false, service_location_mode: ServiceLocationMode::default(), } } diff --git a/crates/defguard_common/src/db/models/wireguard/tests.rs b/crates/defguard_common/src/db/models/wireguard/tests.rs index ddd9fad5d..3244efd0d 100644 --- a/crates/defguard_common/src/db/models/wireguard/tests.rs +++ b/crates/defguard_common/src/db/models/wireguard/tests.rs @@ -241,7 +241,7 @@ async fn test_can_assign_ips(_: PgPoolOptions, options: PgConnectOptions) { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.1.1.1/24").unwrap()]) @@ -371,7 +371,7 @@ async fn test_can_assign_ips_multiple_addresses(_: PgPoolOptions, options: PgCon false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([ diff --git a/crates/defguard_common/src/types/user_info.rs b/crates/defguard_common/src/types/user_info.rs index 8c98a147d..03e784ab3 100644 --- a/crates/defguard_common/src/types/user_info.rs +++ b/crates/defguard_common/src/types/user_info.rs @@ -48,7 +48,7 @@ async fn has_non_mfa_location_access(pool: &PgPool, groups: &[String]) -> sqlx:: query_scalar!( "SELECT EXISTS( \ SELECT 1 FROM wireguard_network wn \ - WHERE wn.location_mfa_mode = 'disabled' \ + WHERE NOT wn.mfa_enabled \ AND ( \ wn.allow_all_groups \ OR EXISTS( \ @@ -243,7 +243,7 @@ mod test { group::Group, settings::initialize_current_settings, user::User, - wireguard::{LocationMfaMode, ServiceLocationMode, WireguardNetwork}, + wireguard::{ServiceLocationMode, WireguardNetwork}, }, setup_pool, }, @@ -509,7 +509,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.1.1.1/24").unwrap()]) @@ -540,7 +540,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.2.1.1/24").unwrap()]) @@ -578,7 +578,7 @@ mod test { false, false, false, // not allow_all_groups - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.3.1.1/24").unwrap()]) @@ -614,7 +614,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.4.1.1/24").unwrap()]) @@ -646,7 +646,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.5.1.1/24").unwrap()]) @@ -666,7 +666,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.6.1.1/24").unwrap()]) @@ -701,7 +701,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.7.1.1/24").unwrap()]) @@ -733,7 +733,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.8.1.1/24").unwrap()]) @@ -786,7 +786,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.9.1.1/24").unwrap()]) @@ -806,7 +806,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.10.1.1/24").unwrap()]) diff --git a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs index 15fce447e..bb84e132c 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs @@ -2,10 +2,7 @@ use std::ops::Bound; use chrono::{NaiveDateTime, Timelike}; use defguard_common::{ - db::{ - models::wireguard::{LocationMfaMode, ServiceLocationMode}, - setup_pool, - }, + db::{models::wireguard::ServiceLocationMode, setup_pool}, utils::parse_address_list, }; use rand::{Rng, thread_rng}; @@ -168,7 +165,7 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .save(&pool) @@ -184,7 +181,7 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .save(&pool) diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index 4f7683b05..ea865ed8a 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -7,8 +7,7 @@ mod test { db::{ models::{ Device, DeviceType, Session, SessionState, Settings, User, WireguardNetwork, - settings::initialize_current_settings, - wireguard::{LocationMfaMode, ServiceLocationMode}, + settings::initialize_current_settings, wireguard::ServiceLocationMode, }, setup_pool, }, @@ -89,7 +88,7 @@ mod test { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::from_str("10.10.10.1/24").unwrap()]) diff --git a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs index 116030ff3..40394df66 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs @@ -6,7 +6,6 @@ use axum::{ use defguard_common::db::models::{ Settings, WireguardNetwork, settings::{OpenIdUsernameHandling, update_current_settings}, - wireguard::LocationMfaMode, }; use rsa::{RsaPrivateKey, pkcs8::DecodePrivateKey}; use serde_json::json; @@ -308,7 +307,7 @@ pub(crate) async fn delete_openid_provider( "Falling back to internal MFA for {location} because exteral OIDC provider has \ been removed" ); - location.location_mfa_mode = LocationMfaMode::Internal; + location.mfa_enabled = true; location.save(&mut *transaction).await?; } transaction.commit().await?; diff --git a/crates/defguard_core/src/enterprise/posture/tests.rs b/crates/defguard_core/src/enterprise/posture/tests.rs index d7bb5023d..2a347aa34 100644 --- a/crates/defguard_core/src/enterprise/posture/tests.rs +++ b/crates/defguard_core/src/enterprise/posture/tests.rs @@ -1,10 +1,7 @@ use chrono::{TimeDelta, Utc}; use defguard_common::db::{ Id, NoId, - models::{ - WireguardNetwork, - wireguard::{LocationMfaMode, ServiceLocationMode}, - }, + models::{WireguardNetwork, wireguard::ServiceLocationMode}, setup_pool, }; use defguard_proto::enterprise::posture::{ @@ -63,7 +60,7 @@ async fn create_location(pool: &PgPool) -> Id { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .save(pool) diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 323e8dceb..c98572d8d 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -13,6 +13,7 @@ use defguard_common::{ models::{ BiometricAuth, BiometricChallenge, Device, User, WireguardNetwork, device::{DeviceNetworkInfo, WireguardNetworkDevice}, + mfa_flow::MfaFlow, polling_token::PollingToken, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, wireguard::LocationMfaMode, @@ -298,8 +299,16 @@ impl ClientMfaServer { Status::invalid_argument("invalid MFA method selected") })?; + let location_mfa_mode = MfaFlow::derive_legacy_mode(&self.pool, request.location_id) + .await + .map_err(|err| { + error!("Failed to derive legacy MFA mode: {err}"); + Status::internal("unexpected error") + })? + .unwrap_or(LocationMfaMode::Disabled); + // check if selected MFA method matches location settings - match (&location.location_mfa_mode, selected_method) { + match (&location_mfa_mode, selected_method) { // MFA enabled status is already verified (LocationMfaMode::Disabled, _) => unreachable!(), ( @@ -317,8 +326,7 @@ impl ClientMfaServer { _ => { error!( "Selected MFA method ({selected_method}) is not supported by location \ - {location} which uses {}", - location.location_mfa_mode + {location}" ); return Err(Status::invalid_argument( @@ -2566,7 +2574,7 @@ mod tests { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 10, 0, 1)), 24).unwrap()]) @@ -2587,7 +2595,7 @@ mod tests { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 20, 0, 1)), 24).unwrap()]) diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 5b8d04018..48e031bf9 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -90,7 +90,7 @@ pub async fn build_device_config_response( } // DEPRECATED(1.5): superseeded by location_mfa_mode - let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; + let mfa_enabled = network.mfa_enabled; let mut conn = pool.acquire().await.map_err(|err| { error!("Failed to acquire connection: {err}"); @@ -163,7 +163,7 @@ pub async fn build_device_config_response( continue; } // DEPRECATED(1.5): superseeded by location_mfa_mode - let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; + let mfa_enabled = network.mfa_enabled; if let Some(wireguard_network_device) = wireguard_network_device { let mut conn = pool.acquire().await.map_err(|err| { error!("Failed to acquire connection: {err}"); diff --git a/crates/defguard_core/src/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index 9df878f73..979b6f141 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -138,7 +138,7 @@ pub(crate) struct DeviceWireGuardConfig { ), responses( (status = 200, description = "Network device configuration for each location of the device.", body = [Object], example = json!([ - {"network_id": 1, "network_name": "office", "config": "[Interface]\n...", "location_mfa_mode": "disabled"} + {"network_id": 1, "network_name": "office", "config": "[Interface]\n...", "mfa_enabled": false} ])), (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), (status = 403, description = "Requires admin privileges or the request must target your own account.", body = ApiErrorResponse, example = json!({"msg": "requires privileged access"})), @@ -781,7 +781,7 @@ pub(crate) async fn start_network_device_setup_for_device( "pubkey": "Zm9vYmFyMDEyMzQ1Njc4OWFiY2RlZmdoaWprbG1ub3A=", "dns": "10.0.0.1", "keepalive_interval": 25, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled", "posture_check_required": false }, diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index ec4148c3f..fe5c07306 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -11,7 +11,7 @@ use defguard_common::{ models::{ Device, DeviceConfig, DeviceType, User, WireguardNetwork, device::{AddDevice, DeviceInfo, ModifyDevice, WireguardNetworkDevice}, - wireguard::{LocationMfaMode, MappedDevice, ServiceLocationMode}, + wireguard::{MappedDevice, ServiceLocationMode}, }, }, utils::parse_network_address_list, @@ -84,7 +84,7 @@ pub struct WireguardNetworkData { pub acl_default_allow: bool, #[serde(default)] pub allowed_ips_from_acl: bool, - pub location_mfa_mode: LocationMfaMode, + pub mfa_enabled: bool, pub service_location_mode: ServiceLocationMode, pub posture_checks: Option>, } @@ -99,7 +99,7 @@ impl WireguardNetworkData { } pub(crate) fn validate_peer_disconnect_threshold(&self) -> Result<(), WebError> { - if self.location_mfa_mode == LocationMfaMode::Disabled { + if !self.mfa_enabled { return Ok(()); } @@ -112,42 +112,10 @@ impl WireguardNetworkData { ))) } - pub(crate) async fn validate_location_mfa_mode<'e, E: sqlx::PgExecutor<'e>>( - &self, - executor: E, - ) -> Result<(), WebError> { - // if external MFA was chosen verify if enterprise features are enabled - // and external OpenID provider is configured - if self.location_mfa_mode == LocationMfaMode::External { - if !is_business_license_active() { - error!( - "Unable to create location with external MFA. External OpenID provider is not configured" - ); - - return Err(WebError::Forbidden( - "Cannot enable external MFA. Enterprise features are disabled", - )); - } - - if OpenIdProvider::get_current(executor).await?.is_none() { - error!( - "Unable to create location with external MFA. External OpenID provider is not configured" - ); - return Err(WebError::BadRequest( - "Cannot enable external MFA. External OpenID provider is not configured".into(), - )); - } - } - - Ok(()) - } - /// Rejects service-location mode combined with location MFA: core cannot serve it and the /// client cannot represent it (`Location::is_service_location()` requires MFA disabled). pub(crate) fn validate_service_location_mfa(&self) -> Result<(), WebError> { - if self.service_location_mode == ServiceLocationMode::Disabled - || self.location_mfa_mode == LocationMfaMode::Disabled - { + if self.service_location_mode == ServiceLocationMode::Disabled || !self.mfa_enabled { return Ok(()); } @@ -204,7 +172,7 @@ pub struct ImportedNetworkData { post, path = "/api/v1/network", tag = "network", - request_body(content = WireguardNetworkData, description = "`address` is a comma-separated list of network addresses.", example = json!({"name": "office", "address": "10.0.0.1/24", "endpoint": "vpn.example.com", "port": 50051, "allowed_ips": "0.0.0.0/0", "dns": "1.1.1.1", "mtu": 1420, "fwmark": 0, "allow_all_groups": true, "allowed_groups": [], "keepalive_interval": 25, "peer_disconnect_threshold": 180, "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, "location_mfa_mode": "disabled", "service_location_mode": "disabled"})), + request_body(content = WireguardNetworkData, description = "`address` is a comma-separated list of network addresses.", example = json!({"name": "office", "address": "10.0.0.1/24", "endpoint": "vpn.example.com", "port": 50051, "allowed_ips": "0.0.0.0/0", "dns": "1.1.1.1", "mtu": 1420, "fwmark": 0, "allow_all_groups": true, "allowed_groups": [], "keepalive_interval": 25, "peer_disconnect_threshold": 180, "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, "mfa_enabled": false, "service_location_mode": "disabled"})), responses( (status = 201, description = "Network created.", body = WireguardNetwork), (status = 400, description = "Invalid location settings.", body = ApiErrorResponse, example = json!({"msg": "At least one group must be specified when allow_all_groups is disabled"})), @@ -256,7 +224,6 @@ pub(crate) async fn create_network( } data.validate_peer_disconnect_threshold()?; - data.validate_location_mfa_mode(&appstate.pool).await?; data.validate_service_location_mfa()?; data.validate_keepalive_interval()?; data.validate_allowed_groups()?; @@ -272,7 +239,7 @@ pub(crate) async fn create_network( data.acl_enabled, data.acl_default_allow, data.allowed_ips_from_acl, - data.location_mfa_mode, + data.mfa_enabled, data.service_location_mode, ) .try_set_address(&data.address)?; @@ -385,7 +352,6 @@ pub(crate) async fn modify_network( } data.validate_peer_disconnect_threshold()?; - data.validate_location_mfa_mode(&appstate.pool).await?; data.validate_service_location_mfa()?; data.validate_keepalive_interval()?; data.validate_allowed_groups()?; @@ -412,7 +378,7 @@ pub(crate) async fn modify_network( network.acl_default_allow = data.acl_default_allow; network.allowed_ips_from_acl = data.allowed_ips_from_acl; network.service_location_mode = data.service_location_mode; - network.location_mfa_mode = data.location_mfa_mode; + network.mfa_enabled = data.mfa_enabled; network.save(&mut *transaction).await?; network @@ -868,7 +834,7 @@ pub(crate) struct AddDeviceResult { "pubkey": "pubkey", "dns": "8.8.8.8", "keepalive_interval": 5, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" } ], @@ -1499,7 +1465,7 @@ pub(crate) async fn download_config( ), responses( (status = 200, description = "Device configuration for each location.", body = [Object], example = json!([ - {"network_id": 1, "network_name": "office", "config": "[Interface]\n...", "location_mfa_mode": "disabled"} + {"network_id": 1, "network_name": "office", "config": "[Interface]\n...", "mfa_enabled": false} ])), (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), (status = 403, description = "Requires admin privileges or the request must target your own account.", body = ApiErrorResponse, example = json!({"msg": "requires privileged access"})), diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 010318f6a..a9afaa4c2 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -34,7 +34,7 @@ use defguard_common::{ initial_setup_wizard::{InitialSetupState, InitialSetupStep}, oauth2client::OAuth2Client, settings::{initialize_current_settings, update_current_settings}, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, wizard::{ActiveWizard, Wizard}, }, }, @@ -1066,7 +1066,7 @@ pub async fn init_dev_env(config: &DefGuardConfig) { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 1, 1, 1)), 24).unwrap()]) @@ -1168,7 +1168,7 @@ pub async fn init_vpn_location( false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([args.address])?; @@ -1209,7 +1209,7 @@ pub async fn init_vpn_location( false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([args.address])?; diff --git a/crates/defguard_core/src/location_management/allowed_peers.rs b/crates/defguard_core/src/location_management/allowed_peers.rs index 24d2629d2..3e3f478b1 100644 --- a/crates/defguard_core/src/location_management/allowed_peers.rs +++ b/crates/defguard_core/src/location_management/allowed_peers.rs @@ -105,11 +105,8 @@ mod test { use chrono::Utc; use defguard_common::db::{ models::{ - Device, DeviceType, WireguardNetwork, - device::WireguardNetworkDevice, - user::User, - vpn_client_session::VpnClientSession, - wireguard::{LocationMfaMode, ServiceLocationMode}, + Device, DeviceType, WireguardNetwork, device::WireguardNetworkDevice, user::User, + vpn_client_session::VpnClientSession, wireguard::ServiceLocationMode, }, setup_pool, }; @@ -164,7 +161,7 @@ mod test { .unwrap(); network_normal.name = "normal-location".to_owned(); network_normal.service_location_mode = ServiceLocationMode::Disabled; - network_normal.location_mfa_mode = LocationMfaMode::Disabled; + network_normal.mfa_enabled = false; let network_normal = network_normal.save(&mut *conn).await.unwrap(); WireguardNetworkDevice::new( @@ -188,7 +185,7 @@ mod test { .unwrap(); network_prelogon.name = "prelogon-service-location".to_owned(); network_prelogon.service_location_mode = ServiceLocationMode::PreLogon; - network_prelogon.location_mfa_mode = LocationMfaMode::Disabled; + network_prelogon.mfa_enabled = false; let network_prelogon = network_prelogon.save(&mut *conn).await.unwrap(); WireguardNetworkDevice::new( @@ -217,7 +214,7 @@ mod test { .unwrap(); network_alwayson.name = "alwayson-service-location".to_owned(); network_alwayson.service_location_mode = ServiceLocationMode::AlwaysOn; - network_alwayson.location_mfa_mode = LocationMfaMode::Disabled; + network_alwayson.mfa_enabled = false; let network_alwayson = network_alwayson.save(&mut *conn).await.unwrap(); let device3 = Device::new( @@ -289,7 +286,7 @@ mod test { .unwrap(); network.name = "mfa-location".to_owned(); network.service_location_mode = ServiceLocationMode::Disabled; - network.location_mfa_mode = LocationMfaMode::Internal; + network.mfa_enabled = true; let network = network.save(&mut *conn).await.unwrap(); let network_device = WireguardNetworkDevice::new( @@ -347,7 +344,7 @@ mod test { .unwrap(); network.name = "non-mfa-location".to_owned(); network.service_location_mode = ServiceLocationMode::Disabled; - network.location_mfa_mode = LocationMfaMode::Disabled; + network.mfa_enabled = false; let network = network.save(&mut *conn).await.unwrap(); let network_device = WireguardNetworkDevice::new( @@ -414,7 +411,7 @@ mod test { .unwrap(); network.name = "mfa-location-with-session-psk".to_owned(); network.service_location_mode = ServiceLocationMode::Disabled; - network.location_mfa_mode = LocationMfaMode::Internal; + network.mfa_enabled = true; let network = network.save(&mut *conn).await.unwrap(); WireguardNetworkDevice::new( diff --git a/crates/defguard_core/src/wg_config.rs b/crates/defguard_core/src/wg_config.rs index bbea192ab..dbd035f47 100644 --- a/crates/defguard_core/src/wg_config.rs +++ b/crates/defguard_core/src/wg_config.rs @@ -7,7 +7,7 @@ use defguard_common::{ Id, models::{ Device, WireguardNetwork, - wireguard::{DEFAULT_WIREGUARD_MTU, LocationMfaMode, ServiceLocationMode}, + wireguard::{DEFAULT_WIREGUARD_MTU, ServiceLocationMode}, }, }, }; @@ -120,7 +120,7 @@ pub(crate) fn parse_wireguard_config( false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address(addresses.clone())?; diff --git a/crates/defguard_core/tests/integration/api/acl/mod.rs b/crates/defguard_core/tests/integration/api/acl/mod.rs index 96be77439..a0548ed06 100644 --- a/crates/defguard_core/tests/integration/api/acl/mod.rs +++ b/crates/defguard_core/tests/integration/api/acl/mod.rs @@ -6,7 +6,7 @@ use defguard_common::{ Device, DeviceType, User, WireguardNetwork, group::{Group, Permission}, settings::initialize_current_settings, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, }, }, }; diff --git a/crates/defguard_core/tests/integration/api/acl/rules.rs b/crates/defguard_core/tests/integration/api/acl/rules.rs index 86a8c55f9..cc7845361 100644 --- a/crates/defguard_core/tests/integration/api/acl/rules.rs +++ b/crates/defguard_core/tests/integration/api/acl/rules.rs @@ -670,7 +670,7 @@ async fn test_related_objects(_: PgPoolOptions, options: PgConnectOptions) { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .save(&pool) @@ -1219,7 +1219,7 @@ async fn test_rule_delete_state_applied(_: PgPoolOptions, options: PgConnectOpti false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .save(&pool) diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index dc401c504..0415ad7a1 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -226,7 +226,7 @@ pub(crate) async fn make_network(client: &TestClient, name: &str) -> TestRespons "acl_default_allow": false, "allowed_ips_from_acl": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() diff --git a/crates/defguard_core/tests/integration/api/device_posture.rs b/crates/defguard_core/tests/integration/api/device_posture.rs index 7fea42faf..7ddccc5f9 100644 --- a/crates/defguard_core/tests/integration/api/device_posture.rs +++ b/crates/defguard_core/tests/integration/api/device_posture.rs @@ -1292,7 +1292,7 @@ async fn make_service_location(client: &TestClient, name: &str) -> i64 { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "prelogon" })) .send() diff --git a/crates/defguard_core/tests/integration/api/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs index e9e41fd22..e4eb978b2 100644 --- a/crates/defguard_core/tests/integration/api/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -99,7 +99,7 @@ async fn test_admin_devices_management_is_enforced(_: PgPoolOptions, options: Pg "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -216,7 +216,7 @@ async fn test_regular_user_device_management(_: PgPoolOptions, options: PgConnec "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -325,7 +325,7 @@ async fn dg25_12_test_enforce_client_activation_only(_: PgPoolOptions, options: "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -452,7 +452,7 @@ async fn dg25_13_test_disable_device_config(_: PgPoolOptions, options: PgConnect "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 16cb7826e..07df28be9 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -87,7 +87,7 @@ async fn test_network(_: PgPoolOptions, options: PgConnectOptions) { acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, + mfa_enabled: false, service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; @@ -192,7 +192,7 @@ async fn test_create_network_blocked_when_location_count_exceeds_license_limit( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -265,7 +265,7 @@ async fn test_create_network_with_posture_checks_assigns_postures( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled", "posture_checks": posture_ids })) @@ -327,7 +327,7 @@ async fn test_create_network_with_posture_checks_requires_enterprise_license( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled", "posture_checks": [1] })) @@ -640,7 +640,7 @@ async fn test_modify_network_rejects_service_location_with_mfa( .send() .await; let fetched: WireguardNetwork = response.json().await; - assert_eq!(fetched.location_mfa_mode, LocationMfaMode::Disabled); + assert_eq!(fetched.mfa_enabled, false); assert_eq!(fetched.service_location_mode, ServiceLocationMode::Disabled); // enabling service location mode alone is accepted and persisted @@ -837,7 +837,7 @@ async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgC acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::External, + mfa_enabled: true, service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; @@ -926,7 +926,7 @@ async fn test_location_mfa_mode_validation_modify(_: PgPoolOptions, options: PgC acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, + mfa_enabled: false, service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; @@ -946,7 +946,7 @@ async fn test_location_mfa_mode_validation_modify(_: PgPoolOptions, options: PgC set_cached_license(None); // attempt to modify location - location_data.location_mfa_mode = LocationMfaMode::External; + location_data.mfa_enabled = true; let response = client .put("/api/v1/network/1") .json(&location_data) @@ -1033,7 +1033,7 @@ async fn test_peer_disconnect_threshold_validation_create( acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, + mfa_enabled: false, service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; @@ -1046,7 +1046,7 @@ async fn test_peer_disconnect_threshold_validation_create( assert_eq!(response.status(), StatusCode::CREATED); location_data.name = "test_location_internal".into(); - location_data.location_mfa_mode = LocationMfaMode::Internal; + location_data.mfa_enabled = true; let response = client .post("/api/v1/network") .json(&location_data) @@ -1090,7 +1090,7 @@ async fn test_peer_disconnect_threshold_validation_modify( acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, + mfa_enabled: false, service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; @@ -1109,7 +1109,7 @@ async fn test_peer_disconnect_threshold_validation_modify( .await; assert_eq!(response.status(), StatusCode::OK); - location_data.location_mfa_mode = LocationMfaMode::Internal; + location_data.mfa_enabled = true; let response = client .put("/api/v1/network/1") .json(&location_data) @@ -1364,7 +1364,7 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" }); let response = client @@ -1693,7 +1693,7 @@ async fn test_network_size_validation(_: PgPoolOptions, options: PgConnectOption "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" }); let response = client @@ -1721,7 +1721,7 @@ async fn test_network_size_validation(_: PgPoolOptions, options: PgConnectOption "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" }); let response = client @@ -1852,7 +1852,7 @@ async fn test_user_device_configs_auth(_: PgPoolOptions, options: PgConnectOptio "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -1942,7 +1942,7 @@ async fn test_add_device_for_disabled_user(_: PgPoolOptions, options: PgConnectO "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2006,7 +2006,7 @@ async fn test_user_device_configs_excludes_mfa_locations( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2099,7 +2099,7 @@ async fn test_location_allowed_ips_from_acl_flag(_: PgPoolOptions, options: PgCo "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2140,7 +2140,7 @@ async fn test_location_allowed_ips_from_acl_flag(_: PgPoolOptions, options: PgCo "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2183,7 +2183,7 @@ async fn test_location_allowed_ips_from_acl_flag(_: PgPoolOptions, options: PgCo "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2324,7 +2324,7 @@ async fn test_config_allowed_ips_from_acl_merged(_: PgPoolOptions, options: PgCo "acl_enabled": true, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2393,7 +2393,7 @@ async fn test_config_allowed_ips_from_acl_no_match(_: PgPoolOptions, options: Pg "acl_enabled": true, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2500,7 +2500,7 @@ async fn test_config_allowed_ips_from_acl_toggle_off(_: PgPoolOptions, options: "acl_enabled": true, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2572,7 +2572,7 @@ async fn test_config_allowed_ips_from_acl_any_address_skipped( "acl_enabled": true, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2652,7 +2652,7 @@ async fn test_config_allowed_ips_from_acl_no_license(_: PgPoolOptions, options: "acl_enabled": true, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2723,7 +2723,7 @@ async fn test_config_allowed_ips_from_acl_disabled(_: PgPoolOptions, options: Pg "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": true, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs index 8cfe863a3..8bf3105f4 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs @@ -173,7 +173,7 @@ async fn test_create_new_network(_: PgPoolOptions, options: PgConnectOptions) { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -226,7 +226,7 @@ async fn test_create_new_network_allow_all_groups(_: PgPoolOptions, options: PgC "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -287,7 +287,7 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -325,7 +325,7 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -362,7 +362,7 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -400,7 +400,7 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -453,7 +453,7 @@ async fn test_modify_network_enable_allow_all_groups(_: PgPoolOptions, options: "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -488,7 +488,7 @@ async fn test_modify_network_enable_allow_all_groups(_: PgPoolOptions, options: "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -759,7 +759,7 @@ async fn test_modify_user(_: PgPoolOptions, options: PgConnectOptions) { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -874,7 +874,7 @@ async fn test_modify_user_no_effect_when_allow_all_groups( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -990,7 +990,7 @@ async fn test_delete_only_allowed_group_rejected(_: PgPoolOptions, options: PgCo "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -1063,7 +1063,7 @@ async fn test_delete_allowed_group_when_location_keeps_other_groups( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -1113,7 +1113,7 @@ async fn test_create_network_without_groups_rejected(_: PgPoolOptions, options: acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, + mfa_enabled: false, // mfa_enabled service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; @@ -1168,7 +1168,7 @@ async fn test_modify_network_without_groups_rejected(_: PgPoolOptions, options: acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, + mfa_enabled: false, // mfa_enabled service_location_mode: ServiceLocationMode::Disabled, posture_checks: None, }; diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs index 082a52047..7fac70aaa 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs @@ -39,7 +39,7 @@ async fn make_first_network(client: &TestClient) -> TestResponse { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -67,7 +67,7 @@ async fn make_second_network(client: &TestClient) -> TestResponse { "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -335,7 +335,7 @@ async fn test_device_ip_validation(_: PgPoolOptions, options: PgConnectOptions) "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": "disabled", + "mfa_enabled": false, "service_location_mode": "disabled" }); let response = client.post("/api/v1/network").json(&location).send().await; diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_import.rs b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs index 3def41a12..7d04a32e5 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_import.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs @@ -1,9 +1,7 @@ use std::net::IpAddr; use defguard_common::db::models::{ - Device, DeviceType, User, WireguardNetwork, - device::UserDevice, - wireguard::{LocationMfaMode, ServiceLocationMode}, + Device, DeviceType, User, WireguardNetwork, device::UserDevice, wireguard::ServiceLocationMode, }; use defguard_core::{ device_access::join_device_to_all_networks, @@ -58,7 +56,7 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address(["10.1.9.1/24".parse().unwrap()]) diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 288ded831..66c79c462 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -5,13 +5,8 @@ use defguard_common::db::{ Id, NoId, models::{ AuthenticationKey, AuthenticationKeyType, Device, DeviceType, MFAMethod, Settings, User, - WebAuthn, WireguardNetwork, - gateway::Gateway, - group::Group, - oauth2client::OAuth2Client, - proxy::Proxy, - settings::set_settings, - wireguard::{LocationMfaMode, ServiceLocationMode}, + WebAuthn, WireguardNetwork, gateway::Gateway, group::Group, oauth2client::OAuth2Client, + proxy::Proxy, settings::set_settings, wireguard::ServiceLocationMode, }, }; use defguard_core::{ @@ -69,7 +64,7 @@ fn sample_location() -> WireguardNetwork { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 24).unwrap()]) diff --git a/crates/defguard_gateway_manager/src/handler.rs b/crates/defguard_gateway_manager/src/handler.rs index 8c9a89d6a..8dc50a554 100644 --- a/crates/defguard_gateway_manager/src/handler.rs +++ b/crates/defguard_gateway_manager/src/handler.rs @@ -1147,7 +1147,7 @@ mod tests { device::WireguardNetworkDevice, gateway::Gateway, vpn_client_session::VpnClientSession, - wireguard::{LocationMfaMode, ServiceLocationMode, WireguardNetwork}, + wireguard::{ServiceLocationMode, WireguardNetwork}, }, setup_pool, }, @@ -1162,7 +1162,7 @@ mod tests { GatewayHandler, GatewayUpdatesHandler, WireguardPeer, try_protos_into_stats_message, }; - fn test_network(location_mfa_mode: LocationMfaMode) -> WireguardNetwork { + fn test_network(mfa_enabled: bool) -> WireguardNetwork { WireguardNetwork::new( "test-network".into(), 51820, @@ -1173,7 +1173,7 @@ mod tests { false, false, false, - location_mfa_mode, + true, ServiceLocationMode::Disabled, ) .with_id(1) @@ -1205,7 +1205,7 @@ mod tests { false, false, false, - LocationMfaMode::default(), + false, // mfa_enabled ServiceLocationMode::default(), ) .set_address([ @@ -1336,8 +1336,8 @@ mod tests { assert!(config.firewall_config.is_none()); } - fn test_handler(location_mfa_mode: LocationMfaMode) -> GatewayUpdatesHandler { - let network = test_network(location_mfa_mode); + fn test_handler(mfa_enabled: bool) -> GatewayUpdatesHandler { + let network = test_network(mfa_enabled); let (events_tx, events_rx) = broadcast::channel(1); let (tx, _rx) = unbounded_channel(); drop(events_tx); @@ -1350,7 +1350,7 @@ mod tests { #[test] fn test_runtime_peer_update_strips_preshared_key_for_non_mfa_locations() { - let handler = test_handler(LocationMfaMode::Disabled); + let handler = test_handler(false); let peer = handler .runtime_peer_update( @@ -1370,7 +1370,7 @@ mod tests { #[test] fn test_runtime_peer_update_skips_authorized_mfa_peer_without_session_preshared_key() { - let handler = test_handler(LocationMfaMode::Internal); + let handler = test_handler(true); let peer = handler.runtime_peer_update( "device", @@ -1385,7 +1385,7 @@ mod tests { #[test] fn test_runtime_peer_update_preserves_session_preshared_key_for_authorized_mfa_peer() { - let handler = test_handler(LocationMfaMode::Internal); + let handler = test_handler(true); let peer = handler .runtime_peer_update( @@ -1402,7 +1402,7 @@ mod tests { #[test] fn test_runtime_peer_update_preserves_session_preshared_key_for_authorized_posture_peer() { - let mut handler = test_handler(LocationMfaMode::Disabled); + let mut handler = test_handler(false); handler.session_authorization_required = true; let peer = handler @@ -1465,7 +1465,7 @@ mod tests { .try_set_address("10.7.1.1/24") .unwrap(); network.name = "mfa-full-config-location".to_owned(); - network.location_mfa_mode = LocationMfaMode::Internal; + network.mfa_enabled = true; network.service_location_mode = ServiceLocationMode::Disabled; let network = network.save(&pool).await.unwrap(); diff --git a/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/support.rs b/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/support.rs index 3234824de..0b8f37a04 100644 --- a/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/support.rs +++ b/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/support.rs @@ -7,7 +7,7 @@ use defguard_common::{ device::{Device, DeviceInfo, DeviceNetworkInfo, DeviceType, WireguardNetworkDevice}, user::User, vpn_client_session::VpnClientSession, - wireguard::{LocationMfaMode, WireguardNetwork}, + wireguard::WireguardNetwork, }, }, gateway_event::GatewayCommand, @@ -211,7 +211,7 @@ pub(crate) async fn enable_internal_mfa_for_network( pool: &sqlx::PgPool, network: &mut WireguardNetwork, ) { - network.location_mfa_mode = LocationMfaMode::Internal; + network.mfa_enabled = true; network .save(pool) .await diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index 506d5cd1d..701e73aef 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -257,7 +257,7 @@ impl EnrollmentServer { let instance_has_internal_mfa = query_scalar!( "SELECT EXISTS( \ SELECT 1 FROM wireguard_network \ - WHERE location_mfa_mode = 'internal'::location_mfa_mode \ + WHERE mfa_enabled = true \ ) \"exists!\"" ) .fetch_one(&self.pool) diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs index fbfc93d5d..d051db334 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs @@ -14,7 +14,7 @@ use defguard_common::{ settings::{Settings, update_current_settings}, user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, vpn_client_session::VpnClientSession, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, }, }, secret::SecretStringWrapper, @@ -186,7 +186,7 @@ pub(crate) async fn create_network(pool: &PgPool) -> WireguardNetwork { false, // acl_enabled false, // acl_default_allow false, - LocationMfaMode::default(), + false, // mfa_enabled ServiceLocationMode::default(), ) .try_set_address("10.0.0.1/24") @@ -447,7 +447,7 @@ pub(crate) async fn create_mfa_network(pool: &PgPool) -> WireguardNetwork { false, // acl_enabled false, // acl_default_allow false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::default(), ) .try_set_address("10.1.0.1/24") @@ -471,7 +471,7 @@ pub(crate) async fn create_external_mfa_network(pool: &PgPool) -> WireguardNetwo false, // acl_enabled false, // acl_default_allow false, - LocationMfaMode::External, + true, // mfa_enabled ServiceLocationMode::default(), ) .try_set_address("10.2.0.1/24") diff --git a/crates/defguard_session_manager/src/session_state.rs b/crates/defguard_session_manager/src/session_state.rs index 95bbf012f..a7a821d47 100644 --- a/crates/defguard_session_manager/src/session_state.rs +++ b/crates/defguard_session_manager/src/session_state.rs @@ -11,7 +11,6 @@ use defguard_common::{ Device, User, WireguardNetwork, vpn_client_session::{VpnClientSession, VpnClientSessionState}, vpn_session_stats::VpnSessionStats, - wireguard::LocationMfaMode, }, }, messages::peer_stats_update::PeerStatsUpdate, @@ -359,7 +358,7 @@ impl ActiveSessionsMap { // check location MFA mode since MFA sessions should be created elsewhere // once MFA auth is successful - if location.location_mfa_mode != LocationMfaMode::Disabled { + if location.mfa_enabled { warn!( "Received peer stats update for MFA-enabled location {location}, but VPN session does not exist yet. Skipping creating a new session..." ); diff --git a/crates/defguard_session_manager/tests/common/mod.rs b/crates/defguard_session_manager/tests/common/mod.rs index 385c431b2..f0a5b991c 100644 --- a/crates/defguard_session_manager/tests/common/mod.rs +++ b/crates/defguard_session_manager/tests/common/mod.rs @@ -13,7 +13,7 @@ use defguard_common::{ gateway::Gateway, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, vpn_session_stats::VpnSessionStats, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, }, }, gateway_event::GatewayCommand, @@ -117,12 +117,12 @@ impl SessionManagerHarness { } pub(crate) async fn create_location(pool: &PgPool) -> WireguardNetwork { - create_location_with_mfa_mode(pool, LocationMfaMode::Disabled).await + create_location_with_mfa_mode(pool, false).await } pub(crate) async fn create_location_with_mfa_mode( pool: &PgPool, - location_mfa_mode: LocationMfaMode, + mfa_enabled: bool, ) -> WireguardNetwork { WireguardNetwork::new( "TestNet".to_owned(), @@ -134,7 +134,7 @@ pub(crate) async fn create_location_with_mfa_mode( false, false, false, - location_mfa_mode, + mfa_enabled, ServiceLocationMode::Disabled, ) .set_address([IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 24).unwrap()]) diff --git a/crates/defguard_session_manager/tests/session_manager/mfa.rs b/crates/defguard_session_manager/tests/session_manager/mfa.rs index 5d4f773a9..2d898075e 100644 --- a/crates/defguard_session_manager/tests/session_manager/mfa.rs +++ b/crates/defguard_session_manager/tests/session_manager/mfa.rs @@ -6,7 +6,6 @@ use defguard_common::{ models::{ vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, vpn_session_stats::VpnSessionStats, - wireguard::LocationMfaMode, }, setup_pool, }, @@ -32,7 +31,7 @@ async fn test_mfa_location_stats_do_not_create_missing_session( options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; @@ -72,7 +71,7 @@ async fn test_mfa_new_session_upgrades_to_connected_on_stats( options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; @@ -184,7 +183,7 @@ async fn test_duplicate_first_stats_on_mfa_new_session_are_idempotent( options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; @@ -269,7 +268,7 @@ async fn test_repeated_later_stats_on_mfa_session_remain_idempotent( options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; @@ -374,7 +373,7 @@ async fn test_closed_event_channel_keeps_mfa_first_stats_upgrade_idempotent( options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; @@ -449,7 +448,7 @@ async fn test_inactive_mfa_connected_sessions_disconnect_and_clear_authorization options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; @@ -531,7 +530,7 @@ async fn test_never_connected_mfa_new_sessions_disconnect_after_threshold( options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let location = create_location_with_mfa_mode(&pool, LocationMfaMode::Internal).await; + let location = create_location_with_mfa_mode(&pool, true).await; let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; diff --git a/crates/defguard_setup/src/auto_adoption.rs b/crates/defguard_setup/src/auto_adoption.rs index f75580b87..8a36da341 100644 --- a/crates/defguard_setup/src/auto_adoption.rs +++ b/crates/defguard_setup/src/auto_adoption.rs @@ -19,7 +19,7 @@ use defguard_common::{ setup_auto_adoption::{ AutoAdoptionComponentResult, AutoAdoptionWizardState, SetupAutoAdoptionComponent, }, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, }, utils::strip_scheme, }; @@ -984,7 +984,7 @@ id={} for new gateway", false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([network_address])? diff --git a/crates/defguard_setup/src/handlers/auto_wizard.rs b/crates/defguard_setup/src/handlers/auto_wizard.rs index c2f1ffdd7..86ea3f78b 100644 --- a/crates/defguard_setup/src/handlers/auto_wizard.rs +++ b/crates/defguard_setup/src/handlers/auto_wizard.rs @@ -8,7 +8,6 @@ use defguard_common::{ initial_setup_wizard::InitialSetupStep, settings::update_current_settings, setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, - wireguard::LocationMfaMode, wizard::{ActiveWizard, Wizard}, }, }, @@ -305,7 +304,7 @@ pub async fn set_vpn_settings( #[derive(Deserialize, Serialize, Debug)] pub struct MfaSettingsConfig { #[serde(rename = "vpn_mfa_mode")] - mfa_mode: LocationMfaMode, + mfa_enabled: bool, } /// Updates first auto-adopted network location with MFA mode from Auto-adoption wizard. @@ -332,14 +331,14 @@ pub async fn set_mfa_settings( )) })?; - network.location_mfa_mode = mfa_settings.mfa_mode; + network.mfa_enabled = mfa_settings.mfa_enabled; network.save(&pool).await?; advance_auto_wizard_to_step(&pool, AutoAdoptionWizardStep::Summary).await?; debug!( "Auto-adoption MFA settings applied to network_id={} location_mfa_mode={:?}", - network.id, network.location_mfa_mode + network.id, network.mfa_enabled ); Ok(ApiResponse::with_status(StatusCode::CREATED)) diff --git a/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs index 7637b498b..0c28eafc5 100644 --- a/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs +++ b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs @@ -10,7 +10,7 @@ use defguard_common::{ setup_auto_adoption::{ AutoAdoptionWizardState, AutoAdoptionWizardStep, SetupAutoAdoptionComponent, }, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, wizard::{ActiveWizard, Wizard}, }, setup_pool, @@ -65,7 +65,7 @@ async fn seed_wireguard_network(pool: &sqlx::PgPool) -> WireguardNetwork { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address(["10.0.0.1/24".parse::().unwrap()]) @@ -189,7 +189,7 @@ async fn test_auto_adoption_full_flow(_: PgPoolOptions, options: PgConnectOption .await .expect("DB query failed") .expect("Network not found after MFA settings update"); - assert_eq!(updated_network.location_mfa_mode, LocationMfaMode::Disabled); + assert_eq!(updated_network.mfa_enabled, false); let resp = client .get("/api/v1/initial_setup/auto_adoption") diff --git a/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs b/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs index 5482a3a4f..fe0e94031 100644 --- a/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs +++ b/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs @@ -8,7 +8,7 @@ use defguard_common::{ certificates::{CoreCertSource, ProxyCertSource}, settings::initialize_current_settings, setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, wizard::{ActiveWizard, Wizard}, }, setup_pool, @@ -74,7 +74,7 @@ async fn seed_wireguard_network(pool: &sqlx::PgPool) -> WireguardNetwork { false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address(["10.0.0.1/24".parse::().unwrap()]) diff --git a/crates/defguard_setup/tests/integration/wizard_state.rs b/crates/defguard_setup/tests/integration/wizard_state.rs index 59446faf9..a16e8f52b 100644 --- a/crates/defguard_setup/tests/integration/wizard_state.rs +++ b/crates/defguard_setup/tests/integration/wizard_state.rs @@ -4,7 +4,7 @@ use defguard_common::{ models::{ settings::initialize_current_settings, setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, - wireguard::{LocationMfaMode, ServiceLocationMode, WireguardNetwork}, + wireguard::{ServiceLocationMode, WireguardNetwork}, wizard::{ActiveWizard, Wizard}, }, setup_pool, @@ -148,7 +148,7 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address(["10.0.0.1/24".parse().unwrap()]) diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql index a07b6bae1..6f9f73dd1 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql @@ -3,3 +3,4 @@ DROP TABLE IF EXISTS location_mfa_flow; DROP TABLE IF EXISTS mfa_flow_step; DROP TABLE IF EXISTS mfa_flow; ALTER TABLE wireguard_network DROP COLUMN IF EXISTS mfa_enabled; +ALTER TABLE wireguard_network ADD COLUMN IF NOT EXISTS location_mfa_mode location_mfa_mode NOT NULL DEFAULT 'disabled'; diff --git a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql index 9d2ebb238..b352c74c6 100644 --- a/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql @@ -71,3 +71,5 @@ INSERT INTO location_mfa_flow (location_id, flow_id, position, is_default) SELECT wn.id, mf.id, 0, true FROM wireguard_network wn, mfa_flow mf WHERE wn.location_mfa_mode = 'external' AND mf.title = 'Default External MFA'; + +ALTER TABLE wireguard_network DROP COLUMN location_mfa_mode; From 57ce2aff82ff93f41be205e3c5e3f95747ff3245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 12 Aug 2026 07:38:30 +0200 Subject: [PATCH 20/36] fix clippy warnings --- .../defguard_common/src/db/models/mfa_flow.rs | 8 +-- .../src/db/models/mfa_flow/tests.rs | 54 +++++++++---------- .../src/grpc/proxy/client_mfa.rs | 2 +- crates/defguard_core/src/handlers/mfa_flow.rs | 6 +-- .../defguard_core/src/handlers/wireguard.rs | 3 +- .../tests/integration/api/wireguard.rs | 4 +- .../api/wireguard_network_allowed_groups.rs | 2 +- .../defguard_gateway_manager/src/handler.rs | 2 +- .../tests/integration/auto_adoption_wizard.rs | 2 +- 9 files changed, 41 insertions(+), 42 deletions(-) diff --git a/crates/defguard_common/src/db/models/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs index 452be1359..6ad46673a 100644 --- a/crates/defguard_common/src/db/models/mfa_flow.rs +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -270,10 +270,10 @@ impl MfaFlow { if default_count != 1 { return Err(MfaFlowAssignmentError::NoDefaultDesignated); } - if let Some(default) = assignments.iter().find(|a| a.is_default) { - if !default.group_ids.is_empty() { - return Err(MfaFlowAssignmentError::NoDefaultDesignated); - } + if let Some(default) = assignments.iter().find(|a| a.is_default) + && !default.group_ids.is_empty() + { + return Err(MfaFlowAssignmentError::NoDefaultDesignated); } query!( diff --git a/crates/defguard_common/src/db/models/mfa_flow/tests.rs b/crates/defguard_common/src/db/models/mfa_flow/tests.rs index 66f1a265b..2b2586b07 100644 --- a/crates/defguard_common/src/db/models/mfa_flow/tests.rs +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -14,7 +14,7 @@ use crate::db::{ async fn create_flow(pool: &sqlx::PgPool) -> (MfaFlow, Vec>) { let mut tx = pool.begin().await.unwrap(); let (flow, steps) = MfaFlow::create( - &mut *tx, + &mut tx, "Test Flow".into(), vec![ vec![VpnClientMfaMethod::Totp], @@ -35,7 +35,7 @@ async fn test_insert_new_step(_: PgPoolOptions, options: PgConnectOptions) { let mut tx = pool.begin().await.unwrap(); let (_, updated_steps) = MfaFlow::update_with_steps( - &mut *tx, + &mut tx, flow.id, "Test Flow".into(), vec![ @@ -65,7 +65,7 @@ async fn test_update_kept_step(_: PgPoolOptions, options: PgConnectOptions) { let mut tx = pool.begin().await.unwrap(); let (_, updated_steps) = MfaFlow::update_with_steps( - &mut *tx, + &mut tx, flow.id, "Renamed Flow".into(), vec![ @@ -105,7 +105,7 @@ async fn test_delete_removed_step(_: PgPoolOptions, options: PgConnectOptions) { // Add a third step let mut tx = pool.begin().await.unwrap(); - MfaFlowStep::insert_batch(&mut *tx, flow.id, &[vec![VpnClientMfaMethod::Oidc]]) + MfaFlowStep::insert_batch(&mut tx, flow.id, &[vec![VpnClientMfaMethod::Oidc]]) .await .unwrap(); tx.commit().await.unwrap(); @@ -116,7 +116,7 @@ async fn test_delete_removed_step(_: PgPoolOptions, options: PgConnectOptions) { // Update: keep steps 0 and 2, delete step 1 let mut tx = pool.begin().await.unwrap(); let (_, updated_steps) = MfaFlow::update_with_steps( - &mut *tx, + &mut tx, flow.id, "Test Flow".into(), vec![ @@ -152,7 +152,7 @@ async fn test_position_swap(_: PgPoolOptions, options: PgConnectOptions) { let mut tx = pool.begin().await.unwrap(); let (_, updated_steps) = MfaFlow::update_with_steps( - &mut *tx, + &mut tx, flow.id, "Test Flow".into(), vec![ @@ -181,7 +181,7 @@ async fn test_assign_to_location(_: PgPoolOptions, options: PgConnectOptions) { let (flow2, _) = { let mut tx = pool.begin().await.unwrap(); let (f, s) = MfaFlow::create( - &mut *tx, + &mut tx, "Second Flow".into(), vec![vec![VpnClientMfaMethod::Oidc]], ) @@ -201,7 +201,7 @@ async fn test_assign_to_location(_: PgPoolOptions, options: PgConnectOptions) { // Assign two flows to the location let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[ LocationMfaFlowAssignment { @@ -241,7 +241,7 @@ async fn test_assign_to_location_full_replace(_: PgPoolOptions, options: PgConne let (flow2, _) = { let mut tx = pool.begin().await.unwrap(); let (f, s) = MfaFlow::create( - &mut *tx, + &mut tx, "Second Flow".into(), vec![vec![VpnClientMfaMethod::Oidc]], ) @@ -261,7 +261,7 @@ async fn test_assign_to_location_full_replace(_: PgPoolOptions, options: PgConne // First assignment: flow1 only let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow1.id, @@ -276,7 +276,7 @@ async fn test_assign_to_location_full_replace(_: PgPoolOptions, options: PgConne // Second assignment replaces: flow2 only let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow2.id, @@ -306,7 +306,7 @@ async fn test_assign_no_default_rejected(_: PgPoolOptions, options: PgConnectOpt .unwrap(); let result = MfaFlow::assign_to_location( - &mut *pool.acquire().await.unwrap(), + &mut pool.acquire().await.unwrap(), network.id, &[LocationMfaFlowAssignment { flow_id: flow1.id, @@ -334,7 +334,7 @@ async fn test_assign_default_with_groups_rejected(_: PgPoolOptions, options: PgC .unwrap(); let result = MfaFlow::assign_to_location( - &mut *pool.acquire().await.unwrap(), + &mut pool.acquire().await.unwrap(), network.id, &[LocationMfaFlowAssignment { flow_id: flow1.id, @@ -363,7 +363,7 @@ async fn test_check_deletable_location_requires_flow(_: PgPoolOptions, options: let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow1.id, @@ -389,7 +389,7 @@ async fn test_check_deletable_flow_is_default(_: PgPoolOptions, options: PgConne let (flow2, _) = { let mut tx = pool.begin().await.unwrap(); let (f, s) = MfaFlow::create( - &mut *tx, + &mut tx, "Second".into(), vec![vec![VpnClientMfaMethod::Oidc]], ) @@ -408,7 +408,7 @@ async fn test_check_deletable_flow_is_default(_: PgPoolOptions, options: PgConne let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[ LocationMfaFlowAssignment { @@ -442,7 +442,7 @@ async fn test_resolve_group_match(_: PgPoolOptions, options: PgConnectOptions) { let (flow2, _) = { let mut tx = pool.begin().await.unwrap(); let (f, s) = MfaFlow::create( - &mut *tx, + &mut tx, "Default".into(), vec![vec![VpnClientMfaMethod::Oidc]], ) @@ -475,7 +475,7 @@ async fn test_resolve_group_match(_: PgPoolOptions, options: PgConnectOptions) { let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[ LocationMfaFlowAssignment { @@ -509,7 +509,7 @@ async fn test_resolve_fallback_to_default(_: PgPoolOptions, options: PgConnectOp let (flow2, _) = { let mut tx = pool.begin().await.unwrap(); let (f, s) = MfaFlow::create( - &mut *tx, + &mut tx, "Default".into(), vec![vec![VpnClientMfaMethod::Oidc]], ) @@ -534,7 +534,7 @@ async fn test_resolve_fallback_to_default(_: PgPoolOptions, options: PgConnectOp let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[ LocationMfaFlowAssignment { @@ -566,7 +566,7 @@ async fn test_derive_legacy_internal(_: PgPoolOptions, options: PgConnectOptions let mut tx = pool.begin().await.unwrap(); let (flow, _) = MfaFlow::create( - &mut *tx, + &mut tx, "Internal".into(), vec![vec![ VpnClientMfaMethod::Totp, @@ -588,7 +588,7 @@ async fn test_derive_legacy_internal(_: PgPoolOptions, options: PgConnectOptions let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow.id, @@ -612,7 +612,7 @@ async fn test_derive_legacy_external(_: PgPoolOptions, options: PgConnectOptions let mut tx = pool.begin().await.unwrap(); let (flow, _) = MfaFlow::create( - &mut *tx, + &mut tx, "External".into(), vec![vec![VpnClientMfaMethod::Oidc]], ) @@ -629,7 +629,7 @@ async fn test_derive_legacy_external(_: PgPoolOptions, options: PgConnectOptions let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow.id, @@ -661,7 +661,7 @@ async fn test_derive_legacy_multi_step_omitted(_: PgPoolOptions, options: PgConn let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow.id, @@ -685,7 +685,7 @@ async fn test_derive_legacy_internal_subset_omitted(_: PgPoolOptions, options: P let mut tx = pool.begin().await.unwrap(); let (flow, _) = MfaFlow::create( - &mut *tx, + &mut tx, "Subset".into(), vec![vec![VpnClientMfaMethod::Totp]], // only TOTP, not all four ) @@ -702,7 +702,7 @@ async fn test_derive_legacy_internal_subset_omitted(_: PgPoolOptions, options: P let mut tx = pool.begin().await.unwrap(); MfaFlow::assign_to_location( - &mut *tx, + &mut tx, network.id, &[LocationMfaFlowAssignment { flow_id: flow.id, diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index c98572d8d..fa845f084 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -1380,7 +1380,7 @@ mod tests { polling_token::PollingToken, settings::initialize_current_settings, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, }, setup_pool, }; diff --git a/crates/defguard_core/src/handlers/mfa_flow.rs b/crates/defguard_core/src/handlers/mfa_flow.rs index a5e7fd5b1..fd64f785b 100644 --- a/crates/defguard_core/src/handlers/mfa_flow.rs +++ b/crates/defguard_core/src/handlers/mfa_flow.rs @@ -249,7 +249,7 @@ pub async fn create_mfa_flow( } let mut tx = appstate.pool.begin().await?; - let (flow, steps) = MfaFlow::create(&mut *tx, data.title, step_methods).await?; + let (flow, steps) = MfaFlow::create(&mut tx, data.title, step_methods).await?; tx.commit().await?; debug!("Created MFA flow {}", flow.id); @@ -368,7 +368,7 @@ pub async fn update_mfa_flow( let mut tx = appstate.pool.begin().await?; let (flow, steps) = - MfaFlow::update_with_steps(&mut *tx, existing.id, data.title, step_updates).await?; + MfaFlow::update_with_steps(&mut tx, existing.id, data.title, step_updates).await?; tx.commit().await?; appstate.emit_event(ApiEvent { @@ -557,7 +557,7 @@ pub async fn set_location_mfa_flows( .collect(); let mut tx = appstate.pool.begin().await?; - MfaFlow::assign_to_location(&mut *tx, location_id, &assignments) + MfaFlow::assign_to_location(&mut tx, location_id, &assignments) .await .map_err(|e| match e { MfaFlowAssignmentError::NoDefaultDesignated => { diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index fe5c07306..99f94ad70 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -32,11 +32,10 @@ use crate::{ enterprise::{ db::models::{ device_posture::DevicePostureLocation, enterprise_settings::EnterpriseSettings, - openid_provider::OpenIdProvider, }, firewall::try_get_location_firewall_config, handlers::CanManageDevices, - has_enterprise_access, is_business_license_active, + has_enterprise_access, license::{LicenseFeature, get_cached_license}, limits::{get_counts, update_counts}, }, diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 07df28be9..6a70a563d 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -9,7 +9,7 @@ use defguard_common::db::{ settings::OpenIdUsernameHandling, wireguard::{ DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_WIREGUARD_MTU, - LocationMfaMode, ServiceLocationMode, + ServiceLocationMode, }, }, }; @@ -640,7 +640,7 @@ async fn test_modify_network_rejects_service_location_with_mfa( .send() .await; let fetched: WireguardNetwork = response.json().await; - assert_eq!(fetched.mfa_enabled, false); + assert!(!fetched.mfa_enabled); assert_eq!(fetched.service_location_mode, ServiceLocationMode::Disabled); // enabling service location mode alone is accepted and persisted diff --git a/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs index 8bf3105f4..34e4fe976 100644 --- a/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs @@ -10,7 +10,7 @@ use defguard_common::{ group::Group, wireguard::{ DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_WIREGUARD_MTU, - LocationMfaMode, ServiceLocationMode, + ServiceLocationMode, }, }, }, diff --git a/crates/defguard_gateway_manager/src/handler.rs b/crates/defguard_gateway_manager/src/handler.rs index 8dc50a554..38e3dda76 100644 --- a/crates/defguard_gateway_manager/src/handler.rs +++ b/crates/defguard_gateway_manager/src/handler.rs @@ -1162,7 +1162,7 @@ mod tests { GatewayHandler, GatewayUpdatesHandler, WireguardPeer, try_protos_into_stats_message, }; - fn test_network(mfa_enabled: bool) -> WireguardNetwork { + fn test_network(__mfa_enabled: bool) -> WireguardNetwork { WireguardNetwork::new( "test-network".into(), 51820, diff --git a/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs index 0c28eafc5..5cd20b7a9 100644 --- a/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs +++ b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs @@ -189,7 +189,7 @@ async fn test_auto_adoption_full_flow(_: PgPoolOptions, options: PgConnectOption .await .expect("DB query failed") .expect("Network not found after MFA settings update"); - assert_eq!(updated_network.mfa_enabled, false); + assert!(!updated_network.mfa_enabled); let resp = client .get("/api/v1/initial_setup/auto_adoption") From d674c480df96ba407afd85c4018ccfea3e43b075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 12 Aug 2026 08:43:14 +0200 Subject: [PATCH 21/36] remove mfa mode references from the frontend --- web/messages/en/location.json | 1 + .../steps/AddLocationMfaStep.tsx | 84 ++++++------------- .../AddLocationPage/useAddLocationStore.tsx | 8 +- .../EditLocationPage/EditLocationPage.tsx | 78 ++++++----------- .../components/LocationsTable.tsx | 45 ++++------ .../NetworkDevicesTable.tsx | 10 +-- .../steps/AutoAdoptionMfaSetupStep.tsx | 54 +++--------- .../useAutoAdoptionSetupWizardStore.tsx | 5 +- web/src/shared/api/types.ts | 4 +- 9 files changed, 86 insertions(+), 203 deletions(-) diff --git a/web/messages/en/location.json b/web/messages/en/location.json index 08a0de228..cbcfa241e 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -46,6 +46,7 @@ "add_location_internal_vpn_helper_allowed_ips": "", "add_location_internal_vpn_label_dns": "DNS", "add_location_internal_vpn_helper_dns": "", + "add_location_mfa_toggle_label": "Enforce Multi-Factor Authentication for this location", "add_location_mfa_disabled_title": "Do not enforce MFA", "add_location_mfa_internal_title": "Internal Defguard Multi-Factor Authentication", "add_location_mfa_internal_content": "Uses the MFA methods configured in your Defguard profile.", diff --git a/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx index 3e292a66a..09908092e 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx @@ -1,19 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import z from 'zod'; import { m } from '../../../paraglide/messages'; -import { LocationMfaMode, type NetworkLocation } from '../../../shared/api/types'; -import { businessBadgeProps } from '../../../shared/components/badges/BusinessBadge'; import { Controls } from '../../../shared/components/Controls/Controls'; import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { Input } from '../../../shared/defguard-ui/components/Input/Input'; -import { InteractiveBlock } from '../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Toggle } from '../../../shared/defguard-ui/components/Toggle/Toggle'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { getLicenseInfoQueryOptions } from '../../../shared/query'; -import { canUseBusinessFeature } from '../../../shared/utils/license'; import { AddLocationPageStep } from '../types'; import { useAddLocationStore } from '../useAddLocationStore'; @@ -24,79 +19,50 @@ const schema = z export const AddLocationMfaStep = () => { const [error, setError] = useState(null); const [disconnect, setDisconnect] = useState(300); - const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); - const canUseFeature = useMemo(() => { - if (licenseInfo === undefined) return undefined; - return canUseBusinessFeature(licenseInfo).result; - }, [licenseInfo]); - - const [choice, setChoice] = useState( - LocationMfaMode.Disabled, - ); + const [mfaEnabled, setMfaEnabled] = useState(false); const handleSubmit = () => { if (!error) { useAddLocationStore.setState({ - location_mfa_mode: choice, + mfa_enabled: mfaEnabled, activeStep: AddLocationPageStep.AccessControl, }); } }; - useEffect(() => { - if (choice === LocationMfaMode.Disabled) { + useMemo(() => { + if (!mfaEnabled) { setError(null); setDisconnect(300); return; } const result = schema.safeParse(disconnect); if (!result.success) { + setError(result.error.issues[0]?.message ?? null); } else { setError(null); } - }, [disconnect, choice]); + }, [disconnect, mfaEnabled]); return ( - setChoice(LocationMfaMode.Disabled)} - title={m.add_location_mfa_disabled_title()} - data-testid="do-not-enforce-mfa" - /> - - setChoice(LocationMfaMode.Internal)} - title={m.add_location_mfa_internal_title()} - content={m.add_location_mfa_internal_content()} - data-testid="enforce-internal-mfa" + setMfaEnabled(!mfaEnabled)} + label={m.add_location_mfa_toggle_label()} + testId="toggle-mfa" /> - - setChoice(LocationMfaMode.External)} - title={m.add_location_mfa_external_title()} - content={m.add_location_mfa_external_content()} - disabled={isPresent(canUseFeature) && !canUseFeature} - badge={ - isPresent(canUseFeature) && !canUseFeature ? businessBadgeProps : undefined - } - data-testid="enforce-external-mfa" - /> - {choice !== LocationMfaMode.Disabled && ( - <> - - setDisconnect(value as number | null)} - error={error} - required - /> - + + {mfaEnabled && ( + setDisconnect(value as number | null)} + error={error} + required + /> )}