diff --git a/.sqlx/query-15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985.json b/.sqlx/query-15509c92b1af656cba448302a1559c7c7a7987e7c12b31e7a79e71f08ef73985.json new file mode 100644 index 0000000000..71e64bfb8b --- /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-cc8297a9fb2acf4829e4330eb98f87f6cb8b22b0e2e2718d0bde9ab965c548fd.json b/.sqlx/query-16283b66b9af80f3214bf3c9b5398f8f2e40e281f1e76fdf71adbf207220a7a9.json similarity index 71% rename from .sqlx/query-cc8297a9fb2acf4829e4330eb98f87f6cb8b22b0e2e2718d0bde9ab965c548fd.json rename to .sqlx/query-16283b66b9af80f3214bf3c9b5398f8f2e40e281f1e76fdf71adbf207220a7a9.json index 98cdee7c8f..6e4043b151 100644 --- a/.sqlx/query-cc8297a9fb2acf4829e4330eb98f87f6cb8b22b0e2e2718d0bde9ab965c548fd.json +++ b/.sqlx/query-16283b66b9af80f3214bf3c9b5398f8f2e40e281f1e76fdf71adbf207220a7a9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"wireguard_network\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"pubkey\" = $5,\"prvkey\" = $6,\"endpoint\" = $7,\"dns\" = $8,\"mtu\" = $9,\"fwmark\" = $10,\"allowed_ips\" = $11,\"allow_all_groups\" = $12,\"connected_at\" = $13,\"acl_enabled\" = $14,\"acl_default_allow\" = $15,\"allowed_ips_from_acl\" = $16,\"keepalive_interval\" = $17,\"peer_disconnect_threshold\" = $18,\"location_mfa_mode\" = $19,\"service_location_mode\" = $20 WHERE id = $1", + "query": "UPDATE \"wireguard_network\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"pubkey\" = $5,\"prvkey\" = $6,\"endpoint\" = $7,\"dns\" = $8,\"mtu\" = $9,\"fwmark\" = $10,\"allowed_ips\" = $11,\"allow_all_groups\" = $12,\"connected_at\" = $13,\"acl_enabled\" = $14,\"acl_default_allow\" = $15,\"allowed_ips_from_acl\" = $16,\"keepalive_interval\" = $17,\"peer_disconnect_threshold\" = $18,\"mfa_enabled\" = $19,\"service_location_mode\" = $20 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -23,18 +23,7 @@ "Bool", "Int4", "Int4", - { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - }, + "Bool", { "Custom": { "name": "service_location_mode", @@ -51,5 +40,5 @@ }, "nullable": [] }, - "hash": "cc8297a9fb2acf4829e4330eb98f87f6cb8b22b0e2e2718d0bde9ab965c548fd" + "hash": "16283b66b9af80f3214bf3c9b5398f8f2e40e281f1e76fdf71adbf207220a7a9" } diff --git a/.sqlx/query-1a390d684b013ab653c83681b343869c197148982067a5a98f453a47b1d9664b.json b/.sqlx/query-1a390d684b013ab653c83681b343869c197148982067a5a98f453a47b1d9664b.json new file mode 100644 index 0000000000..ba6ac472fe --- /dev/null +++ b/.sqlx/query-1a390d684b013ab653c83681b343869c197148982067a5a98f453a47b1d9664b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO location_mfa_flow_group (location_id, flow_id, group_id) SELECT $1, $2, unnest($3::bigint[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "1a390d684b013ab653c83681b343869c197148982067a5a98f453a47b1d9664b" +} diff --git a/.sqlx/query-1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129.json b/.sqlx/query-1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129.json new file mode 100644 index 0000000000..183f0222c6 --- /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": "Timestamp" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "1b6d9368e1f10f09a0e3b85d329788d6a0a7ab1f085219e5a942f324c0ba2129" +} diff --git a/.sqlx/query-56a64fe4c8c11d2916b0b2ed1f69b92c50a1f99619a91268e9c40ed6b096595b.json b/.sqlx/query-1b6f751f828ffca3381a344360397c845462b176941775b32ca04d2d5c6aee4c.json similarity index 84% rename from .sqlx/query-56a64fe4c8c11d2916b0b2ed1f69b92c50a1f99619a91268e9c40ed6b096595b.json rename to .sqlx/query-1b6f751f828ffca3381a344360397c845462b176941775b32ca04d2d5c6aee4c.json index f7c4f6ecf7..551c20904d 100644 --- a/.sqlx/query-56a64fe4c8c11d2916b0b2ed1f69b92c50a1f99619a91268e9c40ed6b096595b.json +++ b/.sqlx/query-1b6f751f828ffca3381a344360397c845462b176941775b32ca04d2d5c6aee4c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\" \"allowed_ips: _\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\" \"location_mfa_mode: _\",\"service_location_mode\" \"service_location_mode: _\" FROM \"wireguard_network\" WHERE id = $1", + "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\" \"allowed_ips: _\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"mfa_enabled\",\"service_location_mode\" \"service_location_mode: _\" FROM \"wireguard_network\" WHERE id = $1", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: _", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled", + "type_info": "Bool" }, { "ordinal": 19, @@ -154,5 +143,5 @@ false ] }, - "hash": "56a64fe4c8c11d2916b0b2ed1f69b92c50a1f99619a91268e9c40ed6b096595b" + "hash": "1b6f751f828ffca3381a344360397c845462b176941775b32ca04d2d5c6aee4c" } diff --git a/.sqlx/query-28fed800620098ab793e625a488b06cac3531141bcd083d1daf4a6822de0e14a.json b/.sqlx/query-28fed800620098ab793e625a488b06cac3531141bcd083d1daf4a6822de0e14a.json new file mode 100644 index 0000000000..65429ac479 --- /dev/null +++ b/.sqlx/query-28fed800620098ab793e625a488b06cac3531141bcd083d1daf4a6822de0e14a.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mfa_flow SET title = $1, updated_at = $2 WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Timestamp", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "28fed800620098ab793e625a488b06cac3531141bcd083d1daf4a6822de0e14a" +} diff --git a/.sqlx/query-32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af.json b/.sqlx/query-32c79e9ec1690cfd9223b5e3b835581761f270279e228566ef4731ef273ed6af.json new file mode 100644 index 0000000000..722e815820 --- /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-3f02db14c251586b54ff685d050600e2cede5184808e018468eb14d2df2d2a76.json b/.sqlx/query-3f02db14c251586b54ff685d050600e2cede5184808e018468eb14d2df2d2a76.json new file mode 100644 index 0000000000..0d8f5264f8 --- /dev/null +++ b/.sqlx/query-3f02db14c251586b54ff685d050600e2cede5184808e018468eb14d2df2d2a76.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS( SELECT 1 FROM wireguard_network wn JOIN location_mfa_flow lmf ON lmf.location_id = wn.id JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id WHERE wn.mfa_enabled = true AND mfs.methods && ARRAY['totp','email','biometric','mobileapprove']::vpn_client_mfa_method[] ) \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3f02db14c251586b54ff685d050600e2cede5184808e018468eb14d2df2d2a76" +} diff --git a/.sqlx/query-3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211.json b/.sqlx/query-3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211.json new file mode 100644 index 0000000000..24a280c178 --- /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": "Timestamp" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "3fc2f6065426b06efcd76947da141a500a1842308bd0c59d9769e163b2d0a211" +} diff --git a/.sqlx/query-42e5ffc58caf05ed544759253873fb87006b1508ac23932403c3dcf0328425a1.json b/.sqlx/query-42e5ffc58caf05ed544759253873fb87006b1508ac23932403c3dcf0328425a1.json new file mode 100644 index 0000000000..a0228d2f26 --- /dev/null +++ b/.sqlx/query-42e5ffc58caf05ed544759253873fb87006b1508ac23932403c3dcf0328425a1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mfa_flow WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "42e5ffc58caf05ed544759253873fb87006b1508ac23932403c3dcf0328425a1" +} diff --git a/.sqlx/query-ff188a49f1e440cf3a2e0fe9e51c8e56219024ee49eda0217392ae8b7d22913e.json b/.sqlx/query-465fc44d0673ae9711ee5db0ca9a159c0e94982cfcceaed35fc789a14f16f5aa.json similarity index 84% rename from .sqlx/query-ff188a49f1e440cf3a2e0fe9e51c8e56219024ee49eda0217392ae8b7d22913e.json rename to .sqlx/query-465fc44d0673ae9711ee5db0ca9a159c0e94982cfcceaed35fc789a14f16f5aa.json index 1697bdc02d..a505490b1b 100644 --- a/.sqlx/query-ff188a49f1e440cf3a2e0fe9e51c8e56219024ee49eda0217392ae8b7d22913e.json +++ b/.sqlx/query-465fc44d0673ae9711ee5db0ca9a159c0e94982cfcceaed35fc789a14f16f5aa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\" \"allowed_ips: _\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\" \"location_mfa_mode: _\",\"service_location_mode\" \"service_location_mode: _\" FROM \"wireguard_network\" LIMIT $1 OFFSET $2", + "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\" \"allowed_ips: _\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"mfa_enabled\",\"service_location_mode\" \"service_location_mode: _\" FROM \"wireguard_network\" LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: _", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled", + "type_info": "Bool" }, { "ordinal": 19, @@ -155,5 +144,5 @@ false ] }, - "hash": "ff188a49f1e440cf3a2e0fe9e51c8e56219024ee49eda0217392ae8b7d22913e" + "hash": "465fc44d0673ae9711ee5db0ca9a159c0e94982cfcceaed35fc789a14f16f5aa" } diff --git a/.sqlx/query-550e1cdb4f03bc4feaa553f6b6ca34d9afe85ac5429f5eaf0be2189d2862f821.json b/.sqlx/query-4ce3332e5d53e4986a4786ce1515f63ca7afaf888d7cdfd8a8fcf8572cd3cbed.json similarity index 79% rename from .sqlx/query-550e1cdb4f03bc4feaa553f6b6ca34d9afe85ac5429f5eaf0be2189d2862f821.json rename to .sqlx/query-4ce3332e5d53e4986a4786ce1515f63ca7afaf888d7cdfd8a8fcf8572cd3cbed.json index 3a73a22548..1ef8f26b09 100644 --- a/.sqlx/query-550e1cdb4f03bc4feaa553f6b6ca34d9afe85ac5429f5eaf0be2189d2862f821.json +++ b/.sqlx/query-4ce3332e5d53e4986a4786ce1515f63ca7afaf888d7cdfd8a8fcf8572cd3cbed.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, 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\", 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 ORDER BY id LIMIT 1)", + "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, allowed_ips, allow_all_groups, connected_at, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow, allowed_ips_from_acl, 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 NOT mfa_enabled", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: LocationMfaMode", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled!: bool", + "type_info": "Bool" }, { "ordinal": 19, @@ -154,5 +143,5 @@ false ] }, - "hash": "550e1cdb4f03bc4feaa553f6b6ca34d9afe85ac5429f5eaf0be2189d2862f821" + "hash": "4ce3332e5d53e4986a4786ce1515f63ca7afaf888d7cdfd8a8fcf8572cd3cbed" } diff --git a/.sqlx/query-418cfbdf875f541c080ef6ce96d31b7b9d4e630c4c1b1d244386ca53ab1b335d.json b/.sqlx/query-568a0474fdbc930863ddb99c2569076931ab44109712950c7387878e9cea5929.json similarity index 75% rename from .sqlx/query-418cfbdf875f541c080ef6ce96d31b7b9d4e630c4c1b1d244386ca53ab1b335d.json rename to .sqlx/query-568a0474fdbc930863ddb99c2569076931ab44109712950c7387878e9cea5929.json index 210f564233..a114b9d219 100644 --- a/.sqlx/query-418cfbdf875f541c080ef6ce96d31b7b9d4e630c4c1b1d244386ca53ab1b335d.json +++ b/.sqlx/query-568a0474fdbc930863ddb99c2569076931ab44109712950c7387878e9cea5929.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, 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\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM wireguard_network WHERE location_mfa_mode = 'external'::location_mfa_mode", + "query": "SELECT DISTINCT wn.id, wn.name, wn.address, wn.port, wn.pubkey, wn.prvkey, wn.endpoint, wn.dns, wn.mtu, wn.fwmark, wn.allowed_ips, wn.allow_all_groups, wn.connected_at, wn.keepalive_interval, wn.peer_disconnect_threshold, wn.acl_enabled, wn.acl_default_allow, wn.allowed_ips_from_acl, wn.mfa_enabled \"mfa_enabled!: bool\", wn.service_location_mode \"service_location_mode: ServiceLocationMode\" FROM wireguard_network wn JOIN location_mfa_flow lmf ON lmf.location_id = wn.id JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id WHERE 'oidc' = ANY(mfs.methods)", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: LocationMfaMode", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled!: bool", + "type_info": "Bool" }, { "ordinal": 19, @@ -152,5 +141,5 @@ false ] }, - "hash": "418cfbdf875f541c080ef6ce96d31b7b9d4e630c4c1b1d244386ca53ab1b335d" + "hash": "568a0474fdbc930863ddb99c2569076931ab44109712950c7387878e9cea5929" } diff --git a/.sqlx/query-5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9.json b/.sqlx/query-5d336c4dfb0a111c9a39f505cdf903e392dc0692f83309e69efb839ced77adb9.json new file mode 100644 index 0000000000..a577b30efd --- /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": "Timestamp" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamp" + }, + { + "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 0000000000..46819b2dd4 --- /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-e2d5bb28064afd6169f6078dc06edc7a62ac4c807dd36a025611d71cc8d11919.json b/.sqlx/query-5fcef226340ad637e7350b99cea0ffad94580a58ba499514c0e18827f5b37165.json similarity index 61% rename from .sqlx/query-e2d5bb28064afd6169f6078dc06edc7a62ac4c807dd36a025611d71cc8d11919.json rename to .sqlx/query-5fcef226340ad637e7350b99cea0ffad94580a58ba499514c0e18827f5b37165.json index 12ed372fe7..f24751b1d2 100644 --- a/.sqlx/query-e2d5bb28064afd6169f6078dc06edc7a62ac4c807dd36a025611d71cc8d11919.json +++ b/.sqlx/query-5fcef226340ad637e7350b99cea0ffad94580a58ba499514c0e18827f5b37165.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", 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\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, state, connected_at FROM vpn_client_session WHERE location_id = n.id AND device_id = wnd.device_id AND connected_at IS NOT NULL ORDER BY connected_at DESC, id DESC LIMIT 1 ) latest_successful_session ON true LEFT JOIN LATERAL ( SELECT endpoint FROM vpn_session_stats WHERE session_id = latest_successful_session.id ORDER BY collected_at DESC, id DESC LIMIT 1 ) latest_successful_stats ON true WHERE wnd.device_id = $1", + "query": "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_successful_stats.endpoint \"device_endpoint?\", latest_successful_session.connected_at \"last_connected_at?\", latest_successful_session.state \"state?: VpnClientSessionState\", n.mfa_enabled FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, state, connected_at FROM vpn_client_session WHERE location_id = n.id AND device_id = wnd.device_id AND connected_at IS NOT NULL ORDER BY connected_at DESC, id DESC LIMIT 1 ) latest_successful_session ON true LEFT JOIN LATERAL ( SELECT endpoint FROM vpn_session_stats WHERE session_id = latest_successful_session.id ORDER BY collected_at DESC, id DESC LIMIT 1 ) latest_successful_stats ON true WHERE wnd.device_id = $1", "describe": { "columns": [ { @@ -51,19 +51,8 @@ }, { "ordinal": 7, - "name": "location_mfa_mode: LocationMfaMode", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled", + "type_info": "Bool" } ], "parameters": { @@ -82,5 +71,5 @@ false ] }, - "hash": "e2d5bb28064afd6169f6078dc06edc7a62ac4c807dd36a025611d71cc8d11919" + "hash": "5fcef226340ad637e7350b99cea0ffad94580a58ba499514c0e18827f5b37165" } diff --git a/.sqlx/query-62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107.json b/.sqlx/query-62b0a321eb18d39c6ee509811b900273a3bdf350b1b29197214b22f2592d9107.json new file mode 100644 index 0000000000..b906bb97b6 --- /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-644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68.json b/.sqlx/query-644dd2b4e085bb68e6ff9707ef1b18a27caf969112e19f06026b13a43061cd68.json new file mode 100644 index 0000000000..81092fc2c3 --- /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-64e5644c3c8f1b6f70b9df2bbf378f39dad016f1a3b7a6d4d67c2a73cc31db93.json b/.sqlx/query-64e5644c3c8f1b6f70b9df2bbf378f39dad016f1a3b7a6d4d67c2a73cc31db93.json new file mode 100644 index 0000000000..ac25725ccf --- /dev/null +++ b/.sqlx/query-64e5644c3c8f1b6f70b9df2bbf378f39dad016f1a3b7a6d4d67c2a73cc31db93.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "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", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flow_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "group_ids!: Vec", + "type_info": "Int8Array" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "64e5644c3c8f1b6f70b9df2bbf378f39dad016f1a3b7a6d4d67c2a73cc31db93" +} diff --git a/.sqlx/query-f086fa6545d2391213872b9b61bd99515b06dd1c84b28baae23098903f053801.json b/.sqlx/query-74eb508e84d80e4872907c34c248e8e6ec2ae328167814eb5da276f909a04280.json similarity index 52% rename from .sqlx/query-f086fa6545d2391213872b9b61bd99515b06dd1c84b28baae23098903f053801.json rename to .sqlx/query-74eb508e84d80e4872907c34c248e8e6ec2ae328167814eb5da276f909a04280.json index 715e4b562c..577e1efb92 100644 --- a/.sqlx/query-f086fa6545d2391213872b9b61bd99515b06dd1c84b28baae23098903f053801.json +++ b/.sqlx/query-74eb508e84d80e4872907c34c248e8e6ec2ae328167814eb5da276f909a04280.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS( SELECT 1 FROM wireguard_network wn WHERE wn.location_mfa_mode = 'disabled' AND ( wn.allow_all_groups OR EXISTS( SELECT 1 FROM wireguard_network_allowed_group wnag JOIN \"group\" g ON g.id = wnag.group_id WHERE wnag.network_id = wn.id AND g.name = ANY($1) ) ) )", + "query": "SELECT EXISTS( SELECT 1 FROM wireguard_network wn WHERE NOT wn.mfa_enabled AND ( wn.allow_all_groups OR EXISTS( SELECT 1 FROM wireguard_network_allowed_group wnag JOIN \"group\" g ON g.id = wnag.group_id WHERE wnag.network_id = wn.id AND g.name = ANY($1) ) ) )", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "f086fa6545d2391213872b9b61bd99515b06dd1c84b28baae23098903f053801" + "hash": "74eb508e84d80e4872907c34c248e8e6ec2ae328167814eb5da276f909a04280" } diff --git a/.sqlx/query-fe6d87bfe08df5db271dc0e8a764939117182647de5c50b301b3c68f4ffea27d.json b/.sqlx/query-761ba71e502f40670a0f48b45aa0967ff9828950cc1ad48198026a445974b0db.json similarity index 79% rename from .sqlx/query-fe6d87bfe08df5db271dc0e8a764939117182647de5c50b301b3c68f4ffea27d.json rename to .sqlx/query-761ba71e502f40670a0f48b45aa0967ff9828950cc1ad48198026a445974b0db.json index cf203c0641..74f15bbbe2 100644 --- a/.sqlx/query-fe6d87bfe08df5db271dc0e8a764939117182647de5c50b301b3c68f4ffea27d.json +++ b/.sqlx/query-761ba71e502f40670a0f48b45aa0967ff9828950cc1ad48198026a445974b0db.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, 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\", 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'", + "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, allowed_ips, allow_all_groups, connected_at, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow, allowed_ips_from_acl, 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 ORDER BY id LIMIT 1)", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: LocationMfaMode", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled!: bool", + "type_info": "Bool" }, { "ordinal": 19, @@ -154,5 +143,5 @@ false ] }, - "hash": "fe6d87bfe08df5db271dc0e8a764939117182647de5c50b301b3c68f4ffea27d" + "hash": "761ba71e502f40670a0f48b45aa0967ff9828950cc1ad48198026a445974b0db" } diff --git a/.sqlx/query-778af4a6bc97eae18de6b03badd63ad7209e8c74f0edde545373472a4a5edf97.json b/.sqlx/query-778af4a6bc97eae18de6b03badd63ad7209e8c74f0edde545373472a4a5edf97.json new file mode 100644 index 0000000000..00eb31cbda --- /dev/null +++ b/.sqlx/query-778af4a6bc97eae18de6b03badd63ad7209e8c74f0edde545373472a4a5edf97.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO location_mfa_flow (location_id, flow_id, position, is_default) VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int4", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "778af4a6bc97eae18de6b03badd63ad7209e8c74f0edde545373472a4a5edf97" +} diff --git a/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json b/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json new file mode 100644 index 0000000000..bcb58797cc --- /dev/null +++ b/.sqlx/query-842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mfa_flow_step SET position = $1, methods = $2::vpn_client_mfa_method[] WHERE id = $3 AND flow_id = $4", + "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", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "842b460f91cd2f54be03ead78e5eff81ff2143d5713ce5b5424c388b79086fb3" +} diff --git a/.sqlx/query-89ce11d7778425dceaeb5ad6eed4e886d1901f48ca8957d543c1dc07fa00ca94.json b/.sqlx/query-89ce11d7778425dceaeb5ad6eed4e886d1901f48ca8957d543c1dc07fa00ca94.json new file mode 100644 index 0000000000..01658e0021 --- /dev/null +++ b/.sqlx/query-89ce11d7778425dceaeb5ad6eed4e886d1901f48ca8957d543c1dc07fa00ca94.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wn.name FROM location_mfa_flow lmf JOIN wireguard_network wn ON wn.id = lmf.location_id WHERE lmf.flow_id = $1 AND wn.mfa_enabled = true AND (SELECT COUNT(*) FROM location_mfa_flow WHERE location_id = lmf.location_id) = 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "89ce11d7778425dceaeb5ad6eed4e886d1901f48ca8957d543c1dc07fa00ca94" +} diff --git a/.sqlx/query-8b9ede93cc39f26e6006bd26eb6d3e7a35e12060a60ddc70ba3cc135c89db642.json b/.sqlx/query-8b9ede93cc39f26e6006bd26eb6d3e7a35e12060a60ddc70ba3cc135c89db642.json deleted file mode 100644 index 126c4d8db2..0000000000 --- a/.sqlx/query-8b9ede93cc39f26e6006bd26eb6d3e7a35e12060a60ddc70ba3cc135c89db642.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "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 ELSE active_session.preshared_key END \"preshared_key?\", CASE WHEN n.location_mfa_mode = 'disabled'::location_mfa_mode THEN TRUE ELSE active_session.preshared_key IS NOT NULL END \"is_authorized!\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, preshared_key FROM vpn_client_session WHERE location_id = wnd.wireguard_network_id AND device_id = wnd.device_id AND state IN ('new', 'connected') ORDER BY created_at DESC, id DESC LIMIT 1 ) active_session ON true WHERE wnd.device_id = $1 ORDER BY wnd.wireguard_network_id ASC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "network_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "device_wireguard_ips: Vec", - "type_info": "InetArray" - }, - { - "ordinal": 2, - "name": "preshared_key?", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "is_authorized!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - null, - null - ] - }, - "hash": "8b9ede93cc39f26e6006bd26eb6d3e7a35e12060a60ddc70ba3cc135c89db642" -} diff --git a/.sqlx/query-da4791580f89ca9ecc934b16a24934be9ac285d48fbd04a8c94372a9e0f65f5a.json b/.sqlx/query-931a75c03a58f37a5cf2c3e090990b37a9ae0ab99d541b75d92c4e9e47ff9c1c.json similarity index 82% rename from .sqlx/query-da4791580f89ca9ecc934b16a24934be9ac285d48fbd04a8c94372a9e0f65f5a.json rename to .sqlx/query-931a75c03a58f37a5cf2c3e090990b37a9ae0ab99d541b75d92c4e9e47ff9c1c.json index 7e719e32b1..a7a1a7bbff 100644 --- a/.sqlx/query-da4791580f89ca9ecc934b16a24934be9ac285d48fbd04a8c94372a9e0f65f5a.json +++ b/.sqlx/query-931a75c03a58f37a5cf2c3e090990b37a9ae0ab99d541b75d92c4e9e47ff9c1c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, 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\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM wireguard_network WHERE name = $1", + "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, allowed_ips, allow_all_groups, connected_at, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow, allowed_ips_from_acl, mfa_enabled \"mfa_enabled!: bool\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM wireguard_network WHERE name = $1", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: LocationMfaMode", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled!: bool", + "type_info": "Bool" }, { "ordinal": 19, @@ -154,5 +143,5 @@ false ] }, - "hash": "da4791580f89ca9ecc934b16a24934be9ac285d48fbd04a8c94372a9e0f65f5a" + "hash": "931a75c03a58f37a5cf2c3e090990b37a9ae0ab99d541b75d92c4e9e47ff9c1c" } diff --git a/.sqlx/query-94283d9aa82f7ce245a05a811e7d80c0709dcd0f356550ba18ec290b997bfca8.json b/.sqlx/query-94283d9aa82f7ce245a05a811e7d80c0709dcd0f356550ba18ec290b997bfca8.json new file mode 100644 index 0000000000..bef516faac --- /dev/null +++ b/.sqlx/query-94283d9aa82f7ce245a05a811e7d80c0709dcd0f356550ba18ec290b997bfca8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "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", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "94283d9aa82f7ce245a05a811e7d80c0709dcd0f356550ba18ec290b997bfca8" +} diff --git a/.sqlx/query-94b724fd845cde4b019f9c509304aa6d1f44aeac31c89e5d227663a00a78d52f.json b/.sqlx/query-94b724fd845cde4b019f9c509304aa6d1f44aeac31c89e5d227663a00a78d52f.json new file mode 100644 index 0000000000..a042f968b2 --- /dev/null +++ b/.sqlx/query-94b724fd845cde4b019f9c509304aa6d1f44aeac31c89e5d227663a00a78d52f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM location_mfa_flow WHERE location_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "94b724fd845cde4b019f9c509304aa6d1f44aeac31c89e5d227663a00a78d52f" +} diff --git a/.sqlx/query-9c6c55faa25dc4a9d891b701c4baeb2d000b2abcfbe90963b7003679ad59c81d.json b/.sqlx/query-9c6c55faa25dc4a9d891b701c4baeb2d000b2abcfbe90963b7003679ad59c81d.json new file mode 100644 index 0000000000..3270a3cdbd --- /dev/null +++ b/.sqlx/query-9c6c55faa25dc4a9d891b701c4baeb2d000b2abcfbe90963b7003679ad59c81d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9c6c55faa25dc4a9d891b701c4baeb2d000b2abcfbe90963b7003679ad59c81d" +} diff --git a/.sqlx/query-a34444c710b08be6562aa4eec94113b219405ee17b0f4f469802d687dd989ae6.json b/.sqlx/query-a34444c710b08be6562aa4eec94113b219405ee17b0f4f469802d687dd989ae6.json new file mode 100644 index 0000000000..703e2d5453 --- /dev/null +++ b/.sqlx/query-a34444c710b08be6562aa4eec94113b219405ee17b0f4f469802d687dd989ae6.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wnd.wireguard_network_id network_id, wnd.wireguard_ips \"device_wireguard_ips: Vec\", CASE WHEN NOT n.mfa_enabled THEN NULL::text ELSE active_session.preshared_key END \"preshared_key?\", CASE WHEN NOT n.mfa_enabled THEN TRUE ELSE active_session.preshared_key IS NOT NULL END \"is_authorized!\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, preshared_key FROM vpn_client_session WHERE location_id = wnd.wireguard_network_id AND device_id = wnd.device_id AND state IN ('new', 'connected') ORDER BY created_at DESC, id DESC LIMIT 1 ) active_session ON true WHERE wnd.device_id = $1 ORDER BY wnd.wireguard_network_id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "network_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "device_wireguard_ips: Vec", + "type_info": "InetArray" + }, + { + "ordinal": 2, + "name": "preshared_key?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_authorized!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + null, + null + ] + }, + "hash": "a34444c710b08be6562aa4eec94113b219405ee17b0f4f469802d687dd989ae6" +} diff --git a/.sqlx/query-daf745b8d55787b50a4f2eb74d6e5fcc3d7d87178bd44190b2e565f3017014db.json b/.sqlx/query-ac4753c24b643a11b7fb28bde7a06bc12c4093edf2a54f723140e6cd5e910ae4.json similarity index 80% rename from .sqlx/query-daf745b8d55787b50a4f2eb74d6e5fcc3d7d87178bd44190b2e565f3017014db.json rename to .sqlx/query-ac4753c24b643a11b7fb28bde7a06bc12c4093edf2a54f723140e6cd5e910ae4.json index a00d3ce43b..3c144d5264 100644 --- a/.sqlx/query-daf745b8d55787b50a4f2eb74d6e5fcc3d7d87178bd44190b2e565f3017014db.json +++ b/.sqlx/query-ac4753c24b643a11b7fb28bde7a06bc12c4093edf2a54f723140e6cd5e910ae4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT n.id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, 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\", service_location_mode \"service_location_mode: ServiceLocationMode\" FROM aclrulenetwork r JOIN wireguard_network n ON n.id = r.network_id WHERE r.rule_id = $1", + "query": "SELECT n.id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, allowed_ips, allow_all_groups, connected_at, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow, allowed_ips_from_acl, 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 WHERE r.rule_id = $1", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: LocationMfaMode", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled!: bool", + "type_info": "Bool" }, { "ordinal": 19, @@ -154,5 +143,5 @@ false ] }, - "hash": "daf745b8d55787b50a4f2eb74d6e5fcc3d7d87178bd44190b2e565f3017014db" + "hash": "ac4753c24b643a11b7fb28bde7a06bc12c4093edf2a54f723140e6cd5e910ae4" } diff --git a/.sqlx/query-b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb.json b/.sqlx/query-b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb.json deleted file mode 100644 index 1967b76ed5..0000000000 --- a/.sqlx/query-b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS( SELECT 1 FROM wireguard_network WHERE location_mfa_mode = 'internal'::location_mfa_mode ) \"exists!\"", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb" -} diff --git a/.sqlx/query-b309087ecb5dd4e470af791118c019889420712e6f479982040101d55fa96610.json b/.sqlx/query-b309087ecb5dd4e470af791118c019889420712e6f479982040101d55fa96610.json new file mode 100644 index 0000000000..8708153095 --- /dev/null +++ b/.sqlx/query-b309087ecb5dd4e470af791118c019889420712e6f479982040101d55fa96610.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM mfa_flow)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "b309087ecb5dd4e470af791118c019889420712e6f479982040101d55fa96610" +} diff --git a/.sqlx/query-bc760664c6a1f3f7324acf3d45deb9256373908ceeea0ed599fc3827733c9d5f.json b/.sqlx/query-bc760664c6a1f3f7324acf3d45deb9256373908ceeea0ed599fc3827733c9d5f.json new file mode 100644 index 0000000000..591dad2bab --- /dev/null +++ b/.sqlx/query-bc760664c6a1f3f7324acf3d45deb9256373908ceeea0ed599fc3827733c9d5f.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "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", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "step_count!: i64", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "group_names!: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "position", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "is_default", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + null, + null, + false, + false + ] + }, + "hash": "bc760664c6a1f3f7324acf3d45deb9256373908ceeea0ed599fc3827733c9d5f" +} diff --git a/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json b/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json new file mode 100644 index 0000000000..7777110f2e --- /dev/null +++ b/.sqlx/query-be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wn.mfa_enabled AS \"mfa_enabled!: bool\", mfs.methods AS \"methods?: Vec\" FROM wireguard_network wn LEFT JOIN location_mfa_flow lmf ON lmf.location_id = wn.id LEFT JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id WHERE wn.id = $1 ORDER BY lmf.position, mfs.position", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mfa_enabled!: bool", + "type_info": "Bool" + }, + { + "ordinal": 1, + "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 + ] + }, + "hash": "be5959bb82c34ffd3293b1168af62662fdf15fc1c809d039ea8adccad2c5c2a0" +} diff --git a/.sqlx/query-c4e584ef2c40a8d60c2e5278b1cf33bf8cb8b0dfdcd03bd419dea6f0a44a81ae.json b/.sqlx/query-c4e584ef2c40a8d60c2e5278b1cf33bf8cb8b0dfdcd03bd419dea6f0a44a81ae.json new file mode 100644 index 0000000000..cf16ab3d2b --- /dev/null +++ b/.sqlx/query-c4e584ef2c40a8d60c2e5278b1cf33bf8cb8b0dfdcd03bd419dea6f0a44a81ae.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT group_id FROM group_user WHERE user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + }, + "hash": "c4e584ef2c40a8d60c2e5278b1cf33bf8cb8b0dfdcd03bd419dea6f0a44a81ae" +} diff --git a/.sqlx/query-cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8.json b/.sqlx/query-cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8.json new file mode 100644 index 0000000000..642b178009 --- /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", + "Timestamp", + "Timestamp" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cbc5530fe713d2d1eb12dbf939248274929c4c3be8d9821de27beb4667fdb6c8" +} diff --git a/.sqlx/query-1d9d6fd32cc1ee120d797fb74e03d742d4904e0fc345431b60c68c47538ba12f.json b/.sqlx/query-cf273ace99bc20e1c44861c2b3a20c30a7b5ef226b9d64605ff6c2a037eff4db.json similarity index 66% rename from .sqlx/query-1d9d6fd32cc1ee120d797fb74e03d742d4904e0fc345431b60c68c47538ba12f.json rename to .sqlx/query-cf273ace99bc20e1c44861c2b3a20c30a7b5ef226b9d64605ff6c2a037eff4db.json index 1e1268b12d..cb7747e07a 100644 --- a/.sqlx/query-1d9d6fd32cc1ee120d797fb74e03d742d4904e0fc345431b60c68c47538ba12f.json +++ b/.sqlx/query-cf273ace99bc20e1c44861c2b3a20c30a7b5ef226b9d64605ff6c2a037eff4db.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"wireguard_network\" (\"name\",\"address\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\",\"service_location_mode\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING id", + "query": "INSERT INTO \"wireguard_network\" (\"name\",\"address\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"mfa_enabled\",\"service_location_mode\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING id", "describe": { "columns": [ { @@ -28,18 +28,7 @@ "Bool", "Int4", "Int4", - { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - }, + "Bool", { "Custom": { "name": "service_location_mode", @@ -58,5 +47,5 @@ false ] }, - "hash": "1d9d6fd32cc1ee120d797fb74e03d742d4904e0fc345431b60c68c47538ba12f" + "hash": "cf273ace99bc20e1c44861c2b3a20c30a7b5ef226b9d64605ff6c2a037eff4db" } diff --git a/.sqlx/query-d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348.json b/.sqlx/query-d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348.json new file mode 100644 index 0000000000..e5cd7ff9eb --- /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": "Timestamp" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d0e0506092df30559a7127af5df185a8570aa43a48028d54944beafcc18b5348" +} diff --git a/.sqlx/query-dce7dd1186cd6736e983cf554e5ef936385de201a551cb96be02ca0a12e2cebf.json b/.sqlx/query-dce7dd1186cd6736e983cf554e5ef936385de201a551cb96be02ca0a12e2cebf.json new file mode 100644 index 0000000000..0de7760b1a --- /dev/null +++ b/.sqlx/query-dce7dd1186cd6736e983cf554e5ef936385de201a551cb96be02ca0a12e2cebf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM location_mfa_flow WHERE location_id = $1 AND is_default = true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "dce7dd1186cd6736e983cf554e5ef936385de201a551cb96be02ca0a12e2cebf" +} diff --git a/.sqlx/query-df1152515dc822a2e9c3a391c6cb871f9f8717fccd197f7c0e5ae69f0e17c287.json b/.sqlx/query-df1152515dc822a2e9c3a391c6cb871f9f8717fccd197f7c0e5ae69f0e17c287.json new file mode 100644 index 0000000000..9aeefa3af5 --- /dev/null +++ b/.sqlx/query-df1152515dc822a2e9c3a391c6cb871f9f8717fccd197f7c0e5ae69f0e17c287.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM mfa_flow_step WHERE flow_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "df1152515dc822a2e9c3a391c6cb871f9f8717fccd197f7c0e5ae69f0e17c287" +} diff --git a/.sqlx/query-e20ef5d1dbe7d2aa1c5ae9c304ac179a81d123bcdf088c892b67c04f1939585a.json b/.sqlx/query-e20ef5d1dbe7d2aa1c5ae9c304ac179a81d123bcdf088c892b67c04f1939585a.json new file mode 100644 index 0000000000..0417b39391 --- /dev/null +++ b/.sqlx/query-e20ef5d1dbe7d2aa1c5ae9c304ac179a81d123bcdf088c892b67c04f1939585a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM mfa_flow WHERE id = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e20ef5d1dbe7d2aa1c5ae9c304ac179a81d123bcdf088c892b67c04f1939585a" +} diff --git a/.sqlx/query-e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab.json b/.sqlx/query-e7d64d8604c8ed3e0ab29fc56afc2af6b2da42bdc3dcb88406c96f40024c56ab.json new file mode 100644 index 0000000000..83904330b8 --- /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-1d8caa6cd7e086addb7c7ea3ededd0e22107416cf891ede02df654af3515e768.json b/.sqlx/query-f93f9d5983b4336e7395c1d2d752a1ae2fc196a49f0e12eda6907506b16d3ef4.json similarity index 84% rename from .sqlx/query-1d8caa6cd7e086addb7c7ea3ededd0e22107416cf891ede02df654af3515e768.json rename to .sqlx/query-f93f9d5983b4336e7395c1d2d752a1ae2fc196a49f0e12eda6907506b16d3ef4.json index 81c99466b1..1bcfc7962e 100644 --- a/.sqlx/query-1d8caa6cd7e086addb7c7ea3ededd0e22107416cf891ede02df654af3515e768.json +++ b/.sqlx/query-f93f9d5983b4336e7395c1d2d752a1ae2fc196a49f0e12eda6907506b16d3ef4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\" \"allowed_ips: _\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\" \"location_mfa_mode: _\",\"service_location_mode\" \"service_location_mode: _\" FROM \"wireguard_network\"", + "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"mtu\",\"fwmark\",\"allowed_ips\" \"allowed_ips: _\",\"allow_all_groups\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"allowed_ips_from_acl\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"mfa_enabled\",\"service_location_mode\" \"service_location_mode: _\" FROM \"wireguard_network\"", "describe": { "columns": [ { @@ -95,19 +95,8 @@ }, { "ordinal": 18, - "name": "location_mfa_mode: _", - "type_info": { - "Custom": { - "name": "location_mfa_mode", - "kind": { - "Enum": [ - "disabled", - "internal", - "external" - ] - } - } - } + "name": "mfa_enabled", + "type_info": "Bool" }, { "ordinal": 19, @@ -152,5 +141,5 @@ false ] }, - "hash": "1d8caa6cd7e086addb7c7ea3ededd0e22107416cf891ede02df654af3515e768" + "hash": "f93f9d5983b4336e7395c1d2d752a1ae2fc196a49f0e12eda6907506b16d3ef4" } diff --git a/.sqlx/query-fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd.json b/.sqlx/query-fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd.json new file mode 100644 index 0000000000..246affc00c --- /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", + "Timestamp", + "Timestamp" + ] + }, + "nullable": [] + }, + "hash": "fb155286c7f7c7b8fad9385078690d4ee41cee64088959625e31c0d532ae77cd" +} diff --git a/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json b/.sqlx/query-fd7f6f507a106fdd886d5bb174b174798bc280b6a3c2237d574dba90ecd3342c.json new file mode 100644 index 0000000000..ee7293c366 --- /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/.sqlx/query-fe664afdefa55838c0bb7c6cfcf0a6d1b28a95152fb1845a5ddf5b8abe6c6ba3.json b/.sqlx/query-fe664afdefa55838c0bb7c6cfcf0a6d1b28a95152fb1845a5ddf5b8abe6c6ba3.json new file mode 100644 index 0000000000..04abba55b3 --- /dev/null +++ b/.sqlx/query-fe664afdefa55838c0bb7c6cfcf0a6d1b28a95152fb1845a5ddf5b8abe6c6ba3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT mfa_enabled FROM wireguard_network WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mfa_enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fe664afdefa55838c0bb7c6cfcf0a6d1b28a95152fb1845a5ddf5b8abe6c6ba3" +} diff --git a/.sqlx/query-fedffe94b2f15ab529b09e1dcfc5ce4ab099eb7f223a6303c3f1247a2dd91e60.json b/.sqlx/query-fedffe94b2f15ab529b09e1dcfc5ce4ab099eb7f223a6303c3f1247a2dd91e60.json new file mode 100644 index 0000000000..ad12e54eb5 --- /dev/null +++ b/.sqlx/query-fedffe94b2f15ab529b09e1dcfc5ce4ab099eb7f223a6303c3f1247a2dd91e60.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM mfa_flow WHERE id = ANY($1) FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fedffe94b2f15ab529b09e1dcfc5ce4ab099eb7f223a6303c3f1247a2dd91e60" +} diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 9a56447a6f..7ab8934850 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -41,7 +41,14 @@ pub struct DeviceConfig { pub pubkey: String, pub dns: Option, pub keepalive_interval: i32, - pub location_mfa_mode: LocationMfaMode, + /// Whether the location requires MFA. This is the authoritative flag, read from the stored + /// `wireguard_network.mfa_enabled` column. + pub mfa_enabled: bool, + /// Legacy single-factor mode, derived in memory for backward-compatible locations only. + /// `None` when the location's flow configuration cannot be expressed as a legacy mode, which + /// includes every location that has no flows at all. Consumers deciding whether a location + /// requires MFA must use `mfa_enabled`, not the absence of this field. + pub location_mfa_mode: Option, pub service_location_mode: ServiceLocationMode, pub posture_check_required: bool, } @@ -179,11 +186,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 \ @@ -229,7 +236,7 @@ pub struct UserDeviceNetworkInfo { pub last_connected_ip: Option, pub last_connected_at: Option, pub is_active: bool, - pub location_mfa_mode: LocationMfaMode, + pub mfa_enabled: bool, } impl UserDevice { @@ -241,7 +248,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 +303,7 @@ impl UserDevice { last_connected_ip: device_ip, last_connected_at: r.last_connected_at, is_active, - location_mfa_mode: r.location_mfa_mode, + mfa_enabled: r.mfa_enabled, } }) .collect::>(); @@ -337,7 +344,7 @@ impl WireguardNetworkDevice { where E: PgExecutor<'e>, { - if !network.mfa_enabled() { + if !network.mfa_enabled { return Ok(None); } @@ -350,7 +357,7 @@ impl WireguardNetworkDevice { network: &WireguardNetwork, active_session: Option<&VpnClientSession>, ) -> DeviceNetworkInfo { - let (preshared_key, is_authorized) = if network.mfa_enabled() { + let (preshared_key, is_authorized) = if network.mfa_enabled { let preshared_key = active_session.and_then(|session| session.preshared_key.clone()); let is_authorized = preshared_key.is_some(); (preshared_key, is_authorized) @@ -1366,7 +1373,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1417,7 +1424,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1495,7 +1502,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1570,7 +1577,7 @@ mod test { false, false, false, - LocationMfaMode::Internal, + true, // mfa_enabled ServiceLocationMode::Disabled, ) .try_set_address("10.1.1.1/24") @@ -1651,7 +1658,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/mfa_flow.rs b/crates/defguard_common/src/db/models/mfa_flow.rs new file mode 100644 index 0000000000..9f0c235132 --- /dev/null +++ b/crates/defguard_common/src/db/models/mfa_flow.rs @@ -0,0 +1,897 @@ +use std::collections::HashSet; + +use chrono::{NaiveDateTime, 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, wireguard::LocationMfaMode}, +}; + +/// 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: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +/// 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: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +/// 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>, +} + +/// 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 assignment as recorded in the audit log, including the position the server derived +/// from the submitted order. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct LocationMfaFlowAssignmentSnapshot { + pub flow_id: Id, + pub position: i32, + pub is_default: bool, + pub group_ids: Vec, +} + +impl LocationMfaFlowAssignment { + /// Build the audit snapshot for an ordered assignment list, stamping each entry with the + /// position it was stored at. + #[must_use] + pub fn snapshot(assignments: &[Self]) -> Vec { + assignments + .iter() + .enumerate() + .map(|(i, a)| LocationMfaFlowAssignmentSnapshot { + flow_id: a.flow_id, + position: i as i32, + is_default: a.is_default, + group_ids: a.group_ids.clone(), + }) + .collect() + } +} + +/// 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("More than one MFA flow designated as the default for this location")] + MultipleDefaultsDesignated, + #[error("The default MFA flow assignment must not be scoped to any groups")] + DefaultHasGroups, + #[error("MFA flow {0} is a non-default assignment scoped to no groups")] + NonDefaultWithoutGroups(Id), + #[error("MFA flow {0} is assigned more than once to this location")] + DuplicateFlow(Id), + #[error("MFA flow {0} does not exist")] + UnknownFlow(Id), + #[error("Group {0} does not exist")] + UnknownGroup(Id), + #[error(transparent)] + Sqlx(#[from] sqlx::Error), +} + +/// Errors that can occur when updating an MFA flow. +#[derive(Debug, Error)] +pub enum MfaFlowUpdateError { + #[error("Step {0} does not belong to this MFA flow")] + UnknownStep(Id), + #[error(transparent)] + 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 { + pub field: String, + pub code: String, +} + +/// Maximum number of steps allowed in a single MFA flow. +pub const MAX_MFA_FLOW_STEPS: usize = 20; + +/// Maximum length of an MFA flow title. +pub const MAX_MFA_FLOW_TITLE_LEN: usize = 255; + +/// 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(), + }); + } else if title.len() > MAX_MFA_FLOW_TITLE_LEN { + errors.push(MfaFlowValidationField { + field: "title".into(), + code: "max_length".into(), + }); + } + + if step_methods.is_empty() { + errors.push(MfaFlowValidationField { + field: "steps".into(), + code: "min_items".into(), + }); + } else if step_methods.len() > MAX_MFA_FLOW_STEPS { + errors.push(MfaFlowValidationField { + field: "steps".into(), + code: "max_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 +} + +/// Offset applied to existing step positions during a swap so that +/// intermediate positions never conflict with the `UNIQUE (flow_id, position)` constraint. +pub const POSITION_SWAP_OFFSET: i32 = 10_000; + +/// Internal row type for the `resolve_for_user` query. +struct ResolveAssignmentRow { + flow_id: Id, + is_default: bool, + group_ids: Vec, +} + +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().naive_utc(); + 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 { + /// 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<()> { + // `updated_at` is bound from Rust rather than set with SQL `now()`: the column is + // `timestamp without time zone`, so `now()` would be cast using the session time + // zone, while inserts write `Utc::now().naive_utc()`. Binding keeps both UTC. + query!( + "UPDATE mfa_flow SET title = $1, updated_at = $2 WHERE id = $3", + title, + Utc::now().naive_utc(), + flow_id, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + + /// Returns whether at least one MFA flow exists. + /// + /// Used as the `mfa_enabled` precondition: a location cannot enable MFA until there is a + /// flow available to assign to it. + pub async fn any_exist<'e, E: PgExecutor<'e>>(executor: E) -> sqlx::Result { + let exists = query_scalar!("SELECT EXISTS (SELECT 1 FROM mfa_flow)") + .fetch_one(executor) + .await?; + Ok(exists.unwrap_or(false)) + } + + /// Returns whether the location has a designated default assignment. + /// + /// The `mfa_enabled` precondition uses this: a location cannot be enabled until it has a + /// default flow to enforce, so "enabled with no policy" is unrepresentable. + pub async fn has_default_assignment<'e, E: PgExecutor<'e>>( + executor: E, + location_id: Id, + ) -> sqlx::Result { + let exists = query_scalar!( + "SELECT EXISTS (SELECT 1 FROM location_mfa_flow WHERE location_id = $1 AND is_default = true)", + location_id, + ) + .fetch_one(executor) + .await?; + Ok(exists.unwrap_or(false)) + } + + /// 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 + } + + /// 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)>, + ) -> Result<(MfaFlow, Vec>), MfaFlowUpdateError> { + let incoming_ids: Vec = step_updates.iter().filter_map(|(id, _)| *id).collect(); + + // Every submitted step id must already belong to this flow. Without this check an id + // borrowed from another flow would be silently UPDATEd, rewriting that flow's step and + // reporting it in this flow's response. + if !incoming_ids.is_empty() { + let owned: HashSet = + query_scalar!("SELECT id FROM mfa_flow_step WHERE flow_id = $1", flow_id) + .fetch_all(&mut *conn) + .await? + .into_iter() + .collect(); + + if let Some(foreign) = incoming_ids.iter().find(|id| !owned.contains(id)) { + return Err(MfaFlowUpdateError::UnknownStep(*foreign)); + } + } + + Self::update_title(&mut *conn, flow_id, &title).await?; + + if incoming_ids.is_empty() { + MfaFlowStep::delete_by_flow(&mut *conn, flow_id).await?; + } else { + 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 { + MfaFlowStep::update_single(&mut *conn, flow_id, step_id, position, &methods) + .await?; + step_id + } else { + MfaFlowStep::insert_single(&mut *conn, flow_id, position, &methods).await? + }; + + 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)) + } + + /// Replaces all MFA flow assignments for a location. + pub async fn assign_to_location( + conn: &mut PgConnection, + location_id: Id, + assignments: &[LocationMfaFlowAssignment], + ) -> Result<(), MfaFlowAssignmentError> { + let mfa_enabled: bool = query_scalar!( + "SELECT mfa_enabled FROM wireguard_network WHERE id = $1", + location_id, + ) + .fetch_one(&mut *conn) + .await?; + + // Clearing a location's assignment list is only valid while MFA is off: an MFA-enabled + // location must always carry a default to enforce, but a disabled one has nothing to + // protect, and clearing is the only way to take it back from "has assignments" to "has + // none". An empty list has zero defaults, so without this early return it would be + // refused as `NoDefaultDesignated`. + if assignments.is_empty() && !mfa_enabled { + query!( + "DELETE FROM location_mfa_flow WHERE location_id = $1", + location_id, + ) + .execute(&mut *conn) + .await?; + return Ok(()); + } + + // Exactly one assignment must be flagged as the default, at every licence tier, and it is + // never inferred from position or from being the only entry. + let default_count = assignments.iter().filter(|a| a.is_default).count(); + match default_count { + 1 => {} + 0 => return Err(MfaFlowAssignmentError::NoDefaultDesignated), + _ => return Err(MfaFlowAssignmentError::MultipleDefaultsDesignated), + } + if let Some(default) = assignments.iter().find(|a| a.is_default) + && !default.group_ids.is_empty() + { + return Err(MfaFlowAssignmentError::DefaultHasGroups); + } + + // `location_mfa_flow` is keyed on (location_id, flow_id), so a repeated flow would raise a + // primary-key violation. Reject it as bad input instead of surfacing a 500. + let mut seen_flows = HashSet::new(); + for a in assignments { + if !seen_flows.insert(a.flow_id) { + return Err(MfaFlowAssignmentError::DuplicateFlow(a.flow_id)); + } + } + + // The mirror of the default rule: a non-default assignment scoped to no groups can never + // overlap any user, so it can never match and would be inert. Reject it rather than let an + // admin save an assignment that never fires. + if let Some(inert) = assignments + .iter() + .find(|a| !a.is_default && a.group_ids.is_empty()) + { + return Err(MfaFlowAssignmentError::NonDefaultWithoutGroups( + inert.flow_id, + )); + } + + // Referenced flows and groups must exist, otherwise the INSERTs below fail on a foreign + // key and the caller sees a 500 rather than a validation error. + // `FOR SHARE` holds the referenced flows against concurrent deletion for the rest of this + // transaction, and conversely blocks here while a delete of one of them is in flight. See + // the note on `check_deletable`. If that delete commits first, the row is gone by the time + // this query runs and the caller gets `UnknownFlow` rather than a foreign-key 500. + let flow_ids: Vec = assignments.iter().map(|a| a.flow_id).collect(); + let existing_flows: HashSet = query_scalar!( + "SELECT id FROM mfa_flow WHERE id = ANY($1) FOR SHARE", + &flow_ids + ) + .fetch_all(&mut *conn) + .await? + .into_iter() + .collect(); + if let Some(missing) = flow_ids.iter().find(|id| !existing_flows.contains(id)) { + return Err(MfaFlowAssignmentError::UnknownFlow(*missing)); + } + + let group_ids: Vec = assignments + .iter() + .flat_map(|a| a.group_ids.iter().copied()) + .collect(); + if !group_ids.is_empty() { + let existing_groups: HashSet = + query_scalar!("SELECT id FROM \"group\" WHERE id = ANY($1)", &group_ids) + .fetch_all(&mut *conn) + .await? + .into_iter() + .collect(); + if let Some(missing) = group_ids.iter().find(|id| !existing_groups.contains(id)) { + return Err(MfaFlowAssignmentError::UnknownGroup(*missing)); + } + } + + 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 + } + + /// Checks whether a flow can be deleted, returning an error naming the + /// affected locations if it cannot. + /// + /// [`MfaFlowDeleteError::LocationRequiresFlow`] is scoped to MFA-enabled locations: a flow + /// that is the only assignment for an MFA-disabled location can be deleted. + /// + /// Must be called on the same connection as the subsequent DELETE, and inside its + /// transaction: the checks below and the delete have to be atomic with respect to + /// [`Self::assign_to_location`], or a concurrent assignment could make this flow a location's + /// sole default in the window between checking and deleting, leaving that location with no + /// MFA policy. The `FOR UPDATE` below takes the flow-identity lock that + /// `assign_to_location` contends on with `FOR SHARE`. + pub async fn check_deletable( + conn: &mut PgConnection, + flow_id: Id, + ) -> Result<(), MfaFlowDeleteError> { + query_scalar!("SELECT id FROM mfa_flow WHERE id = $1 FOR UPDATE", flow_id) + .fetch_optional(&mut *conn) + .await?; + + // Flow is the only assignment for any MFA-enabled 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 wn.mfa_enabled = true \ + AND (SELECT COUNT(*) FROM location_mfa_flow \ + WHERE location_id = lmf.location_id) = 1", + flow_id + ) + .fetch_all(&mut *conn) + .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(&mut *conn) + .await?; + + if !defaults.is_empty() { + return Err(MfaFlowDeleteError::FlowIsDefault(defaults)); + } + + Ok(()) + } + + /// Resolves the MFA flow that applies to a user at a location. + /// + /// Ordered first-match over `location_mfa_flow.position`: the first assignment whose group set + /// intersects the user's groups wins, otherwise the assignment flagged `is_default` wins. + /// Because the default is mandatory and carries an empty group set, a location that has a + /// policy always resolves, so "user matches nothing" is unrepresentable. + /// + /// `None` is therefore not a resolution failure but an absence of policy, and callers must + /// **fail closed** on it rather than treat it as "no MFA required". It occurs in exactly two + /// cases: + /// + /// 1. The location has no assignments at all. This is legitimate and transient: a location can + /// be `mfa_enabled` before its policy has been built. + /// 2. The location has assignments but none is flagged default. The API makes this + /// unreachable, since [`Self::assign_to_location`] rejects it and [`Self::check_deletable`] + /// refuses to remove a default, so this arm only guards data predating those rules. + pub async fn resolve_for_user<'e>( + executor: impl PgExecutor<'e> + Copy, + location_id: Id, + user_id: Id, + ) -> sqlx::Result, Vec>)>> { + 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) + } + + /// Derives the legacy `LocationMfaMode` for a location. + /// + /// `mfa_enabled` is the authoritative flag and is checked first: when `false` the location + /// is MFA-off, which is legacy-representable as `Disabled`. When `true`, the function + /// inspects the flow configuration and returns the matching legacy mode when it is + /// backward-compatible (single-flow, single-step, full internal set or OIDC only). Returns + /// `None` when the location uses multi-flow, multi-step, or subset-of-internal-methods + /// configurations that legacy clients cannot represent. + /// + /// The invariant this guarantees: a location with `mfa_enabled = false` is never advertised + /// to any client as MFA-required. + pub async fn derive_legacy_mode<'e, E: PgExecutor<'e>>( + executor: E, + location_id: Id, + ) -> sqlx::Result> { + // Fetch mfa_enabled and step methods in one query so the executor is consumed only once. + struct DeriveRow { + mfa_enabled: bool, + methods: Option>, + } + + let rows = query_as!( + DeriveRow, + "SELECT wn.mfa_enabled AS \"mfa_enabled!: bool\", \ + mfs.methods AS \"methods?: Vec\" \ + FROM wireguard_network wn \ + LEFT JOIN location_mfa_flow lmf ON lmf.location_id = wn.id \ + LEFT JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id \ + WHERE wn.id = $1 \ + ORDER BY lmf.position, mfs.position", + location_id + ) + .fetch_all(executor) + .await?; + + if rows.is_empty() { + return Ok(None); + } + + // mfa_enabled is the authoritative flag. When false the location is MFA-off, which is + // legacy-representable as Disabled rather than omitted. + if !rows[0].mfa_enabled { + return Ok(Some(LocationMfaMode::Disabled)); + } + + // Collect step rows that actually have methods (NULL for locations with no flows). + let steps: Vec<&Vec> = + rows.iter().filter_map(|r| r.methods.as_ref()).collect(); + + if steps.len() != 1 { + return Ok(None); + } + + let methods = steps[0]; + 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) + } +} + +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( + 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 = Self::insert_single(&mut *conn, flow_id, i as i32, methods).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 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. + /// + /// Scoped by `flow_id` so a step id belonging to another flow can never be written through + /// this path, even if a caller skips the ownership check in `update_with_steps`. + pub async fn update_single( + conn: &mut PgConnection, + flow_id: Id, + 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 AND flow_id = $4", + position, + methods as &[VpnClientMfaMethod], + step_id, + flow_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) + .execute(&mut *conn) + .await?; + 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 0000000000..35ab865cca --- /dev/null +++ b/crates/defguard_common/src/db/models/mfa_flow/tests.rs @@ -0,0 +1,1893 @@ +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::*; +use crate::db::{ + models::{ + group::Group, + user::User, + wireguard::{LocationMfaMode, WireguardNetwork}, + }, + 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) +} + +/// Assign two group-scoped entries and one default in a single full-replace call, used by the +/// resolution-ordering tests. Each `(flow_id, group_id)` pair fixes one entry's scope. +async fn assign_three( + pool: &sqlx::PgPool, + location_id: Id, + first: (Id, Id), + second: (Id, Id), + default: Id, +) { + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut tx, + location_id, + &[ + LocationMfaFlowAssignment { + flow_id: first.0, + is_default: false, + group_ids: vec![first.1], + }, + LocationMfaFlowAssignment { + flow_id: second.0, + is_default: false, + group_ids: vec![second.1], + }, + LocationMfaFlowAssignment { + flow_id: default, + is_default: true, + group_ids: vec![], + }, + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +#[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 at position 2 (the flow already has steps at positions 0 and 1, so this + // must not collide with the `UNIQUE (flow_id, position)` constraint). + let mut tx = pool.begin().await.unwrap(); + MfaFlowStep::insert_single(&mut tx, flow.id, 2, &[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); +} + +/// A three-step reorder still satisfies `UNIQUE (flow_id, position)`: the offset-into-disjoint- +/// range reconciliation must never leave two steps sharing a position inside the transaction. +#[sqlx::test] +async fn test_position_reorder_three_steps(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (flow, steps) = MfaFlow::create( + &mut tx, + "Reorder".into(), + vec![ + vec![VpnClientMfaMethod::Totp], + vec![VpnClientMfaMethod::Email], + vec![VpnClientMfaMethod::Biometric], + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + assert_eq!(steps.len(), 3); + + // Reverse the order. + let mut tx = pool.begin().await.unwrap(); + let (_, updated) = MfaFlow::update_with_steps( + &mut tx, + flow.id, + "Reorder".into(), + vec![ + (Some(steps[2].id), steps[2].methods.clone()), + (Some(steps[1].id), steps[1].methods.clone()), + (Some(steps[0].id), steps[0].methods.clone()), + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert_eq!(updated[0].id, steps[2].id); + assert_eq!(updated[0].position, 0); + assert_eq!(updated[1].id, steps[1].id); + assert_eq!(updated[1].position, 1); + assert_eq!(updated[2].id, steps[0].id); + assert_eq!(updated[2].position, 2); +} + +#[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(); + let group = Group::new("assign-group").save(&pool).await.unwrap(); + + // Assign two flows to the location: the non-default flow is group-scoped, the default is not. + 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 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(), 1); + 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_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) + )); +} + +/// Two assignments both flagged default is a distinct failure from none being flagged, so it must +/// not be reported as `no_default_designated`. +#[sqlx::test] +async fn test_assign_multiple_defaults_rejected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.7.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![], + }, + LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: true, + group_ids: vec![], + }, + ], + ) + .await; + assert!(matches!( + result, + Err(MfaFlowAssignmentError::MultipleDefaultsDesignated) + )); + + // The rejected save must not have partially applied. + let assignments = MfaFlow::for_location(&pool, network.id).await.unwrap(); + assert!(assignments.is_empty()); +} + +#[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::DefaultHasGroups) + )); +} + +/// An MFA-disabled location can have its assignment list cleared: there is nothing to enforce, so +/// an empty list is a valid (re)configuration rather than a missing default. +#[sqlx::test] +async fn test_assign_clear_disabled_location(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.9.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + // network.mfa_enabled is false (default). + + 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(); + MfaFlow::assign_to_location(&mut tx, network.id, &[]) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let items = MfaFlow::for_location(&pool, network.id).await.unwrap(); + assert!(items.is_empty(), "clearing must remove all assignments"); +} + +/// Clearing an MFA-enabled location's assignment list is still refused: such a location must keep +/// something to enforce. +#[sqlx::test] +async fn test_assign_clear_enabled_location_rejected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut network = WireguardNetwork::default() + .try_set_address("10.0.10.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.save(&pool).await.unwrap(); + + let result = + MfaFlow::assign_to_location(&mut pool.acquire().await.unwrap(), network.id, &[]).await; + assert!(matches!( + result, + Err(MfaFlowAssignmentError::NoDefaultDesignated) + )); +} + +/// A non-default assignment scoped to no groups can never match any user, so it is refused rather +/// than saved as an assignment that never fires. This is the mirror of `DefaultHasGroups`. +#[sqlx::test] +async fn test_assign_non_default_without_groups_rejected( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (flow1, _) = create_flow(&pool).await; + let (flow2, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.11.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![], // inert: empty group set + }, + LocationMfaFlowAssignment { + flow_id: flow2.id, + is_default: true, + group_ids: vec![], + }, + ], + ) + .await; + assert!(matches!( + result, + Err(MfaFlowAssignmentError::NonDefaultWithoutGroups(id)) if id == flow1.id + )); + + // The rejected save must not have partially applied. + let assignments = MfaFlow::for_location(&pool, network.id).await.unwrap(); + assert!(assignments.is_empty()); +} + +/// `has_default_assignment` reflects the presence of a designated default, which is what the +/// `mfa_enabled` precondition keys on. +#[sqlx::test] +async fn test_has_default_assignment(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.12.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + assert!( + !MfaFlow::has_default_assignment(&pool, network.id) + .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(); + + assert!( + MfaFlow::has_default_assignment(&pool, network.id) + .await + .unwrap() + ); + + // Clearing (an MFA-disabled location) removes the default. + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location(&mut tx, network.id, &[]) + .await + .unwrap(); + tx.commit().await.unwrap(); + + assert!( + !MfaFlow::has_default_assignment(&pool, network.id) + .await + .unwrap() + ); +} + +#[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 mut network = WireguardNetwork::default() + .try_set_address("10.0.4.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + // location_requires_flow is scoped to MFA-enabled locations. + network.mfa_enabled = true; + network.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(&mut pool.acquire().await.unwrap(), flow1.id).await; + assert!(matches!( + result, + Err(MfaFlowDeleteError::LocationRequiresFlow(_)) + )); +} + +/// When MFA is disabled at a location, deleting the location's only assigned, +/// non-default flow is allowed because `LocationRequiresFlow` is scoped to +/// MFA-enabled locations. +#[sqlx::test] +async fn test_check_deletable_allows_disabled_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, + "Default".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let network = WireguardNetwork::default() + .try_set_address("10.0.4.2/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + // network.mfa_enabled is false (default). + let group = Group::new("disabled-group").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(); + + // flow1 is the only non-default assignment for an MFA-disabled location. + let result = MfaFlow::check_deletable(&mut pool.acquire().await.unwrap(), flow1.id).await; + assert!( + result.is_ok(), + "deletion should be allowed from an MFA-disabled location: {result:?}" + ); +} + +#[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 group = Group::new("default-group").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![group.id], + }, + ], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // flow2 is not the only assignment and not default → OK + assert!( + MfaFlow::check_deletable(&mut pool.acquire().await.unwrap(), flow2.id) + .await + .is_ok() + ); + // flow1 is the default → refused + let result = MfaFlow::check_deletable(&mut pool.acquire().await.unwrap(), flow1.id).await; + assert!(matches!(result, Err(MfaFlowDeleteError::FlowIsDefault(_)))); +} + +/// A delete that passes its checks must not race an assignment that makes the same flow a +/// location's sole default. The two paths contend on the flow row (`FOR UPDATE` against +/// `FOR SHARE`), so the assignment cannot commit inside the delete's check-to-delete window. +#[sqlx::test] +async fn test_delete_and_assign_do_not_race(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (flow, _) = create_flow(&pool).await; + + let network = WireguardNetwork::default() + .try_set_address("10.0.8.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + // Transaction A: the flow is assigned nowhere, so the delete is currently allowed. + let mut tx_delete = pool.begin().await.unwrap(); + MfaFlow::check_deletable(&mut tx_delete, flow.id) + .await + .expect("flow is unassigned, so deletion is permitted"); + + // Transaction B tries to make it a location's default while A still holds the lock. It must + // block rather than commit, so a short timeout has to elapse. + let assign_pool = pool.clone(); + let flow_id = flow.id; + let location_id = network.id; + let mut assign = tokio::spawn(async move { + let mut tx = assign_pool.begin().await.unwrap(); + let result = MfaFlow::assign_to_location( + &mut tx, + location_id, + &[LocationMfaFlowAssignment { + flow_id, + is_default: true, + group_ids: vec![], + }], + ) + .await; + if result.is_ok() { + tx.commit().await.unwrap(); + } + result + }); + + let blocked = tokio::time::timeout(std::time::Duration::from_millis(500), &mut assign).await; + assert!( + blocked.is_err(), + "the assignment must block until the delete transaction finishes" + ); + + // A completes the delete it was cleared for. + query!("DELETE FROM mfa_flow WHERE id = $1", flow.id) + .execute(&mut *tx_delete) + .await + .unwrap(); + tx_delete.commit().await.unwrap(); + + // B unblocks, sees the flow is gone, and refuses instead of violating the foreign key. + let result = assign.await.unwrap(); + assert!(matches!( + result, + Err(MfaFlowAssignmentError::UnknownFlow(id)) if id == flow_id + )); + + // The location was left with no assignments rather than a dangling default. + let items = MfaFlow::for_location(&pool, network.id).await.unwrap(); + assert!(items.is_empty()); +} + +#[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); +} + +/// Ordered first-match: when a user matches two group-scoped assignments, the one at the lower +/// `position` wins. The test runs resolution in both orderings and asserts the resolved flow +/// flips, so a single ordering cannot pass by accident. +#[sqlx::test] +async fn test_resolve_order_decides_between_matches(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (flow_first, _) = create_flow(&pool).await; // TOTP -> Email + let (flow_second, _) = { + 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 (flow_default, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut tx, + "Default".into(), + vec![vec![VpnClientMfaMethod::Biometric]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let user = User::new("order-resolver", None, "Ln", "Fn", "o@t.com", None) + .save(&pool) + .await + .unwrap(); + let group_a = Group::new("order-a").save(&pool).await.unwrap(); + let group_b = Group::new("order-b").save(&pool).await.unwrap(); + for gid in [group_a.id, group_b.id] { + sqlx::query!( + "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2)", + gid, + user.id, + ) + .execute(&pool) + .await + .unwrap(); + } + + let network = WireguardNetwork::default() + .try_set_address("10.3.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + // flow_first (scoped to group_a) at position 0, flow_second (group_b) at position 1. + assign_three( + &pool, + network.id, + (flow_first.id, group_a.id), + (flow_second.id, group_b.id), + flow_default.id, + ) + .await; + let resolved = MfaFlow::resolve_for_user(&pool, network.id, user.id) + .await + .unwrap() + .unwrap(); + assert_eq!(resolved.0.id, flow_first.id, "lower position must win"); + + // Swap the positions: the resolved flow must flip to the new lower-position entry. + assign_three( + &pool, + network.id, + (flow_second.id, group_b.id), + (flow_first.id, group_a.id), + flow_default.id, + ) + .await; + let resolved = MfaFlow::resolve_for_user(&pool, network.id, user.id) + .await + .unwrap() + .unwrap(); + assert_eq!(resolved.0.id, flow_second.id, "swapped position must win"); +} + +/// A user in two groups that each match a different assignment resolves to the same flow on every +/// call: ordered first-match is deterministic. +#[sqlx::test] +async fn test_resolve_user_in_two_groups_deterministic( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (flow_a, _) = create_flow(&pool).await; + let (flow_b, _) = { + 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 (flow_default, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut tx, + "Default".into(), + vec![vec![VpnClientMfaMethod::Biometric]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let user = User::new("two-group-resolver", None, "Ln", "Fn", "t@t.com", None) + .save(&pool) + .await + .unwrap(); + let group_a = Group::new("two-group-a").save(&pool).await.unwrap(); + let group_b = Group::new("two-group-b").save(&pool).await.unwrap(); + for gid in [group_a.id, group_b.id] { + sqlx::query!( + "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2)", + gid, + user.id, + ) + .execute(&pool) + .await + .unwrap(); + } + + let network = WireguardNetwork::default() + .try_set_address("10.3.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + assign_three( + &pool, + network.id, + (flow_a.id, group_a.id), + (flow_b.id, group_b.id), + flow_default.id, + ) + .await; + + for _ in 0..5 { + let resolved = MfaFlow::resolve_for_user(&pool, network.id, user.id) + .await + .unwrap() + .unwrap(); + assert_eq!(resolved.0.id, flow_a.id, "resolution must be stable"); + } +} + +/// `position`, not `mfa_flow.id` or creation order, decides. `flow_low_id` is created first (so it +/// has the lower id) but is assigned the higher position; `flow_high_id` is created second yet +/// wins because it holds the lower position. A stray `ORDER BY mf.id` would resolve to +/// `flow_low_id`, so this test catches it. +#[sqlx::test] +async fn test_resolve_position_not_id_order(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // Created first: lower id, but assigned the higher position. + let (flow_low_id, _) = create_flow(&pool).await; + // Created second: higher id, assigned the lower position. + let (flow_high_id, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut tx, + "Higher Id".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + let (flow_default, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create( + &mut tx, + "Default".into(), + vec![vec![VpnClientMfaMethod::Biometric]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + assert!( + flow_low_id.id < flow_high_id.id, + "precondition: ids follow creation order" + ); + + let user = User::new("position-resolver", None, "Ln", "Fn", "p@t.com", None) + .save(&pool) + .await + .unwrap(); + let group_a = Group::new("position-a").save(&pool).await.unwrap(); + let group_b = Group::new("position-b").save(&pool).await.unwrap(); + for gid in [group_a.id, group_b.id] { + sqlx::query!( + "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2)", + gid, + user.id, + ) + .execute(&pool) + .await + .unwrap(); + } + + let network = WireguardNetwork::default() + .try_set_address("10.3.2.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + // Higher-id flow at position 0, lower-id flow at position 1. + assign_three( + &pool, + network.id, + (flow_high_id.id, group_a.id), + (flow_low_id.id, group_b.id), + flow_default.id, + ) + .await; + + let resolved = MfaFlow::resolve_for_user(&pool, network.id, user.id) + .await + .unwrap() + .unwrap(); + assert_eq!(resolved.0.id, flow_high_id.id, "position must outrank 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 mut network = WireguardNetwork::default() + .try_set_address("10.1.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.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 mut network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.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 mut network = WireguardNetwork::default() + .try_set_address("10.1.2.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.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 mut network = WireguardNetwork::default() + .try_set_address("10.1.3.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.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); +} + +/// A location with two flows assigned is not legacy-representable: the legacy mode collapses to a +/// single flow, so `derive_legacy_mode` must omit it even though each flow is individually +/// single-step and full-internal. This is the fourth incompatible shape the ADR lists. +#[sqlx::test] +async fn test_derive_legacy_multi_flow_omitted(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let full_internal = vec![vec![ + VpnClientMfaMethod::Totp, + VpnClientMfaMethod::Email, + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove, + ]]; + + let (flow_a, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create(&mut tx, "Flow A".into(), full_internal.clone()) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + let (flow_b, _) = { + let mut tx = pool.begin().await.unwrap(); + let (f, s) = MfaFlow::create(&mut tx, "Flow B".into(), full_internal) + .await + .unwrap(); + tx.commit().await.unwrap(); + (f, s) + }; + + let mut network = WireguardNetwork::default() + .try_set_address("10.1.4.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.save(&pool).await.unwrap(); + + let group = Group::new("multi-flow-group").save(&pool).await.unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut tx, + network.id, + &[ + LocationMfaFlowAssignment { + flow_id: flow_a.id, + is_default: false, + group_ids: vec![group.id], + }, + LocationMfaFlowAssignment { + flow_id: flow_b.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); +} + +/// A location with mfa_enabled = false returns Disabled even when it has +/// flow assignments, because the stored flag is authoritative and the +/// location must never be advertised as MFA-required. +#[sqlx::test] +async fn test_derive_legacy_disabled_with_assignments(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (flow, _) = MfaFlow::create( + &mut tx, + "Internal Recipe".into(), + vec![vec![ + VpnClientMfaMethod::Totp, + VpnClientMfaMethod::Email, + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove, + ]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // network.mfa_enabled is false (default), but the flow is assigned. + let network = WireguardNetwork::default() + .try_set_address("10.2.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::Disabled), + "mfa_enabled=false must derive Disabled to never advertise MFA-required" + ); +} + +#[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") + ); +} + +#[sqlx::test] +async fn test_validation_title_too_long(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let long_title = "x".repeat(MAX_MFA_FLOW_TITLE_LEN + 1); + let errors = validate_flow_input(&long_title, &[vec![VpnClientMfaMethod::Totp]]); + assert!( + errors + .iter() + .any(|e| e.field == "title" && e.code == "max_length"), + "expected max_length error for overly long title, got: {errors:?}" + ); +} + +#[sqlx::test] +async fn test_validation_title_at_max_is_ok(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let max_title = "x".repeat(MAX_MFA_FLOW_TITLE_LEN); + let errors = validate_flow_input(&max_title, &[vec![VpnClientMfaMethod::Totp]]); + assert!( + !errors + .iter() + .any(|e| e.field == "title" && e.code == "max_length"), + "title at max length should pass, got: {errors:?}" + ); +} + +#[sqlx::test] +async fn test_validation_too_many_steps(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let _pool = pool; + let many_steps: Vec> = (0..=MAX_MFA_FLOW_STEPS) + .map(|_| vec![VpnClientMfaMethod::Totp]) + .collect(); + let errors = validate_flow_input("Test", &many_steps); + assert!( + errors + .iter() + .any(|e| e.field == "steps" && e.code == "max_items"), + "expected max_items error for too many steps, got: {errors:?}" + ); +} + +/// `all_with_oidc_in_flows` must select locations whose assigned flow steps +/// include OIDC, and exclude locations that use internal-only flows. +#[sqlx::test] +async fn test_all_with_oidc_in_flows_flow_shape_predicate( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + // Create an OIDC-only flow. + let mut tx = pool.begin().await.unwrap(); + let (oidc_flow, _) = MfaFlow::create( + &mut tx, + "OIDC Only".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Create an internal-only flow. + let mut tx = pool.begin().await.unwrap(); + let (internal_flow, _) = MfaFlow::create( + &mut tx, + "Internal Only".into(), + vec![vec![VpnClientMfaMethod::Totp, VpnClientMfaMethod::Email]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Location A: OIDC flow, MFA enabled. + let mut network_oidc = WireguardNetwork::default() + .try_set_address("10.20.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network_oidc.mfa_enabled = true; + network_oidc.save(&pool).await.unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut tx, + network_oidc.id, + &[LocationMfaFlowAssignment { + flow_id: oidc_flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Location B: internal flow, MFA enabled. + let mut network_internal = WireguardNetwork::default() + .try_set_address("10.20.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network_internal.mfa_enabled = true; + network_internal.save(&pool).await.unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut tx, + network_internal.id, + &[LocationMfaFlowAssignment { + flow_id: internal_flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let external = WireguardNetwork::all_with_oidc_in_flows(&pool) + .await + .unwrap(); + let external_ids: Vec<_> = external.iter().map(|l| l.id).collect(); + assert!( + external_ids.contains(&network_oidc.id), + "OIDC-flow location {oidc_id} should be in results, got: {external_ids:?}", + oidc_id = network_oidc.id + ); + assert!( + !external_ids.contains(&network_internal.id), + "internal-only location {internal_id} must not be in results", + internal_id = network_internal.id + ); +} + +/// An MFA-disabled location whose flows reference OIDC must still be returned. +/// +/// This pins a deliberate decision. Adding `WHERE wn.mfa_enabled = true` to the predicate would +/// look like a consistency fix against `instance_has_internal_mfa`, but it would silently drop this +/// case from the provider-deletion warning. Switching MFA off preserves the assignment list, and +/// re-enabling checks only that a default assignment exists, never that its methods are +/// satisfiable, so such a location is one call away from refusing every user at connect time. +#[sqlx::test] +async fn test_oidc_predicate_includes_mfa_disabled_location( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (oidc_flow, _) = MfaFlow::create( + &mut tx, + "OIDC Only".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // MFA is off, but the OIDC assignment survives, exactly as it does after an admin toggles MFA + // off on a previously OIDC-protected location. + let network = WireguardNetwork::default() + .try_set_address("10.21.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + assert!( + !network.mfa_enabled, + "precondition: this location must have MFA disabled" + ); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: oidc_flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let affected = WireguardNetwork::all_with_oidc_in_flows(&pool) + .await + .unwrap(); + let affected_ids: Vec<_> = affected.iter().map(|l| l.id).collect(); + assert!( + affected_ids.contains(&network.id), + "MFA-disabled location {id} with an OIDC flow must still be reported, got: {affected_ids:?}", + id = network.id + ); +} + +/// `all_with_oidc_in_flows` must return an empty set when no location's flows +/// contain OIDC, even when MFA is enabled on some locations. +#[sqlx::test] +async fn test_all_with_oidc_in_flows_empty_when_no_oidc( + _: 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]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mut network = WireguardNetwork::default() + .try_set_address("10.20.2.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.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 external = WireguardNetwork::all_with_oidc_in_flows(&pool) + .await + .unwrap(); + assert!( + external.is_empty(), + "should return empty when no location has OIDC in its flows, got {} locations", + external.len() + ); +} + +/// Query checking for internal MFA should return false when every MFA location +/// uses only OIDC flows. +#[sqlx::test] +async fn test_internal_mfa_query_false_for_oidc_only(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let mut tx = pool.begin().await.unwrap(); + let (oidc_flow, _) = MfaFlow::create( + &mut tx, + "OIDC Only".into(), + vec![vec![VpnClientMfaMethod::Oidc]], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mut network = WireguardNetwork::default() + .try_set_address("10.30.0.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + network.mfa_enabled = true; + network.save(&pool).await.unwrap(); + + let mut tx = pool.begin().await.unwrap(); + MfaFlow::assign_to_location( + &mut tx, + network.id, + &[LocationMfaFlowAssignment { + flow_id: oidc_flow.id, + is_default: true, + group_ids: vec![], + }], + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // The same flow-shape query the enrollment server uses: any MFA-enabled + // location whose flows include an internal method. + let has_internal = sqlx::query_scalar!( + "SELECT EXISTS( \ + SELECT 1 FROM wireguard_network wn \ + JOIN location_mfa_flow lmf ON lmf.location_id = wn.id \ + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id \ + WHERE wn.mfa_enabled = true \ + AND mfs.methods && ARRAY['totp','email','biometric','mobileapprove']::vpn_client_mfa_method[] \ + ) \"exists!\"" + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + !has_internal, + "OIDC-only location must not trigger the internal MFA check" + ); +} diff --git a/crates/defguard_common/src/db/models/mod.rs b/crates/defguard_common/src/db/models/mod.rs index e6de50e7b7..2fb6388152 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; @@ -34,6 +35,7 @@ pub use device::{Device, DeviceConfig, DeviceError, DeviceNetworkInfo, DeviceTyp pub use device_login::DeviceLoginEvent; pub use error::ModelError; pub use initial_setup_wizard::{InitialSetupState, InitialSetupStep}; +pub use mfa_flow::MfaFlow; pub use mfa_info::MFAInfo; pub use oauth2authorizedapp::OAuth2AuthorizedApp; pub use oauth2token::OAuth2Token; 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 6ca6dcc886..ef9d47e8d7 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, diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index e08c920f2d..e80575a989 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -127,8 +127,7 @@ pub struct WireguardNetwork { pub allowed_ips_from_acl: bool, 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 +168,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 +224,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 +251,7 @@ impl WireguardNetwork { acl_enabled, acl_default_allow, allowed_ips_from_acl, - location_mfa_mode, + mfa_enabled, service_location_mode, } } @@ -352,7 +350,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 +379,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 +406,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 +429,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 \ @@ -1315,28 +1313,40 @@ impl WireguardNetwork { Ok(()) } - #[must_use] - pub fn mfa_enabled(&self) -> bool { - match self.location_mfa_mode { - LocationMfaMode::Internal | LocationMfaMode::External => true, - LocationMfaMode::Disabled => false, - } - } - - /// Fetch all locations using external MFA. - pub async fn all_using_external_mfa<'e, E>(executor: E) -> sqlx::Result> + /// Fetch all locations whose assigned flow steps include the OIDC method, whether or not MFA + /// is currently enabled on them. + /// + /// This replaces a pre-flow-model query that selected on the legacy + /// `location_mfa_mode = 'external'` column. The boolean `mfa_enabled` cannot + /// distinguish external (OIDC) from internal MFA, so the predicate inspects + /// the actual flow shape by joining through `location_mfa_flow` and + /// `mfa_flow_step`. + /// + /// **Deliberately not filtered on `mfa_enabled`**, unlike `instance_has_internal_mfa` in + /// `defguard_proxy_manager`. The sole caller warns an admin which locations are left with + /// unsatisfiable flows after an OIDC provider is deleted, and an MFA-disabled location still + /// belongs in that warning: switching MFA off preserves the assignment list, and re-enabling it + /// checks only that a default assignment exists, never that the flow's methods are satisfiable. + /// Such a location is one call away from refusing every user at connect time. Adding the filter + /// would silently drop that case, so `test_oidc_predicate_includes_mfa_disabled_location` + /// pins the behaviour. + pub async fn all_with_oidc_in_flows<'e, E>(executor: E) -> sqlx::Result> where E: PgExecutor<'e>, { let locations = query_as!( WireguardNetwork, - "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, mtu, fwmark, \ - 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\", \ - service_location_mode \"service_location_mode: ServiceLocationMode\" \ - FROM wireguard_network WHERE location_mfa_mode = 'external'::location_mfa_mode", + "SELECT DISTINCT wn.id, wn.name, wn.address, wn.port, wn.pubkey, wn.prvkey, \ + wn.endpoint, wn.dns, wn.mtu, wn.fwmark, \ + wn.allowed_ips, wn.allow_all_groups, wn.connected_at, wn.keepalive_interval, \ + wn.peer_disconnect_threshold, wn.acl_enabled, wn.acl_default_allow, \ + wn.allowed_ips_from_acl, \ + wn.mfa_enabled \"mfa_enabled!: bool\", \ + wn.service_location_mode \"service_location_mode: ServiceLocationMode\" \ + FROM wireguard_network wn \ + JOIN location_mfa_flow lmf ON lmf.location_id = wn.id \ + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id \ + WHERE 'oidc' = ANY(mfs.methods)", ) .fetch_all(executor) .await?; @@ -1546,7 +1556,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 ddd9fad5d6..3244efd0d5 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 8c98a147dd..03e784ab36 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/db/models/activity_log/mod.rs b/crates/defguard_core/src/db/models/activity_log/mod.rs index 0132c728ec..5ac452fd02 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,11 @@ pub enum EventType { DevicePostureDuplicated, DevicePostureLocationsAssigned, LocationPosturesAssigned, + // MFA flow management + MfaFlowCreated, + MfaFlowUpdated, + MfaFlowDeleted, + LocationMfaFlowsAssigned, DevicePostureCheckPassed, DevicePostureCheckFailed, // LDAP sync events diff --git a/crates/defguard_core/src/device_access/mod.rs b/crates/defguard_core/src/device_access/mod.rs index 6eb453a233..d166926da8 100644 --- a/crates/defguard_core/src/device_access/mod.rs +++ b/crates/defguard_core/src/device_access/mod.rs @@ -9,6 +9,7 @@ use defguard_common::{ models::{ Device, DeviceConfig, DeviceError, WireguardNetwork, device::{DeviceNetworkInfo, WireguardNetworkDevice}, + mfa_flow::MfaFlow, user::User, wireguard::WireguardNetworkError, }, @@ -37,7 +38,14 @@ 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()))?; + + // `None` when the location's flow configuration has no legacy equivalent. Carried through as + // absent rather than coerced to `Disabled`, which would advertise an MFA-enabled location as + // unprotected. Gating such locations for legacy clients is tracked separately (#3042). + let location_mfa_mode = MfaFlow::derive_legacy_mode(&mut *conn, network.id) + .await + .map_err(|err| DeviceError::Unexpected(err.to_string()))?; Ok(DeviceConfig { network_id: network.id, @@ -49,7 +57,8 @@ 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(), + mfa_enabled: network.mfa_enabled, + location_mfa_mode, service_location_mode: network.service_location_mode.clone(), posture_check_required: has_postures, }) 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 15fce447ea..bb84e132c4 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 4f7683b050..ea865ed8aa 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 116030ff31..b2797486b6 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; @@ -262,12 +261,16 @@ pub(crate) async fn get_openid_provider( } /// Delete an OpenID provider +/// +/// Deletion always proceeds. Any location whose assigned MFA flows still reference OIDC is +/// returned in `affected_locations`: those flows become unsatisfiable, so their users cannot +/// complete MFA until an admin edits them. Callers should surface this as a warning. #[utoipa::path( delete, path = "/api/v1/openid/provider/{name}", tag = "OpenID", responses( - (status = 200, description = "OpenID provider deleted."), + (status = 200, description = "OpenID provider deleted. `affected_locations` names any location whose MFA flows still reference OIDC and now cannot complete MFA.", body = Object, example = json!({"affected_locations": ["Warsaw office"]})), (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), (status = 403, description = "Requires admin privileges and an active enterprise license.", body = ApiErrorResponse, example = json!({"msg": "requires privileged access"})), (status = 404, description = "OpenID provider not found."), @@ -296,31 +299,50 @@ pub(crate) async fn delete_openid_provider( let mut transaction = appstate.pool.begin().await?; let provider = OpenIdProvider::find_by_name(&mut *transaction, &name).await?; if let Some(provider) = provider { + // Deleting the provider leaves any flow step referencing OIDC unsatisfiable. Deletion is + // still allowed to proceed, because removing a provider is frequently incident response + // (a compromised or decommissioned IdP) and blocking it would keep that provider live for + // SSO during exactly the window that matters most. The two rejected alternatives were + // refusing the delete, which impedes revocation, and rewriting the affected flows, which + // is unsafe because `location_mfa_flow` is many-to-many so one flow can serve several + // locations and rewriting it would silently change policy for all of them. + // + // Access still fails closed: an unsatisfiable step makes connect-time MFA return + // `failed_precondition`, so affected users are refused rather than let through. The + // affected locations are returned to the caller so an admin can be warned and repair the + // flows. The set is deliberately not filtered on `mfa_enabled`: a location with MFA + // currently switched off would hit the same problem the moment it is re-enabled, so it + // belongs in the warning. + let affected = WireguardNetwork::all_with_oidc_in_flows(&mut *transaction).await?; + let affected_locations: Vec = affected.iter().map(|l| l.name.clone()).collect(); + provider.clone().delete(&mut *transaction).await?; - // fetch all locations using external MFA - let locations = WireguardNetwork::all_using_external_mfa(&mut *transaction).await?; - if locations.is_empty() { - debug!("No locations are using OIDC provider for external MFA"); - } - // fall back to internal MFA in all relevant locations - for mut location in locations { - debug!( - "Falling back to internal MFA for {location} because exteral OIDC provider has \ - been removed" + transaction.commit().await?; + + if affected_locations.is_empty() { + info!( + "User {} deleted OpenID provider {}", + session.user.username, provider.name + ); + } else { + warn!( + "User {} deleted OpenID provider {}. {} location(s) still reference OIDC in their \ + MFA flows and their users cannot complete MFA until those flows are edited: {}", + session.user.username, + provider.name, + affected_locations.len(), + affected_locations.join(", "), ); - location.location_mfa_mode = LocationMfaMode::Internal; - location.save(&mut *transaction).await?; } - transaction.commit().await?; - info!( - "User {} deleted OpenID provider {}", - session.user.username, provider.name - ); + appstate.emit_event(ApiEvent { context, event: Box::new(ApiEventType::OpenIdProviderRemoved { provider }), })?; - Ok(ApiResponse::with_status(StatusCode::OK)) + Ok(ApiResponse::new( + json!({ "affected_locations": affected_locations }), + StatusCode::OK, + )) } else { warn!( "User {} failed to delete OpenID provider {name}. Such provider does not exist.", diff --git a/crates/defguard_core/src/enterprise/posture/tests.rs b/crates/defguard_core/src/enterprise/posture/tests.rs index d7bb5023d3..2a347aa342 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/events.rs b/crates/defguard_core/src/events.rs index 776bcb033e..625a3f00fd 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -5,7 +5,11 @@ 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::{LocationMfaFlowAssignmentSnapshot, MfaFlowSnapshot}, + oauth2client::OAuth2Client, + proxy::Proxy, }, }; use defguard_proto::{client_types::MfaMethod, enterprise::posture::DevicePostureData}; @@ -364,6 +368,21 @@ pub enum ApiEventType { location: WireguardNetwork, posture_ids: Vec, }, + MfaFlowCreated { + snapshot: MfaFlowSnapshot, + }, + MfaFlowUpdated { + before: MfaFlowSnapshot, + after: MfaFlowSnapshot, + }, + MfaFlowDeleted { + snapshot: MfaFlowSnapshot, + }, + LocationMfaFlowsAssigned { + location_id: Id, + location_name: String, + assignments: Vec, + }, } /// Events from Web API diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 323e8dceb0..21410aa5ae 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, @@ -187,7 +188,7 @@ impl ClientMfaServer { }; // return early if MFA is not enabled for this location - if !location.mfa_enabled() { + if !location.mfa_enabled { error!("MFA is not enabled for location {location}"); return Err(Status::invalid_argument("MFA not enabled for location")); } @@ -298,10 +299,29 @@ impl ClientMfaServer { Status::invalid_argument("invalid MFA method selected") })?; + // Derive the legacy single-factor mode for this location. `None` means the location's + // flow configuration cannot be expressed as a legacy mode (multi-flow, multi-step, or a + // subset of the internal method set), so no current client can enforce it. Fail closed + // rather than fall back to a mode: `mfa_enabled` is a stored column now, so it no longer + // implies that a legacy mode is derivable. + let Some(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") + })? + else { + error!( + "Location {location} has an MFA flow configuration that cannot be enforced by \ + this client" + ); + return Err(Status::failed_precondition( + "location MFA configuration is not supported by this client", + )); + }; + // check if selected MFA method matches location settings - match (&location.location_mfa_mode, selected_method) { - // MFA enabled status is already verified - (LocationMfaMode::Disabled, _) => unreachable!(), + match (&location_mfa_mode, selected_method) { ( LocationMfaMode::Internal, MfaMethod::Totp @@ -317,8 +337,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( @@ -936,7 +955,7 @@ impl ClientMfaServer { return Err(Status::invalid_argument("location not found")); }; - if location.mfa_enabled() { + if location.mfa_enabled { error!( "Posture check: location {location} has MFA enabled, posture-only sessions are not allowed" ); @@ -1372,7 +1391,7 @@ mod tests { polling_token::PollingToken, settings::initialize_current_settings, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, - wireguard::{LocationMfaMode, ServiceLocationMode}, + wireguard::ServiceLocationMode, }, setup_pool, }; @@ -2566,7 +2585,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 +2606,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 5b8d040180..a279d8402f 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}"); @@ -117,12 +117,9 @@ pub async fn build_device_config_response( keepalive_interval: device_config.keepalive_interval, #[allow(deprecated)] mfa_enabled, - location_mfa_mode: Some( - >::into( - device_config.location_mfa_mode, - ) - .into(), - ), + location_mfa_mode: device_config + .location_mfa_mode + .map(|mode| >::into(mode).into()), service_location_mode: Some( >::into( - device_config.location_mfa_mode, - ) - .into(), - ), + location_mfa_mode: device_config.location_mfa_mode.map(|mode| { + >::into(mode).into() + }), service_location_mode: Some( 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::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +/// 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, + } + } +} + +impl From<(MfaFlow, Vec>)> for MfaFlowDetailResponse { + fn from((flow, steps): (MfaFlow, Vec>)) -> Self { + Self { + id: flow.id, + title: flow.title, + steps: steps.into_iter().map(Into::into).collect(), + created_at: flow.created_at, + updated_at: flow.updated_at, + } + } +} + +/// 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, + #[serde(default)] + pub position: i32, + 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 `403` licence-refusal response carrying the same `fields[]` contract as validation +/// errors, so the editor can attach the message to the offending row. +/// +/// The status stays `403` rather than the `400` the impl spec tabulates: a licence refusal is not +/// a malformed request, and the rest of the codebase answers licence gates with `403`. The +/// top-level `error` discriminator distinguishes it from `validation_failed`. +fn license_error_response(field: String, code: &str) -> ApiResponse { + ApiResponse::new( + json!({ + "error": "license_required", + "fields": [{"field": field, "code": code}] + }), + StatusCode::FORBIDDEN, + ) +} + +/// Check licence gates for flow create/update, returning a refusal response when the request +/// would produce over-tier state. +/// +/// Gates compose additively on top of the per-method prerequisites in +/// [`check_method_prerequisites`]: multi-step needs Business, and OIDC needs Business as well as a +/// configured provider. +#[must_use] +fn check_flow_license_gates(step_methods: &[Vec]) -> Option { + if step_methods.len() > 1 && !is_business_license_active() { + return Some(license_error_response( + "steps".into(), + "business_license_required", + )); + } + + let oidc_step = step_methods + .iter() + .position(|methods| methods.contains(&VpnClientMfaMethod::Oidc)); + if let Some(index) = oidc_step + && !is_business_license_active() + { + return Some(license_error_response( + format!("steps[{index}].methods"), + "business_license_required", + )); + } + + None +} + +/// Check per-method prerequisites that are configuration rather than licensing: Email needs SMTP, +/// OIDC needs a configured provider. +/// +/// These are checked on every save so a flow can never reference a method the instance cannot +/// actually perform. `before_methods` is aligned with `step_methods`: each entry carries the +/// methods that already existed in the corresponding step (matched by step id), or `None` for a +/// newly added step. Methods already present in a step are not re-checked - this follows the +/// "permissive read, restrictive write" principle and prevents a backfilled flow from becoming +/// uneditable when a prerequisite (e.g. SMTP) is not configured for a method the backfill itself +/// inserted. Newly added methods are still checked. +#[must_use] +fn check_method_prerequisites( + step_methods: &[Vec], + smtp_configured: bool, + oidc_configured: bool, + before_methods: &[Option>], +) -> Option { + let mut errors = Vec::new(); + + for (index, methods) in step_methods.iter().enumerate() { + let before = before_methods.get(index).and_then(|b| b.as_ref()); + let email_is_new = before.is_none_or(|b| !b.contains(&VpnClientMfaMethod::Email)); + if methods.contains(&VpnClientMfaMethod::Email) && email_is_new && !smtp_configured { + errors.push(MfaFlowValidationField { + field: format!("steps[{index}].methods"), + code: "smtp_not_configured".into(), + }); + } + let oidc_is_new = before.is_none_or(|b| !b.contains(&VpnClientMfaMethod::Oidc)); + if methods.contains(&VpnClientMfaMethod::Oidc) && oidc_is_new && !oidc_configured { + errors.push(MfaFlowValidationField { + field: format!("steps[{index}].methods"), + code: "oidc_provider_missing".into(), + }); + } + } + + if errors.is_empty() { + None + } else { + Some(validation_error_response(errors)) + } +} + +/// Check licence gates for flow assignment: group scoping requires Enterprise. +/// +/// Uses `has_enterprise_access(None)` (raw Enterprise tier) rather than +/// `has_enterprise_access(Some(LicenseFeature::MfaFlowGroupScoping))` because +/// adding a `LicenseFeature` variant would require coordination outside this +/// repo: the proto enum in the `proto` repo and license issuance must both +/// recognise the new variant. The `None` form gates strictly on the Enterprise +/// tier, which is the correct behaviour for this feature. +#[must_use] +fn check_assignment_license_gates(assignments: &[AssignMfaFlowEntry]) -> Option { + let scoped = assignments.iter().position(|a| !a.group_ids.is_empty()); + if let Some(index) = scoped + && !has_enterprise_access(None) + { + return Some(license_error_response( + format!("assignments[{index}].group_ids"), + "enterprise_license_required", + )); + } + None +} + +/// Field path for the first assignment entry matching `predicate`, suffixed with `suffix`. +/// +/// Errors point at the row the admin submitted rather than at the list as a whole. When no entry +/// matches, the path degrades to the bare `assignments` list, which is the best available anchor. +fn assignment_field_path( + assignments: &[AssignMfaFlowEntry], + suffix: &str, + predicate: impl Fn(&AssignMfaFlowEntry) -> bool, +) -> String { + assignments.iter().position(predicate).map_or_else( + || "assignments".to_owned(), + |i| format!("assignments[{i}].{suffix}"), + ) +} + +/// Field path for the assignment entry referencing `flow_id`. +fn assignment_field(assignments: &[AssignMfaFlowEntry], flow_id: Id) -> String { + assignment_field_path(assignments, "flow_id", |a| a.flow_id == flow_id) +} + +/// Field path for the assignment entry referencing `group_id`. +fn group_field(assignments: &[AssignMfaFlowEntry], group_id: Id) -> String { + assignment_field_path(assignments, "group_ids", |a| { + a.group_ids.contains(&group_id) + }) +} + +/// Field path for the assignment entry whose empty group set made it inert, pointing at the +/// `group_ids` the admin must populate rather than at the flow as a whole. +fn non_default_group_field(assignments: &[AssignMfaFlowEntry], flow_id: Id) -> String { + assignment_field_path(assignments, "group_ids", |a| a.flow_id == flow_id) +} + +/// 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 a create request. +/// +/// Array order is authoritative: the server derives contiguous 0-based positions from it and +/// ignores any client-supplied `position`, which makes gaps and duplicate positions +/// unrepresentable. +fn extract_create_step_methods(steps: &[CreateMfaFlowStep]) -> Vec> { + steps.iter().map(|s| s.methods.clone()).collect() +} + +/// Extract step updates from an update request. +/// +/// Array order is authoritative, as for create. `id` is carried through so the model can +/// reconcile existing steps. +fn extract_update_step_updates( + steps: &[UpdateMfaFlowStep], +) -> Vec<(Option, Vec)> { + steps.iter().map(|s| (s.id, s.methods.clone())).collect() +} + +/// Run the create/update validation sequence: licence gates, structural validation, then +/// per-method prerequisites. Returns the first refusal response, or `None` when the request +/// passes all three. +/// +/// `before_methods` carries the methods that already existed per step (by step id) so that +/// [`check_method_prerequisites`] can skip re-checking backfilled methods; the create path passes +/// an empty slice because it has no pre-existing steps. +async fn validate_flow_request( + title: &str, + step_methods: &[Vec], + before_methods: &[Option>], + pool: &PgPool, +) -> Result, WebError> { + if let Some(resp) = check_flow_license_gates(step_methods) { + return Ok(Some(resp)); + } + + let errors = validate_flow_input(title, step_methods); + if !errors.is_empty() { + return Ok(Some(validation_error_response(errors))); + } + + if let Some(resp) = check_method_prerequisites( + step_methods, + Settings::get_current_settings().smtp_configured(), + OpenIdProvider::get_current(pool).await?.is_some(), + before_methods, + ) { + return Ok(Some(resp)); + } + + Ok(None) +} + +// 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: structured `validation_failed` with `fields[]`, e.g. `required`, `min_items`, `max_items`, `max_length`, `duplicate`, `smtp_not_configured`, `oidc_provider_missing`.", body = ApiErrorResponse, example = json!({"error": "validation_failed", "fields": [{"field": "steps[0].methods", "code": "oidc_provider_missing"}]})), + (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 needs a higher licence tier (`business_license_required` for a multi-step flow or an OIDC method). A licence refusal carries the same `fields[]` contract as validation errors under an `error` of `license_required`.", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "steps", "code": "business_license_required"}]})), + (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, + context: ApiRequestContext, + 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); + + if let Some(resp) = + validate_flow_request(&data.title, &step_methods, &[], &appstate.pool).await? + { + return Ok(resp); + } + + 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); + + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaFlowCreated { + snapshot: MfaFlowSnapshot { + flow: flow.clone(), + steps: steps.clone(), + }, + }), + })?; + + let response = MfaFlowDetailResponse::from((flow, steps)); + + 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::from((flow, steps)); + + 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: structured `validation_failed` with `fields[]`, e.g. `required`, `min_items`, `max_items`, `max_length`, `duplicate`, `smtp_not_configured`, `oidc_provider_missing`. A method already present in a step is not re-checked, so a prerequisite that was never configured does not make an existing flow uneditable.", body = ApiErrorResponse, example = json!({"error": "validation_failed", "fields": [{"field": "steps[0].methods", "code": "oidc_provider_missing"}]})), + (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 needs a higher licence tier (`business_license_required` for a multi-step flow or an OIDC method). A licence refusal carries the same `fields[]` contract as validation errors under an `error` of `license_required`.", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "steps", "code": "business_license_required"}]})), + (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, + context: ApiRequestContext, + 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 before_steps = MfaFlowStep::find_by_flow(&appstate.pool, id).await?; + + let step_updates = extract_update_step_updates(&data.steps); + let step_methods: Vec> = step_updates + .iter() + .map(|(_, methods)| methods.clone()) + .collect(); + + let before_by_id: HashMap> = + before_steps.iter().map(|s| (s.id, s)).collect(); + let before_methods: Vec>> = data + .steps + .iter() + .map(|s| { + s.id.and_then(|id| before_by_id.get(&id)) + .map(|bs| bs.methods.iter().copied().collect()) + }) + .collect(); + + if let Some(resp) = + validate_flow_request(&data.title, &step_methods, &before_methods, &appstate.pool).await? + { + return Ok(resp); + } + + let mut tx = appstate.pool.begin().await?; + let (flow, steps) = + match MfaFlow::update_with_steps(&mut tx, existing.id, data.title, step_updates).await { + Ok(result) => result, + Err(MfaFlowUpdateError::UnknownStep(step_id)) => { + let index = data + .steps + .iter() + .position(|s| s.id == Some(step_id)) + .unwrap_or(0); + return Ok(validation_error_response(vec![MfaFlowValidationField { + field: format!("steps[{index}].id"), + code: "unknown_step".into(), + }])); + } + Err(MfaFlowUpdateError::Sqlx(e)) => return Err(WebError::from(e)), + }; + 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::from((flow, steps)); + + 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 = 409, description = "The flow is still load-bearing for at least one location: `location_requires_flow` when deleting it would leave an MFA-enabled location with no flows, `flow_is_default` when it is a location's designated default.", body = ApiErrorResponse, example = json!({"error": "conflict", "fields": [{"field": "id", "code": "flow_is_default", "locations": ["Warsaw Office"]}]})), + (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, + context: ApiRequestContext, + 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")))?; + + // The refusal checks and the delete share one transaction so a concurrent assignment cannot + // make this flow a location's sole default in between. + let mut tx = appstate.pool.begin().await?; + + // Both refusals are 409 Conflict: the request is well formed, but the flow is load-bearing + // for at least one location. The two codes are deliberately distinct. + if let Err(e) = MfaFlow::check_deletable(&mut tx, id).await { + let (code, locations) = match e { + MfaFlowDeleteError::LocationRequiresFlow(locations) => { + ("location_requires_flow", locations) + } + MfaFlowDeleteError::FlowIsDefault(locations) => ("flow_is_default", locations), + MfaFlowDeleteError::Sqlx(e) => return Err(WebError::from(e)), + }; + + return Ok(ApiResponse::new( + json!({ + "error": "conflict", + "fields": [{ + "field": "id", + "code": code, + "locations": locations, + }] + }), + StatusCode::CONFLICT, + )); + } + + let steps = MfaFlowStep::find_by_flow(&mut *tx, id).await?; + + let snapshot = MfaFlowSnapshot { + flow: flow.clone(), + steps, + }; + + flow.delete(&mut *tx).await?; + tx.commit().await?; + + debug!("Deleted MFA flow {id}"); + + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaFlowDeleted { snapshot }), + })?; + + 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 + ); + + // Distinguish "location has no flows" from "location does not exist"; both would otherwise + // return an empty list. + if WireguardNetwork::find_by_id(&appstate.pool, id) + .await? + .is_none() + { + return Err(WebError::ObjectNotFound(format!("Location {id} not found"))); + } + + 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: `no_default_designated`, `multiple_defaults_designated`, `default_must_have_no_groups`, or `non_default_must_have_groups`.", body = ApiErrorResponse, example = json!({"error": "validation_failed", "fields": [{"field": "mfa_flows", "code": "no_default_designated"}]})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse), + (status = 403, description = "Requires admin privileges, or group scoping without an enterprise license (`enterprise_license_required`).", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "assignments[0].group_ids", "code": "enterprise_license_required"}]})), + (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 + ); + + // The location has to exist before we can replace its assignments, and its name is needed for + // the audit event. + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + + let assignments: Vec = data + .assignments + .iter() + .map(|a| LocationMfaFlowAssignment { + flow_id: a.flow_id, + is_default: a.is_default, + group_ids: a.group_ids.clone(), + }) + .collect(); + + if let Some(resp) = check_assignment_license_gates(&data.assignments) { + return Ok(resp); + } + + let mut tx = appstate.pool.begin().await?; + if let Err(e) = MfaFlow::assign_to_location(&mut tx, location_id, &assignments).await { + let (field, code) = match e { + MfaFlowAssignmentError::NoDefaultDesignated => { + ("mfa_flows".to_owned(), "no_default_designated") + } + MfaFlowAssignmentError::MultipleDefaultsDesignated => { + ("mfa_flows".to_owned(), "multiple_defaults_designated") + } + MfaFlowAssignmentError::DefaultHasGroups => { + ("mfa_flows".to_owned(), "default_must_have_no_groups") + } + MfaFlowAssignmentError::NonDefaultWithoutGroups(flow_id) => ( + non_default_group_field(&data.assignments, flow_id), + "non_default_must_have_groups", + ), + MfaFlowAssignmentError::DuplicateFlow(flow_id) => { + (assignment_field(&data.assignments, flow_id), "duplicate") + } + MfaFlowAssignmentError::UnknownFlow(flow_id) => { + (assignment_field(&data.assignments, flow_id), "unknown_flow") + } + MfaFlowAssignmentError::UnknownGroup(group_id) => { + (group_field(&data.assignments, group_id), "unknown_group") + } + MfaFlowAssignmentError::Sqlx(e) => return Err(WebError::from(e)), + }; + + return Ok(validation_error_response(vec![MfaFlowValidationField { + field, + code: code.into(), + }])); + } + 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: location.name, + assignments: LocationMfaFlowAssignment::snapshot(&assignments), + }), + })?; + + Ok(ApiResponse::json(response, StatusCode::OK)) +} + +/// Method availability entry returned by the catalogue endpoint. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct MethodAvailabilityResponse { + pub method: VpnClientMfaMethod, + pub available: bool, + pub reason: MethodAvailabilityReason, +} + +/// Reason a method is (un)available. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MethodAvailabilityReason { + /// Method is usable. + Available, + /// A higher-tier license is required. + Licensed, + /// SMTP must be configured first. + SmtpNotConfigured, + /// An OpenID provider must be configured first. + OidcProviderMissing, +} + +/// Compute per-method availability for the MFA flow editor. +/// +/// Checks license tier, SMTP configuration, and OIDC provider presence to +/// determine which methods are currently usable. All five methods in +/// [`VpnClientMfaMethod`] are always enumerated; unavailable methods carry +/// a `reason` that the UI maps to an appropriate CTA. +fn compute_method_availability( + smtp_configured: bool, + oidc_configured: bool, +) -> Vec { + let has_business = is_business_license_active(); + + let methods = [ + ( + VpnClientMfaMethod::Totp, + true, + MethodAvailabilityReason::Available, + ), + ( + VpnClientMfaMethod::Email, + smtp_configured, + if smtp_configured { + MethodAvailabilityReason::Available + } else { + MethodAvailabilityReason::SmtpNotConfigured + }, + ), + ( + VpnClientMfaMethod::Oidc, + has_business && oidc_configured, + if !has_business { + MethodAvailabilityReason::Licensed + } else if !oidc_configured { + MethodAvailabilityReason::OidcProviderMissing + } else { + MethodAvailabilityReason::Available + }, + ), + ( + VpnClientMfaMethod::Biometric, + true, + MethodAvailabilityReason::Available, + ), + ( + VpnClientMfaMethod::MobileApprove, + true, + MethodAvailabilityReason::Available, + ), + ]; + + methods + .into_iter() + .map(|(method, available, reason)| MethodAvailabilityResponse { + method, + available, + reason, + }) + .collect() +} + +/// Get per-method MFA availability. +#[utoipa::path( + get, + path = "/api/v1/mfa-flow/method-availability", + tag = "mfa flow", + responses( + (status = 200, description = "Per-method availability catalogue.", body = [MethodAvailabilityResponse]), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse), + (status = 500, description = "Unable to compute method availability.", body = ApiErrorResponse) + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn get_method_availability( + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} fetching MFA method availability", + session.user.username + ); + let smtp_configured = Settings::get_current_settings().smtp_configured(); + let oidc_configured = OpenIdProvider::get_current(&appstate.pool).await?.is_some(); + let result = compute_method_availability(smtp_configured, oidc_configured); + Ok(ApiResponse::json(result, StatusCode::OK)) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 6bbb4d080d..882fa43b79 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/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index c473c0ee29..2f60220cb6 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -123,7 +123,12 @@ pub(crate) struct DeviceWireGuardConfig { pub(crate) network_id: Id, pub(crate) network_name: String, pub(crate) config: String, - pub(crate) location_mfa_mode: LocationMfaMode, + /// Authoritative flag for whether the location requires MFA. + pub(crate) mfa_enabled: bool, + /// Legacy derived mode. Absent when the location's MFA flow configuration has no legacy + /// equivalent, which includes every location with no flows, so it must not be used to infer + /// whether MFA is required. + pub(crate) location_mfa_mode: Option, pub(crate) posture_check_required: bool, } @@ -139,7 +144,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", "posture_check_required": false} + {"network_id": 1, "network_name": "office", "config": "[Interface]\n...", "mfa_enabled": false, "posture_check_required": 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"})), @@ -196,6 +201,7 @@ pub(crate) async fn network_device_configs( network_id: device_config.network_id, network_name: device_config.network_name, config: device_config.config, + mfa_enabled: device_config.mfa_enabled, location_mfa_mode: device_config.location_mfa_mode, posture_check_required: device_config.posture_check_required, }; @@ -783,7 +789,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 9d362ca18c..9d2d1c1808 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -11,7 +11,8 @@ use defguard_common::{ models::{ Device, DeviceConfig, DeviceType, User, WireguardNetwork, device::{AddDevice, DeviceInfo, ModifyDevice, WireguardNetworkDevice}, - wireguard::{LocationMfaMode, MappedDevice, ServiceLocationMode}, + mfa_flow::MfaFlow, + wireguard::{MappedDevice, ServiceLocationMode}, }, }, utils::parse_network_address_list, @@ -32,11 +33,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}, }, @@ -84,13 +84,78 @@ 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>, } const MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA: i32 = 120; +/// Build the structured `400` response for the `mfa_enabled` precondition: a location cannot be +/// MFA-enabled while no MFA flow exists to assign to it. +/// +/// The body is a real structured `validation_failed` payload, not a string inside `msg`, so the +/// frontend parses it in one step like every other validation path in this feature. +#[must_use] +pub fn no_flows_exist_response() -> ApiResponse { + ApiResponse::new( + json!({ + "error": "validation_failed", + "fields": [{"field": "mfa_enabled", "code": "no_flows_exist"}] + }), + StatusCode::BAD_REQUEST, + ) +} + +/// Build the structured `400` response for the `mfa_enabled` precondition when the location has no +/// default flow assigned: MFA cannot be enabled until a policy exists to enforce. +#[must_use] +pub fn no_flows_assigned_response() -> ApiResponse { + ApiResponse::new( + json!({ + "error": "validation_failed", + "fields": [{"field": "mfa_enabled", "code": "no_flows_assigned"}] + }), + StatusCode::BAD_REQUEST, + ) +} + +/// Rejects enabling MFA for a location while no MFA flow is assigned to it as its default. +/// +/// The check is per-location: an existing location (`Some(id)`) must carry a designated default +/// assignment, and a brand-new location (`None`, the create path) can never have one, so creating +/// with `mfa_enabled` is refused here too. The global `no_flows_exist` check runs first so a fresh +/// instance reports the more actionable "create a flow" error. Returns a structured `400` response +/// (not a `WebError`) so the body is parsed in one step. +/// +/// Shared by `create_network`, `modify_network` and the auto-adoption wizard, which sets +/// `mfa_enabled` on an already-persisted location, so the three entry points cannot drift. +pub async fn validate_mfa_flows_exist<'e, E: sqlx::PgExecutor<'e> + Copy>( + executor: E, + mfa_enabled: bool, + location_id: Option, +) -> Result, WebError> { + if !mfa_enabled { + return Ok(None); + } + + if !MfaFlow::any_exist(executor).await? { + error!("Unable to enable MFA for location: no MFA flows are configured"); + return Ok(Some(no_flows_exist_response())); + } + + let has_default = match location_id { + Some(id) => MfaFlow::has_default_assignment(executor, id).await?, + None => false, + }; + if !has_default { + error!("Unable to enable MFA for location: no default MFA flow is assigned"); + return Ok(Some(no_flows_assigned_response())); + } + + Ok(None) +} + impl WireguardNetworkData { pub(crate) fn parse_allowed_ips(&self) -> Vec { self.allowed_ips @@ -99,7 +164,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 +177,21 @@ impl WireguardNetworkData { ))) } - pub(crate) async fn validate_location_mfa_mode<'e, E: sqlx::PgExecutor<'e>>( + /// Rejects enabling MFA for a location while no MFA flow is assigned to it as its default. + /// + /// Thin wrapper over [`validate_mfa_flows_exist`] for the create/modify request path. + pub(crate) async fn validate_mfa_flows_exist<'e, E: sqlx::PgExecutor<'e> + Copy>( &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(()) + location_id: Option, + ) -> Result, WebError> { + validate_mfa_flows_exist(executor, self.mfa_enabled, location_id).await } /// 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 +248,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,10 +300,12 @@ 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()?; + if let Some(resp) = data.validate_mfa_flows_exist(&appstate.pool, None).await? { + return Ok(resp); + } let allowed_ips = data.parse_allowed_ips(); let mut network = WireguardNetwork::new( @@ -272,7 +318,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,10 +431,15 @@ 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()?; + if let Some(resp) = data + .validate_mfa_flows_exist(&appstate.pool, Some(network_id)) + .await? + { + return Ok(resp); + } let network = find_network(network_id, &appstate.pool).await?; // store network before mods @@ -412,7 +463,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 +919,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 +1550,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", "posture_check_required": false} + {"network_id": 1, "network_name": "office", "config": "[Interface]\n...", "mfa_enabled": false, "posture_check_required": 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"})), @@ -1555,6 +1606,7 @@ pub(crate) async fn user_device_configs( network_id: device_config.network_id, network_name: device_config.network_name, config: device_config.config, + mfa_enabled: device_config.mfa_enabled, location_mfa_mode: device_config.location_mfa_mode, posture_check_required: device_config.posture_check_required, }); diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index d1d7b0aaec..cf51c7ab90 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}, }, }, @@ -49,6 +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_location_mfa_flows, get_method_availability, + 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, @@ -566,6 +570,26 @@ pub fn build_webapp( .route("/destination/apply", put(apply_acl_destinations)), ); + let api_router = api_router.nest( + "/api/v1", + Router::new() + .route("/mfa-flow", get(list_mfa_flows).post(create_mfa_flow)) + .route( + "/mfa-flow/{id}", + get(get_mfa_flow) + .put(update_mfa_flow) + .delete(delete_mfa_flow), + ) + .route( + "/mfa-flow/method-availability", + get(get_method_availability), + ) + .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() @@ -1042,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()]) @@ -1144,7 +1168,7 @@ pub async fn init_vpn_location( false, false, false, - LocationMfaMode::Disabled, + false, // mfa_enabled ServiceLocationMode::Disabled, ) .set_address([args.address])?; @@ -1185,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 24d2629d2f..1887bc414b 100644 --- a/crates/defguard_core/src/location_management/allowed_peers.rs +++ b/crates/defguard_core/src/location_management/allowed_peers.rs @@ -28,7 +28,7 @@ pub async fn get_location_allowed_peers( } let has_postures = location.has_postures(&mut *conn).await?; - if !location.mfa_enabled() && !has_postures { + if !location.mfa_enabled && !has_postures { let rows = query!( "SELECT d.wireguard_pubkey pubkey, \ ARRAY( \ @@ -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/openapi.rs b/crates/defguard_core/src/openapi.rs index 92a05fccd9..b0c76378fa 100644 --- a/crates/defguard_core/src/openapi.rs +++ b/crates/defguard_core/src/openapi.rs @@ -24,8 +24,8 @@ use super::{ SESSION_COOKIE_NAME, StartEnrollmentRequest, Username, activity_log, app_info, auth, component_setup, core_certs, forward_auth, gateway, group::{self, BulkAssignToGroupsRequest}, - license, location_stats, mail, network_devices, openid_clients, openid_flow, proxy, - reserved, resource_display, session_info, settings, ssh_authorized_keys, static_ips, + license, location_stats, mail, mfa_flow, network_devices, openid_clients, openid_flow, + proxy, reserved, resource_display, session_info, settings, ssh_authorized_keys, static_ips, support, updates, user::{self, UserDetails}, webhooks, wireguard as device, wireguard as network, @@ -264,6 +264,15 @@ Errors are returned as a JSON object with a `msg` field and, for some of them, a openid_clients::get_openid_client, openid_clients::change_openid_client_state, openid_clients::change_openid_client, + // mfa flow + mfa_flow::list_mfa_flows, + mfa_flow::create_mfa_flow, + mfa_flow::get_mfa_flow, + mfa_flow::update_mfa_flow, + mfa_flow::delete_mfa_flow, + mfa_flow::get_location_mfa_flows, + mfa_flow::set_location_mfa_flows, + mfa_flow::get_method_availability, // support mail::send_support_data, mail::test_mail, @@ -307,6 +316,7 @@ Errors are returned as a JSON object with a `msg` field and, for some of them, a (name = "OpenID", description = "External OpenID providers used for logging in to defguard."), (name = "OAuth2", description = "defguard acting as an OAuth2 / OpenID Connect provider for other applications."), (name = "support", description = "Diagnostics, logs and support data."), + (name = "mfa flow", description = "MFA flow templates - ordered multi-step authentication recipes."), (name = "license", description = "Enterprise license."), ) )] diff --git a/crates/defguard_core/src/wg_config.rs b/crates/defguard_core/src/wg_config.rs index bbea192abe..dbd035f47c 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 96be774391..a0548ed06a 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 86a8c55f97..cc78453611 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 dc401c504e..0651c18481 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -225,8 +225,7 @@ pub(crate) async fn make_network(client: &TestClient, name: &str) -> TestRespons "acl_enabled": false, "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 7fea42fafa..7ddccc5f9a 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 e9e41fd22b..e4eb978b2e 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/mfa_flow.rs b/crates/defguard_core/tests/integration/api/mfa_flow.rs new file mode 100644 index 0000000000..8ce444bb1f --- /dev/null +++ b/crates/defguard_core/tests/integration/api/mfa_flow.rs @@ -0,0 +1,1213 @@ +use defguard_common::db::{ + models::{ + Settings, User, mfa_flow::MfaFlow, settings::update_current_settings, + vpn_client_session::VpnClientMfaMethod, + }, + setup_pool, +}; +use defguard_core::{ + enterprise::license::{get_cached_license, set_cached_license}, + events::ApiEventType, +}; +use matches::assert_matches; +use reqwest::StatusCode; +use serde_json::json; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::common::{ + authenticate_admin, configure_smtp, make_network, make_test_client, set_enterprise_license, +}; + +/// Single-step flow without OIDC - should succeed without any license. +#[sqlx::test] +async fn test_mfa_flow_single_step_no_license(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + set_cached_license(None); + let response = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "Test Flow", + "steps": [{ "methods": ["totp"] }] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let created = response.json::().await; + let created_id = created["id"].as_i64().unwrap(); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after create"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::MfaFlowCreated { snapshot } + if snapshot.flow.id == created_id + && snapshot.flow.title == "Test Flow" + && snapshot.steps.len() == 1 + && snapshot.steps[0].methods == vec![VpnClientMfaMethod::Totp] + ); + + set_cached_license(saved); +} + +/// Multi-step flow (2+ steps) requires a business license. +#[sqlx::test] +async fn test_mfa_flow_multi_step_requires_business(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + let body = json!({ + "title": "Multi-Step Flow", + "steps": [ + { "methods": ["totp"] }, + { "methods": ["biometric"] } + ] + }); + + // No license → 403, and a refused request must not emit an audit event. + set_cached_license(None); + let response = client.post("/api/v1/mfa-flow").json(&body).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + // Business license → 201 + set_cached_license(saved.clone()); + let response = client.post("/api/v1/mfa-flow").json(&body).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let created = response.json::().await; + let created_id = created["id"].as_i64().unwrap(); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after create"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::MfaFlowCreated { snapshot } + if snapshot.flow.id == created_id && snapshot.steps.len() == 2 + ); + + set_cached_license(saved); +} + +/// OIDC method requires a business license + a configured OIDC provider. +#[sqlx::test] +async fn test_mfa_flow_oidc_requires_business_and_provider( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + let body = json!({ + "title": "OIDC Flow", + "steps": [{ "methods": ["oidc"] }] + }); + + // No license → 403 + set_cached_license(None); + let response = client.post("/api/v1/mfa-flow").json(&body).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + // Business license but no OIDC provider → 400 + set_cached_license(saved.clone()); + let response = client.post("/api/v1/mfa-flow").json(&body).send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await; + assert_eq!(body["error"], "validation_failed"); + assert_eq!(body["fields"][0]["field"], "steps[0].methods"); + assert_eq!(body["fields"][0]["code"], "oidc_provider_missing"); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + set_cached_license(saved); +} + +/// The Email method cannot be saved while SMTP is unconfigured, otherwise a flow would reference a +/// factor the instance is unable to deliver. +#[sqlx::test] +async fn test_mfa_flow_email_requires_smtp(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let response = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "Email Flow", + "steps": [{ "methods": ["totp"] }, { "methods": ["email"] }] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body: serde_json::Value = response.json().await; + assert_eq!(body["error"], "validation_failed"); + // The field path must point at the offending step so the editor can highlight that row. + assert_eq!(body["fields"][0]["field"], "steps[1].methods"); + assert_eq!(body["fields"][0]["code"], "smtp_not_configured"); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); +} + +/// Group-scoped assignments require an enterprise license. +#[sqlx::test] +async fn test_mfa_flow_group_scoping_requires_enterprise( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + // Create two single-step flows + let flow1_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "Default Flow", + "steps": [{ "methods": ["totp"] }] + })) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + let flow2_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "Scoped Flow", + "steps": [{ "methods": ["biometric"] }] + })) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + // Get the admin group ID via group-info endpoint + let groups_resp = client.get("/api/v1/group-info").send().await; + assert_eq!(groups_resp.status(), StatusCode::OK); + let groups = groups_resp.json::().await; + let admin_group_id = groups + .as_array() + .and_then(|arr| arr.first()) + .and_then(|g| g["id"].as_i64()) + .expect("admin group exists"); + + // Create a location + let network_resp = make_network(&client, "enterprise-test").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // Default assignment (empty group_ids) + scoped assignment (non-empty) + let assignment_body = json!({ + "assignments": [ + { + "flow_id": flow1_id, + "is_default": true, + "group_ids": [] + }, + { + "flow_id": flow2_id, + "is_default": false, + "group_ids": [admin_group_id] + } + ] + }); + + // Drain the create/location events so the refusal assertion below is exact. + let _ = client.drain_all_events(); + + // Business license → 403 (group scoping needs enterprise), and no audit event on refusal. + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&assignment_body) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + // Enterprise license → 200 + set_enterprise_license(); + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&assignment_body) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after assign"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::LocationMfaFlowsAssigned { + location_id: ev_location_id, + location_name, + assignments, + } if *ev_location_id == location_id + && location_name == "enterprise-test" + && assignments.len() == 2 + && assignments[0].flow_id == flow1_id + && assignments[0].position == 0 + && assignments[0].is_default + && assignments[0].group_ids.is_empty() + && assignments[1].flow_id == flow2_id + && assignments[1].position == 1 + && !assignments[1].is_default + && assignments[1].group_ids == vec![admin_group_id] + ); + + set_cached_license(saved); +} + +/// Multi-step guard also applies to updates. +#[sqlx::test] +async fn test_mfa_flow_update_multi_step_requires_business( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + // Create a single-step flow first (allowed) + let create_resp = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "To Be Updated", + "steps": [{ "methods": ["totp"] }] + })) + .send() + .await; + assert_eq!(create_resp.status(), StatusCode::CREATED); + let flow_id = create_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // Clear the create event before exercising the refusal path. + let _ = client.drain_all_events(); + + // Try updating to multi-step without license → 403, and no audit event on refusal. + set_cached_license(None); + let response = client + .put(format!("/api/v1/mfa-flow/{flow_id}")) + .json(&json!({ + "title": "Updated Flow", + "steps": [ + { "methods": ["totp"] }, + { "methods": ["biometric"] } + ] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + // Restore license → update succeeds + set_cached_license(saved.clone()); + let response = client + .put(format!("/api/v1/mfa-flow/{flow_id}")) + .json(&json!({ + "title": "Updated Flow", + "steps": [ + { "methods": ["totp"] }, + { "methods": ["biometric"] } + ] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after update"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::MfaFlowUpdated { before, after } + if before.flow.title == "To Be Updated" + && before.steps.len() == 1 + && after.flow.title == "Updated Flow" + && after.steps.len() == 2 + ); + + set_cached_license(saved); +} + +/// A step id belonging to another flow must be refused, not silently applied. Reconciliation +/// UPDATEs by step id, so an unscoped write would rewrite the other flow's step and report it as +/// this flow's. +#[sqlx::test] +async fn test_mfa_flow_update_rejects_foreign_step_id(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Flow A", "steps": [{ "methods": ["totp"] }]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let flow_a: serde_json::Value = resp.json().await; + + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Flow B", "steps": [{ "methods": ["totp"] }]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let flow_b: serde_json::Value = resp.json().await; + + let flow_a_id = flow_a["id"].as_i64().unwrap(); + let flow_b_id = flow_b["id"].as_i64().unwrap(); + let flow_b_step_id = flow_b["steps"][0]["id"].as_i64().unwrap(); + + // Clear the two create events before exercising the refusal path. + let _ = client.drain_all_events(); + + // Update flow A, but hand it flow B's step id. + let response = client + .put(format!("/api/v1/mfa-flow/{flow_a_id}")) + .json(&json!({ + "title": "Flow A", + "steps": [{ "id": flow_b_step_id, "methods": ["biometric"] }] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await; + assert_eq!(body["fields"][0]["code"], "unknown_step"); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); + + // Flow B must be untouched. + let response = client + .get(format!("/api/v1/mfa-flow/{flow_b_id}")) + .send() + .await; + let flow_b_after: serde_json::Value = response.json().await; + assert_eq!( + flow_b_after["steps"], flow_b["steps"], + "the other flow's steps must not have been rewritten" + ); +} + +/// Assignment input that cannot be satisfied is a validation error, not a 500 from a constraint +/// violation, and an unknown location is a 404 rather than an empty list. +#[sqlx::test] +async fn test_location_mfa_flows_input_validation(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let network_resp = make_network(&client, "assignment-validation").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + let flow_resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Flow", "steps": [{ "methods": ["totp"] }]})) + .send() + .await; + let flow_id = flow_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // Clear the create event so the refusal assertions below are exact. + let _ = client.drain_all_events(); + + // Unknown location → 404, not an empty list. + let response = client.get("/api/v1/location/999999/mfa-flows").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = client + .put("/api/v1/location/999999/mfa-flows") + .json(&json!({"assignments": [{"flow_id": flow_id, "is_default": true, "group_ids": []}]})) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // The same flow twice would violate the (location_id, flow_id) primary key. + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow_id, "is_default": true, "group_ids": []}, + {"flow_id": flow_id, "is_default": false, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.json::().await["fields"][0]["code"], + "duplicate" + ); + + // A nonexistent flow would violate the foreign key. + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": 999999, "is_default": true, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.json::().await["fields"][0]["code"], + "unknown_flow" + ); + + // None of the refused requests above may have emitted an audit event. + assert!( + client.drain_all_events().is_empty(), + "refused requests must not emit audit events" + ); +} + +/// A non-default assignment with an empty group set can never match any user, so it must be +/// rejected with a field path pointing at the offending entry's `group_ids`. +#[sqlx::test] +async fn test_location_mfa_flows_non_default_without_groups( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let flow1_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Scoped", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + let flow2_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Default", "steps": [{"methods": ["biometric"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + let network_resp = make_network(&client, "non-default-without-groups").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // Clear the two create events before exercising the refusal path. + let _ = client.drain_all_events(); + + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow1_id, "is_default": false, "group_ids": []}, + {"flow_id": flow2_id, "is_default": true, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await; + assert_eq!(body["error"], "validation_failed"); + assert_eq!(body["fields"][0]["field"], "assignments[0].group_ids"); + assert_eq!(body["fields"][0]["code"], "non_default_must_have_groups"); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); +} + +/// An MFA-disabled location's assignment list can be cleared to empty via the API. +#[sqlx::test] +async fn test_location_mfa_flows_clear_disabled_location( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let flow_resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Flow", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + let flow_id = flow_resp.json::().await["id"] + .as_i64() + .unwrap(); + + let network_resp = make_network(&client, "clear-disabled").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // Clear the create/location events so the assignment assertions below are exact. + let _ = client.drain_all_events(); + + // Assign a default, then clear it. + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow_id, "is_default": true, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after assign"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::LocationMfaFlowsAssigned { + location_id: ev_location_id, + assignments, + .. + } if *ev_location_id == location_id + && assignments.len() == 1 + && assignments[0].flow_id == flow_id + && assignments[0].position == 0 + && assignments[0].is_default + && assignments[0].group_ids.is_empty() + ); + + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": []})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after clear"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::LocationMfaFlowsAssigned { + location_id: ev_location_id, + assignments, + .. + } if *ev_location_id == location_id && assignments.is_empty() + ); + + let response = client + .get(format!("/api/v1/location/{location_id}/mfa-flows")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .json::() + .await + .as_array() + .unwrap() + .len(), + 0 + ); +} + +/// Method availability returns all five methods with correct availability. +#[sqlx::test] +async fn test_method_availability_basic(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + let response = client + .get("/api/v1/mfa-flow/method-availability") + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let items = response.json::().await; + let items = items.as_array().unwrap(); + assert_eq!(items.len(), 5); + + let find = |method: &str| -> &serde_json::Value { + items + .iter() + .find(|m| m["method"].as_str() == Some(method)) + .unwrap() + }; + + assert_eq!(find("totp")["available"].as_bool(), Some(true)); + assert_eq!(find("email")["available"].as_bool(), Some(false)); + assert_eq!( + find("email")["reason"].as_str(), + Some("smtp_not_configured") + ); + assert_eq!(find("oidc")["available"].as_bool(), Some(false)); + assert_eq!( + find("oidc")["reason"].as_str(), + Some("oidc_provider_missing") + ); + assert_eq!(find("biometric")["available"].as_bool(), Some(true)); + assert_eq!(find("mobileapprove")["available"].as_bool(), Some(true)); + + set_cached_license(None); + let response = client + .get("/api/v1/mfa-flow/method-availability") + .send() + .await; + let items = response.json::().await; + let items = items.as_array().unwrap(); + let find = |method: &str| -> &serde_json::Value { + items + .iter() + .find(|m| m["method"].as_str() == Some(method)) + .unwrap() + }; + assert_eq!(find("oidc")["available"].as_bool(), Some(false)); + assert_eq!(find("oidc")["reason"].as_str(), Some("licensed")); + + set_cached_license(saved); +} + +/// Updating a flow that already contains email (e.g. backfilled from a +/// migration) must succeed even when SMTP is not configured, as long as email +/// was already present in the flow. Adding email where it did not exist +/// before must still be rejected. +#[sqlx::test] +async fn test_mfa_flow_update_preserves_backfilled_email( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool.clone()).await; + authenticate_admin(&mut client).await; + + // Enable SMTP so we can create a flow with email. + let mut settings = Settings::get_current_settings(); + configure_smtp(&mut settings); + update_current_settings(&pool, settings).await.unwrap(); + + // Create a flow with email - this represents the backfilled "Default + // Internal MFA" flow. + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "Flow With Email", + "steps": [{ "methods": ["totp", "email"] }] + })) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let created: serde_json::Value = resp.json().await; + let flow_id = created["id"].as_i64().unwrap(); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after create"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::MfaFlowCreated { snapshot } if snapshot.flow.id == flow_id + ); + + // Remove SMTP. + let mut settings = Settings::get_current_settings(); + settings.smtp.server = None; + settings.smtp.port = None; + settings.smtp.sender = None; + update_current_settings(&pool, settings).await.unwrap(); + + // Update the flow keeping email unchanged -> should succeed. + let resp = client + .put(format!("/api/v1/mfa-flow/{flow_id}")) + .json(&json!({ + "title": "Flow With Email Updated", + "steps": created["steps"] + })) + .send() + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "updating a flow with unchanged email must succeed even without SMTP" + ); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after update"); + let (event_type, _user_id, _username) = &events[0]; + assert_matches!( + event_type, + ApiEventType::MfaFlowUpdated { before, after } + if before.flow.title == "Flow With Email" + && after.flow.title == "Flow With Email Updated" + ); + + // Update the flow adding email to a new step -> must be rejected. + let resp = client + .put(format!("/api/v1/mfa-flow/{flow_id}")) + .json(&json!({ + "title": "Flow With Email Updated", + "steps": [ + { "methods": ["totp"] }, + { "methods": ["email"] } + ] + })) + .send() + .await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "adding email to a new step must still be rejected without SMTP" + ); + let body: serde_json::Value = resp.json().await; + assert_eq!(body["fields"][0]["field"], "steps[1].methods"); + assert_eq!(body["fields"][0]["code"], "smtp_not_configured"); + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); +} + +/// The full `WireguardNetworkData` body used to toggle `mfa_enabled` on an existing location. +fn network_body(name: &str, mfa_enabled: bool) -> serde_json::Value { + json!({ + "name": name, + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "mfa_enabled": mfa_enabled, + "service_location_mode": "disabled" + }) +} + +/// Disabling MFA preserves the assignment list (and its default designation), and re-enabling +/// restores the same policy: the ADR's guarantee that the off toggle is non-destructive. +#[sqlx::test] +async fn test_mfa_enabled_disable_preserves_assignments( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool.clone()).await; + authenticate_admin(&mut client).await; + + let flow_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Lifecycle Flow", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + let network_resp = make_network(&client, "mfa-lifecycle").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // Assign the flow as the location's default. + let resp = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow_id, "is_default": true, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Enable MFA, then disable it: the assignment list must survive untouched. + let resp = client + .put(format!("/api/v1/network/{location_id}")) + .json(&network_body("mfa-lifecycle", true)) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let resp = client + .put(format!("/api/v1/network/{location_id}")) + .json(&network_body("mfa-lifecycle", false)) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let resp = client + .get(format!("/api/v1/location/{location_id}/mfa-flows")) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + let assignments = resp.json::().await; + let assignments = assignments.as_array().unwrap(); + assert_eq!( + assignments.len(), + 1, + "disabling MFA must preserve the assignment list" + ); + assert_eq!(assignments[0]["id"].as_i64(), Some(flow_id)); + assert_eq!(assignments[0]["position"].as_i64(), Some(0)); + assert_eq!(assignments[0]["is_default"].as_bool(), Some(true)); + + // Re-enable: the same policy must be in force, resolving the same flow for a user. + let resp = client + .put(format!("/api/v1/network/{location_id}")) + .json(&network_body("mfa-lifecycle", true)) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let user = User::find_by_username(&pool, "hpotter") + .await + .unwrap() + .unwrap(); + let resolved = MfaFlow::resolve_for_user(&pool, location_id, user.id) + .await + .unwrap() + .expect("a default assignment must resolve"); + assert_eq!( + resolved.0.id, flow_id, + "re-enabling must restore the same resolved flow" + ); +} + +/// Deleting the only flow assigned to an MFA-enabled location is refused with +/// `location_requires_flow` (409), naming the location, and emits no audit event. +#[sqlx::test] +async fn test_mfa_flow_delete_location_requires_flow(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let flow_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Sole Flow", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + let network_resp = make_network(&client, "delete-orphan").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + let resp = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow_id, "is_default": true, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Enable MFA so the location requires this flow. + let resp = client + .put(format!("/api/v1/network/{location_id}")) + .json(&network_body("delete-orphan", true)) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Drain the create/location/assign/enable events before the refusal. + let _ = client.drain_all_events(); + + let resp = client + .delete(format!("/api/v1/mfa-flow/{flow_id}")) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + let body: serde_json::Value = resp.json().await; + assert_eq!(body["error"], "conflict"); + assert_eq!(body["fields"][0]["field"], "id"); + assert_eq!(body["fields"][0]["code"], "location_requires_flow"); + assert_eq!(body["fields"][0]["locations"], json!(["delete-orphan"])); + + assert!( + client.drain_all_events().is_empty(), + "refused delete must not emit an audit event" + ); +} + +/// Deleting a flow that is a location's designated default is refused with `flow_is_default` +/// (409), distinct from `location_requires_flow`, naming the location. +#[sqlx::test] +async fn test_mfa_flow_delete_flow_is_default(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + let saved = get_cached_license().clone(); + + let flow1_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Default Flow", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + let flow2_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Scoped Flow", "steps": [{"methods": ["biometric"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + let groups_resp = client.get("/api/v1/group-info").send().await; + let groups = groups_resp.json::().await; + let admin_group_id = groups + .as_array() + .and_then(|arr| arr.first()) + .and_then(|g| g["id"].as_i64()) + .expect("admin group exists"); + + let network_resp = make_network(&client, "delete-default").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + // flow1 is the default, flow2 is group-scoped; group scoping needs Enterprise. + set_enterprise_license(); + let resp = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow1_id, "is_default": true, "group_ids": []}, + {"flow_id": flow2_id, "is_default": false, "group_ids": [admin_group_id]}, + ]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let _ = client.drain_all_events(); + + // Deleting the default is refused with the distinct `flow_is_default` code. + let resp = client + .delete(format!("/api/v1/mfa-flow/{flow1_id}")) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + let body: serde_json::Value = resp.json().await; + assert_eq!(body["error"], "conflict"); + assert_eq!(body["fields"][0]["code"], "flow_is_default"); + assert_eq!(body["fields"][0]["locations"], json!(["delete-default"])); + assert!( + client.drain_all_events().is_empty(), + "refused delete must not emit an audit event" + ); + + // The non-default flow deletes cleanly and emits `MfaFlowDeleted`. + let resp = client + .delete(format!("/api/v1/mfa-flow/{flow2_id}")) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + let events = client.drain_all_events(); + assert_eq!(events.len(), 1); + assert_matches!( + &events[0].0, + ApiEventType::MfaFlowDeleted { snapshot } if snapshot.flow.id == flow2_id + ); + + set_cached_license(saved); +} + +/// Plain CRUD over HTTP: create, list (with `step_count`), fetch, update, and delete an +/// unassigned flow, asserting the audit events at each step. +#[sqlx::test] +async fn test_mfa_flow_crud(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // Create a two-step flow (business license is active by default). + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "CRUD Flow", + "steps": [ + { "methods": ["totp"] }, + { "methods": ["biometric"] } + ] + })) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + let created: serde_json::Value = resp.json().await; + let flow_id = created["id"].as_i64().unwrap(); + assert_eq!(created["steps"].as_array().unwrap().len(), 2); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after create"); + assert_matches!( + &events[0].0, + ApiEventType::MfaFlowCreated { snapshot } + if snapshot.flow.id == flow_id && snapshot.steps.len() == 2 + ); + + // List: the item must carry the server-computed step_count. + let resp = client.get("/api/v1/mfa-flow").send().await; + assert_eq!(resp.status(), StatusCode::OK); + let items = resp.json::().await; + let item = items + .as_array() + .unwrap() + .iter() + .find(|i| i["id"].as_i64() == Some(flow_id)) + .expect("created flow must appear in list"); + assert_eq!(item["step_count"].as_i64(), Some(2)); + + // Fetch single. + let resp = client + .get(format!("/api/v1/mfa-flow/{flow_id}")) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + let detail = resp.json::().await; + assert_eq!(detail["title"], "CRUD Flow"); + assert_eq!(detail["steps"].as_array().unwrap().len(), 2); + + // Update: rename and collapse to one step. + let resp = client + .put(format!("/api/v1/mfa-flow/{flow_id}")) + .json(&json!({ + "title": "CRUD Flow Updated", + "steps": [ + { "id": detail["steps"][0]["id"], "methods": ["totp"] } + ] + })) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after update"); + assert_matches!( + &events[0].0, + ApiEventType::MfaFlowUpdated { before, after } + if before.flow.title == "CRUD Flow" + && before.steps.len() == 2 + && after.flow.title == "CRUD Flow Updated" + && after.steps.len() == 1 + ); + + // Delete (unassigned) succeeds. + let resp = client + .delete(format!("/api/v1/mfa-flow/{flow_id}")) + .send() + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1, "expected exactly 1 event after delete"); + assert_matches!( + &events[0].0, + ApiEventType::MfaFlowDeleted { snapshot } if snapshot.flow.id == flow_id + ); +} + +/// Saving an assignment set with no designated default is refused over HTTP with +/// `no_default_designated` (400), never silently normalised. +#[sqlx::test] +async fn test_location_mfa_flows_no_default_designated( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let flow_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "No Default", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + let network_resp = make_network(&client, "no-default").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + let _ = client.drain_all_events(); + + let resp = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow_id, "is_default": false, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = resp.json().await; + assert_eq!(body["error"], "validation_failed"); + assert_eq!(body["fields"][0]["code"], "no_default_designated"); + + assert!( + client.drain_all_events().is_empty(), + "refused request must not emit an audit event" + ); +} diff --git a/crates/defguard_core/tests/integration/api/mod.rs b/crates/defguard_core/tests/integration/api/mod.rs index 841d12323d..84772ed2e6 100644 --- a/crates/defguard_core/tests/integration/api/mod.rs +++ b/crates/defguard_core/tests/integration/api/mod.rs @@ -12,6 +12,7 @@ mod forward_auth; mod gateway; mod group; mod location_stats; +mod mfa_flow; mod oauth; mod openid; mod openid_login; diff --git a/crates/defguard_core/tests/integration/api/openid_login.rs b/crates/defguard_core/tests/integration/api/openid_login.rs index 40280fd179..058c695e77 100644 --- a/crates/defguard_core/tests/integration/api/openid_login.rs +++ b/crates/defguard_core/tests/integration/api/openid_login.rs @@ -15,9 +15,10 @@ use defguard_core::{ }; use reqwest::{StatusCode, Url}; use serde::Deserialize; +use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use super::common::{exceed_enterprise_limits, make_client, setup_pool}; +use super::common::{exceed_enterprise_limits, make_client, make_network, setup_pool}; use crate::api::PaginatedApiResponse; #[derive(Deserialize)] @@ -282,3 +283,195 @@ async fn test_openid_login(_: PgPoolOptions, options: PgConnectOptions) { // let response = client.get("/api/v1/me").send().await; // assert_eq!(response.status(), StatusCode::OK); } + +/// Deleting an OIDC provider must actually remove it, not merely report success. +/// +/// Regression test: the OIDC-flow conflict check added for multi-step MFA replaced the +/// `provider.delete(...)` call, so the handler committed an empty transaction, logged and +/// audited a deletion, and returned 200 while the provider row survived. An admin removing +/// a compromised IdP would have been told it was gone while it stayed live for SSO and for +/// OIDC MFA steps. +#[sqlx::test] +async fn test_delete_openid_provider_removes_it(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let client = make_client(pool).await; + + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + exceed_enterprise_limits(&client).await; + + let provider_data = AddProviderData { + name: "to-delete".to_owned(), + base_url: "https://example.com".to_owned(), + kind: OpenIdProviderKind::Custom, + client_id: "client_id".to_owned(), + client_secret: "client_secret".to_owned(), + display_name: Some("display_name".to_owned()), + admin_email: None, + google_service_account_email: None, + google_service_account_key: None, + directory_sync_enabled: false, + directory_sync_interval: 100, + directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_target: DirectorySyncTarget::All.to_string(), + create_account: false, + okta_dirsync_client_id: None, + okta_private_jwk: None, + directory_sync_group_match: None, + username_handling: OpenIdUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, + prefetch_users: false, + disable_password_management: false, + directory_sync_user_groups: None, + }; + + let response = client + .post("/api/v1/openid/provider") + .json(&provider_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // The provider exists before deletion. + let response = client.get("/api/v1/openid/provider/to-delete").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // No location has OIDC in an MFA flow, so deletion is unobstructed. + let response = client + .delete("/api/v1/openid/provider/to-delete") + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // The provider must actually be gone. `get_openid_provider` answers 204 when + // no provider carries the requested name. + let response = client.get("/api/v1/openid/provider/to-delete").send().await; + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "provider still exists after a successful DELETE" + ); +} + +/// Deleting an OIDC provider proceeds even while locations still reference OIDC in their MFA +/// flows, and reports those locations so an admin can be warned. +/// +/// Removing a provider is frequently incident response (a compromised or decommissioned IdP), so +/// revocation is deliberately not blocked. Access still fails closed: the affected flows become +/// unsatisfiable, so connect-time MFA refuses rather than letting anyone through. +#[sqlx::test] +async fn test_delete_openid_provider_reports_affected_locations( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let client = make_client(pool).await; + + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + exceed_enterprise_limits(&client).await; + + // Set the licence explicitly: a sibling test in this file leaves an expired one cached, and + // saving an OIDC flow step requires an active business licence. + set_cached_license(Some(License::new( + "test".to_owned(), + false, + Some(Utc::now() + Duration::days(365)), + None, + None, + LicenseTier::Business, + SupportType::Basic, + vec![], + ))); + + // The provider has to exist first: an OIDC flow cannot be saved without one. + let provider_data = AddProviderData { + name: "to-delete".to_owned(), + base_url: "https://example.com".to_owned(), + kind: OpenIdProviderKind::Custom, + client_id: "client_id".to_owned(), + client_secret: "client_secret".to_owned(), + display_name: Some("display_name".to_owned()), + admin_email: None, + google_service_account_email: None, + google_service_account_key: None, + directory_sync_enabled: false, + directory_sync_interval: 100, + directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_target: DirectorySyncTarget::All.to_string(), + create_account: false, + okta_dirsync_client_id: None, + okta_private_jwk: None, + directory_sync_group_match: None, + username_handling: OpenIdUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, + prefetch_users: false, + disable_password_management: false, + directory_sync_user_groups: None, + }; + let response = client + .post("/api/v1/openid/provider") + .json(&provider_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let location_id = make_network(&client, "oidc-location") + .await + .json::() + .await["id"] + .as_i64() + .unwrap(); + + let response = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "OIDC Flow", + "steps": [{ "methods": ["oidc"] }] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let flow_id = response.json::().await["id"] + .as_i64() + .unwrap(); + + // Assign the OIDC flow as the location's default, so the location genuinely depends on it. + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({ + "assignments": [{ "flow_id": flow_id, "is_default": true, "group_ids": [] }] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Deletion proceeds and names the location whose flows are now unsatisfiable. + let response = client + .delete("/api/v1/openid/provider/to-delete") + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await; + assert_eq!( + body["affected_locations"], + json!(["oidc-location"]), + "the location depending on OIDC must be reported back to the caller" + ); + + // The provider is gone despite the outstanding OIDC reference: revocation is not blocked. + let response = client.get("/api/v1/openid/provider/to-delete").send().await; + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "provider still exists after a successful DELETE" + ); +} diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 16cb7826e5..61d1ae59aa 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -6,20 +6,15 @@ use defguard_common::db::{ Device, User, WireguardNetwork, device::WireguardNetworkDevice, group::Group, - settings::OpenIdUsernameHandling, wireguard::{ DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_WIREGUARD_MTU, - LocationMfaMode, ServiceLocationMode, + ServiceLocationMode, }, }, }; use defguard_core::{ enterprise::{ - db::models::{ - acl::{AclRule, AclRuleNetwork, AclRuleUser, RuleState}, - openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProviderKind}, - }, - handlers::openid_providers::AddProviderData, + db::models::acl::{AclRule, AclRuleNetwork, AclRuleUser, RuleState}, license::{License, LicenseTier, SupportType, get_cached_license, set_cached_license}, limits::update_counts, }, @@ -34,8 +29,8 @@ use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::common::{ - authenticate_admin, client::TestClient, exceed_enterprise_limits, fetch_user_details, - make_network, make_test_client, setup_pool, + authenticate_admin, client::TestClient, fetch_user_details, make_network, make_test_client, + setup_pool, }; const INVALID_MFA_PEER_DISCONNECT_THRESHOLD: i32 = 119; @@ -87,7 +82,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 +187,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 +260,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 +322,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] })) @@ -353,7 +348,7 @@ async fn test_create_network_with_posture_checks_requires_enterprise_license( fn location_payload( name: &str, address: &str, - location_mfa_mode: &str, + mfa_enabled: bool, service_location_mode: &str, ) -> serde_json::Value { json!({ @@ -372,11 +367,212 @@ fn location_payload( "acl_enabled": false, "acl_default_allow": false, "allowed_ips_from_acl": false, - "location_mfa_mode": location_mfa_mode, + "mfa_enabled": mfa_enabled, "service_location_mode": service_location_mode }) } +/// Create a minimal single-step MFA flow so that locations are allowed to enable MFA. +/// +/// `PUT`/`POST /api/v1/network` refuses `mfa_enabled: true` with `no_flows_exist` while no flow +/// exists globally, so any test that enables MFA on a location has to create one first. +/// +/// Returns the created flow's id so callers can assign it to a location. +async fn make_mfa_flow(client: &TestClient) -> i64 { + let response = client + .post("/api/v1/mfa-flow") + .json(&json!({ + "title": "Test MFA Flow", + "steps": [{ "methods": ["totp"] }] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response.json::().await["id"] + .as_i64() + .unwrap() +} + +/// Assign a flow as a location's default so the location can be MFA-enabled. +async fn assign_default_mfa_flow(client: &TestClient, location_id: i64, flow_id: i64) { + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({ "assignments": [{ "flow_id": flow_id, "is_default": true, "group_ids": [] }] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); +} + +/// Enabling MFA while no flow exists returns a structured `validation_failed` body that parses as +/// JSON in one step, not a JSON string wrapped inside `msg`. +#[sqlx::test] +async fn test_mfa_enabled_no_flows_structured_body(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let response = client + .post("/api/v1/network") + .json(&json!({ + "name": "no-flows", + "address": "10.9.9.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.9.9.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "mfa_enabled": true, + "service_location_mode": "disabled" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body: serde_json::Value = response.json().await; + assert_eq!(body["error"], "validation_failed"); + assert_eq!(body["fields"][0]["field"], "mfa_enabled"); + assert_eq!(body["fields"][0]["code"], "no_flows_exist"); + assert!( + body.get("msg").is_none(), + "the refusal body must not be double-encoded via msg" + ); +} + +/// Clearing assignments on an MFA-disabled location is allowed, and the location still cannot be +/// MFA-enabled afterward while no flows exist: the `no_flows_exist` precondition still guards the +/// toggle. +#[sqlx::test] +async fn test_enable_mfa_after_clear_refused_without_flows( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // Create a flow and a location, assign, then clear. + let flow_id = { + let resp = client + .post("/api/v1/mfa-flow") + .json(&json!({"title": "Flow", "steps": [{"methods": ["totp"]}]})) + .send() + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + resp.json::().await["id"] + .as_i64() + .unwrap() + }; + + let network_resp = make_network(&client, "clear-then-enable").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": [ + {"flow_id": flow_id, "is_default": true, "group_ids": []}, + ]})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Clearing is allowed on the MFA-disabled location. + let response = client + .put(format!("/api/v1/location/{location_id}/mfa-flows")) + .json(&json!({"assignments": []})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Delete the now-unassigned flow so no flows exist globally. + let response = client + .delete(format!("/api/v1/mfa-flow/{flow_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Re-enabling MFA is still refused: no flows exist to assign. + let response = client + .put(format!("/api/v1/network/{location_id}")) + .json(&json!({ + "name": "clear-then-enable", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "mfa_enabled": true, + "service_location_mode": "disabled" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await; + assert_eq!(body["fields"][0]["code"], "no_flows_exist"); +} + +/// Enabling MFA on a location that has no default flow assigned is refused with +/// `no_flows_assigned`, even when a flow exists globally. +#[sqlx::test] +async fn test_enable_mfa_without_assignment_refused(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + make_mfa_flow(&client).await; + let network_resp = make_network(&client, "no-assignment").await; + let location_id = network_resp.json::().await["id"] + .as_i64() + .unwrap(); + + let response = client + .put(format!("/api/v1/network/{location_id}")) + .json(&json!({ + "name": "no-assignment", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "mfa_enabled": true, + "service_location_mode": "disabled" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await; + assert_eq!(body["error"], "validation_failed"); + assert_eq!(body["fields"][0]["field"], "mfa_enabled"); + assert_eq!(body["fields"][0]["code"], "no_flows_assigned"); +} + /// Create a posture check and return its ID. async fn make_posture_check(client: &TestClient, name: &str) -> i64 { let response = client @@ -421,7 +617,7 @@ async fn test_modify_network_does_not_notify_gateway_when_commit_fails( .json(&location_payload( "location", "10.1.1.1/24", - "disabled", + false, "disabled", )) .send() @@ -461,7 +657,7 @@ async fn test_modify_network_does_not_notify_gateway_when_commit_fails( .json(&location_payload( "renamed-location", "10.1.1.1/24", - "disabled", + false, "disabled", )) .send() @@ -497,7 +693,7 @@ async fn test_create_network_rejects_service_location_with_mfa( .json(&location_payload( "mfa-service-location", "10.1.1.1/24", - "internal", + true, service_location_mode, )) .send() @@ -509,15 +705,10 @@ async fn test_create_network_rejects_service_location_with_mfa( ); } - // MFA without service location mode is fine + // A plain location (no service location mode, no MFA) is fine let response = client .post("/api/v1/network") - .json(&location_payload( - "mfa-only", - "10.1.1.1/24", - "internal", - "disabled", - )) + .json(&location_payload("plain", "10.1.1.1/24", false, "disabled")) .send() .await; assert_eq!(response.status(), StatusCode::CREATED); @@ -528,7 +719,7 @@ async fn test_create_network_rejects_service_location_with_mfa( .json(&location_payload( "service-location-only", "10.2.2.1/24", - "disabled", + false, "prelogon", )) .send() @@ -546,7 +737,7 @@ async fn test_network_rejects_zero_keepalive_interval(_: PgPoolOptions, options: authenticate_admin(&mut client).await; set_enterprise_license(); - let mut payload = location_payload("zero-keepalive", "10.1.1.1/24", "disabled", "disabled"); + let mut payload = location_payload("zero-keepalive", "10.1.1.1/24", false, "disabled"); payload["keepalive_interval"] = json!(0); let response = client.post("/api/v1/network").json(&payload).send().await; assert_eq!( @@ -561,7 +752,7 @@ async fn test_network_rejects_zero_keepalive_interval(_: PgPoolOptions, options: .json(&location_payload( "good-keepalive", "10.2.2.1/24", - "disabled", + false, "disabled", )) .send() @@ -570,7 +761,7 @@ async fn test_network_rejects_zero_keepalive_interval(_: PgPoolOptions, options: let created: serde_json::Value = response.json().await; let location_id = created["id"].as_i64().unwrap(); - let mut payload = location_payload("good-keepalive", "10.2.2.1/24", "disabled", "disabled"); + let mut payload = location_payload("good-keepalive", "10.2.2.1/24", false, "disabled"); payload["keepalive_interval"] = json!(0); let response = client .put(format!("/api/v1/network/{location_id}")) @@ -608,7 +799,7 @@ async fn test_modify_network_rejects_service_location_with_mfa( .json(&location_payload( "location", "10.1.1.1/24", - "disabled", + false, "disabled", )) .send() @@ -622,7 +813,7 @@ async fn test_modify_network_rejects_service_location_with_mfa( .json(&location_payload( "location", "10.1.1.1/24", - "internal", + true, service_location_mode, )) .send() @@ -640,7 +831,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!(!fetched.mfa_enabled); assert_eq!(fetched.service_location_mode, ServiceLocationMode::Disabled); // enabling service location mode alone is accepted and persisted @@ -649,7 +840,7 @@ async fn test_modify_network_rejects_service_location_with_mfa( .json(&location_payload( "location", "10.1.1.1/24", - "disabled", + false, "prelogon", )) .send() @@ -674,7 +865,7 @@ async fn test_modify_network_without_posture_checks_keeps_assignments( let posture = make_posture_check(&client, "Posture").await; - let mut payload = location_payload("location", "10.1.1.1/24", "disabled", "disabled"); + let mut payload = location_payload("location", "10.1.1.1/24", false, "disabled"); payload["posture_checks"] = json!([posture]); let response = client.post("/api/v1/network").json(&payload).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -690,7 +881,7 @@ async fn test_modify_network_without_posture_checks_keeps_assignments( .json(&location_payload( "renamed-location", "10.1.1.1/24", - "disabled", + false, "disabled", )) .send() @@ -704,7 +895,7 @@ async fn test_modify_network_without_posture_checks_keeps_assignments( ); // an explicit `null` behaves the same way - let mut payload = location_payload("location", "10.1.1.1/24", "disabled", "disabled"); + let mut payload = location_payload("location", "10.1.1.1/24", false, "disabled"); payload["posture_checks"] = json!(null); let response = client .put(format!("/api/v1/network/{}", location.id)) @@ -731,7 +922,7 @@ async fn test_posture_checks_allowed_on_service_locations( let posture = make_posture_check(&client, "Posture").await; // create path: a service location may carry posture checks - let mut payload = location_payload("service-location", "10.1.1.1/24", "disabled", "prelogon"); + let mut payload = location_payload("service-location", "10.1.1.1/24", false, "prelogon"); payload["posture_checks"] = json!([posture]); let response = client.post("/api/v1/network").json(&payload).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -747,7 +938,7 @@ async fn test_posture_checks_allowed_on_service_locations( // modify path: turning a posture-carrying regular location into a service // location keeps its posture checks - let mut payload = location_payload("regular-location", "10.2.2.1/24", "disabled", "disabled"); + let mut payload = location_payload("regular-location", "10.2.2.1/24", false, "disabled"); payload["posture_checks"] = json!([posture]); let response = client.post("/api/v1/network").json(&payload).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -758,7 +949,7 @@ async fn test_posture_checks_allowed_on_service_locations( .json(&location_payload( "regular-location", "10.2.2.1/24", - "disabled", + false, "alwayson", )) .send() @@ -780,7 +971,7 @@ async fn test_posture_checks_allowed_on_service_locations( .json(&location_payload( "service-location-without-postures", "10.3.3.1/24", - "disabled", + false, "alwayson", )) .send() @@ -808,205 +999,6 @@ async fn test_posture_checks_allowed_on_service_locations( ); } -#[sqlx::test] -async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _client_state) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - exceed_enterprise_limits(&client).await; - - // unset the license - let license = get_cached_license().clone(); - set_cached_license(None); - - let location_data = WireguardNetworkData { - name: "test_location".into(), - address: "10.1.1.1/24".into(), - endpoint: "10.1.1.1".parse().unwrap(), - port: 55555, - allowed_ips: Some("10.1.1.0/24, 10.2.0.1/16, 10.10.10.54/32".into()), - dns: None, - mtu: DEFAULT_WIREGUARD_MTU, - fwmark: 0, - allow_all_groups: false, - allowed_groups: vec!["admin".into()], - keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, - peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, - acl_enabled: false, - acl_default_allow: false, - allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::External, - service_location_mode: ServiceLocationMode::Disabled, - posture_checks: None, - }; - - // create network - let response = client - .post("/api/v1/network") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - - // restore valid license and try again - set_cached_license(license); - let response = client - .post("/api/v1/network") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // add external OpenID provider - let provider_data = AddProviderData { - name: "test".to_owned(), - base_url: "https://accounts.google.com".to_owned(), - kind: OpenIdProviderKind::Custom, - client_id: "client_id".to_owned(), - client_secret: "client_secret".to_owned(), - display_name: Some("display_name".to_owned()), - admin_email: None, - google_service_account_email: None, - google_service_account_key: None, - directory_sync_enabled: false, - directory_sync_interval: 100, - directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), - directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), - directory_sync_target: DirectorySyncTarget::All.to_string(), - create_account: false, - okta_dirsync_client_id: None, - okta_private_jwk: None, - directory_sync_group_match: None, - username_handling: OpenIdUsernameHandling::PruneEmailDomain, - jumpcloud_api_key: None, - prefetch_users: false, - disable_password_management: false, - directory_sync_user_groups: None, - }; - - let response = client - .post("/api/v1/openid/provider") - .json(&provider_data) - .send() - .await; - - assert_eq!(response.status(), StatusCode::CREATED); - - // try again - let response = client - .post("/api/v1/network") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); -} - -#[sqlx::test] -async fn test_location_mfa_mode_validation_modify(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _client_state) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - let mut location_data = WireguardNetworkData { - name: "test_location".into(), - address: "10.1.1.254/24".into(), - endpoint: "10.1.1.1".parse().unwrap(), - port: 55555, - allowed_ips: Some("10.1.1.0/24, 10.2.0.1/16, 10.10.10.54/32".into()), - dns: None, - mtu: DEFAULT_WIREGUARD_MTU, - fwmark: 0, - allow_all_groups: false, - allowed_groups: vec!["admin".into()], - keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, - peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, - acl_enabled: false, - acl_default_allow: false, - allowed_ips_from_acl: false, - location_mfa_mode: LocationMfaMode::Disabled, - service_location_mode: ServiceLocationMode::Disabled, - posture_checks: None, - }; - - // create network - let response = client - .post("/api/v1/network") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - - exceed_enterprise_limits(&client).await; - - // unset the license - let license = get_cached_license().clone(); - set_cached_license(None); - - // attempt to modify location - location_data.location_mfa_mode = LocationMfaMode::External; - let response = client - .put("/api/v1/network/1") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - - // restore valid license and try again - set_cached_license(license); - let response = client - .put("/api/v1/network/1") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // add external OpenID provider - let provider_data = AddProviderData { - name: "test".to_owned(), - base_url: "https://accounts.google.com".to_owned(), - kind: OpenIdProviderKind::Google, - client_id: "client_id".to_owned(), - client_secret: "client_secret".to_owned(), - display_name: Some("display_name".to_owned()), - admin_email: None, - google_service_account_email: None, - google_service_account_key: None, - directory_sync_enabled: false, - directory_sync_interval: 100, - directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), - directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), - directory_sync_target: DirectorySyncTarget::All.to_string(), - create_account: false, - okta_dirsync_client_id: None, - okta_private_jwk: None, - directory_sync_group_match: None, - username_handling: OpenIdUsernameHandling::PruneEmailDomain, - jumpcloud_api_key: None, - prefetch_users: false, - disable_password_management: false, - directory_sync_user_groups: None, - }; - - let response = client - .post("/api/v1/openid/provider") - .json(&provider_data) - .send() - .await; - - assert_eq!(response.status(), StatusCode::CREATED); - - // try again - let response = client - .put("/api/v1/network/1") - .json(&location_data) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); -} - #[sqlx::test] async fn test_peer_disconnect_threshold_validation_create( _: PgPoolOptions, @@ -1016,6 +1008,7 @@ async fn test_peer_disconnect_threshold_validation_create( let (mut client, _client_state) = make_test_client(pool).await; authenticate_admin(&mut client).await; + make_mfa_flow(&client).await; let mut location_data = WireguardNetworkData { name: "test_location_disabled".into(), @@ -1033,7 +1026,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 +1039,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) @@ -1054,6 +1047,8 @@ async fn test_peer_disconnect_threshold_validation_create( .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); + // Even with a valid threshold, creating a location already MFA-enabled is refused: a new + // location has no default flow assigned yet, so enabling at create is always rejected. location_data.name = "test_location_internal_boundary".into(); location_data.peer_disconnect_threshold = MINIMUM_MFA_PEER_DISCONNECT_THRESHOLD; let response = client @@ -1061,7 +1056,9 @@ async fn test_peer_disconnect_threshold_validation_create( .json(&location_data) .send() .await; - assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await; + assert_eq!(body["fields"][0]["code"], "no_flows_assigned"); } #[sqlx::test] @@ -1073,6 +1070,7 @@ async fn test_peer_disconnect_threshold_validation_modify( let (mut client, _client_state) = make_test_client(pool).await; authenticate_admin(&mut client).await; + let flow_id = make_mfa_flow(&client).await; let mut location_data = WireguardNetworkData { name: "test_location".into(), @@ -1090,7 +1088,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, }; @@ -1102,6 +1100,10 @@ async fn test_peer_disconnect_threshold_validation_modify( .await; assert_eq!(response.status(), StatusCode::CREATED); + // Give the location a default flow so the threshold checks below operate on a + // MFA-enableable location. + assign_default_mfa_flow(&client, 1, flow_id).await; + let response = client .put("/api/v1/network/1") .json(&location_data) @@ -1109,7 +1111,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 +1366,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 +1695,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 +1723,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 +1854,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 +1944,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() @@ -1987,6 +1989,8 @@ async fn test_user_device_configs_excludes_mfa_locations( let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); + let flow_id = make_mfa_flow(&client).await; + // Create a normal location (allow_all_groups so the device is allowed) let normal_location: WireguardNetwork = client .post("/api/v1/network") @@ -2006,7 +2010,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() @@ -2014,7 +2018,8 @@ async fn test_user_device_configs_excludes_mfa_locations( .json() .await; - // Create an MFA location (internal mode, no enterprise license required). + // Create an MFA location (internal mode, no enterprise license required). It starts with MFA + // off: a new location cannot be created already enabled, so we assign a flow and enable it. let mfa_location: WireguardNetwork = client .post("/api/v1/network") .json(&json!({ @@ -2033,7 +2038,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": "internal", + "mfa_enabled": false, "service_location_mode": "disabled" })) .send() @@ -2041,6 +2046,33 @@ async fn test_user_device_configs_excludes_mfa_locations( .json() .await; + assign_default_mfa_flow(&client, mfa_location.id, flow_id).await; + + let response = client + .put(format!("/api/v1/network/{}", mfa_location.id)) + .json(&json!({ + "name": "mfa-location", + "address": "10.1.2.1/24", + "port": 55556, + "endpoint": "192.168.4.15", + "allowed_ips": "10.1.2.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": [], + "allow_all_groups": true, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "mfa_enabled": true, + "service_location_mode": "disabled" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + // Create a user device let device_payload = json!({ "name": "device", @@ -2099,7 +2131,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 +2172,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 +2215,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 +2356,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 +2425,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 +2532,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 +2604,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 +2684,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 +2755,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 8cfe863a3f..34e4fe976a 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, }, }, }, @@ -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 082a52047d..7fac70aaa0 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 3def41a129..7d04a32e53 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/description.rs b/crates/defguard_event_logger/src/description.rs index b58e27608c..5842f03186 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -306,6 +306,23 @@ 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::LocationMfaFlowsAssigned { + location_name, + assignments, + .. + } => Some(format!( + "Assigned {} MFA flow(s) to location '{location_name}'", + assignments.len() + )), 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 3cf68f0d6c..52f408fec5 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -635,6 +635,32 @@ 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::LocationMfaFlowsAssigned { + location_id, + location_name, + assignments, + } => ( + EventType::LocationMfaFlowsAssigned, + serde_json::to_value(serde_json::json!({ + "location_id": location_id, + "location_name": location_name, + "assignments": assignments, + })) + .ok(), + ), ApiEventType::DevicePostureDuplicated { original, duplicate, diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 288ded831f..981c353a6b 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -8,10 +8,12 @@ use defguard_common::db::{ WebAuthn, WireguardNetwork, gateway::Gateway, group::Group, + mfa_flow::{LocationMfaFlowAssignmentSnapshot, MfaFlow, MfaFlowSnapshot, MfaFlowStep}, oauth2client::OAuth2Client, proxy::Proxy, settings::set_settings, - wireguard::{LocationMfaMode, ServiceLocationMode}, + vpn_client_session::VpnClientMfaMethod, + wireguard::ServiceLocationMode, }, }; use defguard_core::{ @@ -69,7 +71,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()]) @@ -422,6 +424,43 @@ fn api_event_cases() -> Vec { location_ids: Vec::new(), }; + let mfa_flow_snapshot = MfaFlowSnapshot { + flow: MfaFlow { + id: 1, + title: "Strong MFA".into(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }, + steps: vec![MfaFlowStep { + id: 1, + flow_id: 1, + position: 0, + methods: vec![VpnClientMfaMethod::Totp], + }], + }; + let mfa_flow_snapshot2 = MfaFlowSnapshot { + flow: MfaFlow { + id: 1, + title: "Stronger MFA".into(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }, + steps: vec![ + MfaFlowStep { + id: 1, + flow_id: 1, + position: 0, + methods: vec![VpnClientMfaMethod::Totp], + }, + MfaFlowStep { + id: 2, + flow_id: 1, + position: 1, + methods: vec![VpnClientMfaMethod::Email], + }, + ], + }; + let cases = vec![ EventTestCase { name: "UserLogin", @@ -1136,6 +1175,58 @@ fn api_event_cases() -> Vec { module: ActivityLogModule::Posture, description_contains: Some("Assigned"), }, + EventTestCase { + name: "MfaFlowCreated", + message: api_message(ApiEventType::MfaFlowCreated { + snapshot: mfa_flow_snapshot.clone(), + }), + event_type: EventType::MfaFlowCreated, + module: ActivityLogModule::Defguard, + description_contains: Some("Created MFA flow"), + }, + EventTestCase { + name: "MfaFlowUpdated", + message: api_message(ApiEventType::MfaFlowUpdated { + before: mfa_flow_snapshot.clone(), + after: mfa_flow_snapshot2.clone(), + }), + event_type: EventType::MfaFlowUpdated, + module: ActivityLogModule::Defguard, + description_contains: Some("Updated MFA flow"), + }, + EventTestCase { + name: "MfaFlowDeleted", + message: api_message(ApiEventType::MfaFlowDeleted { + snapshot: mfa_flow_snapshot.clone(), + }), + event_type: EventType::MfaFlowDeleted, + module: ActivityLogModule::Defguard, + description_contains: Some("Deleted MFA flow"), + }, + EventTestCase { + name: "LocationMfaFlowsAssigned", + message: api_message(ApiEventType::LocationMfaFlowsAssigned { + location_id: location.id, + location_name: location.name.clone(), + assignments: vec![ + LocationMfaFlowAssignmentSnapshot { + flow_id: 1, + position: 0, + is_default: false, + group_ids: vec![7], + }, + LocationMfaFlowAssignmentSnapshot { + flow_id: 2, + position: 1, + is_default: true, + group_ids: vec![], + }, + ], + }), + event_type: EventType::LocationMfaFlowsAssigned, + module: ActivityLogModule::Defguard, + description_contains: Some("MFA flow"), + }, ]; assert_eq!( diff --git a/crates/defguard_gateway_manager/src/handler.rs b/crates/defguard_gateway_manager/src/handler.rs index 8c9a89d6ac..14baa8de7c 100644 --- a/crates/defguard_gateway_manager/src/handler.rs +++ b/crates/defguard_gateway_manager/src/handler.rs @@ -691,7 +691,7 @@ impl GatewayUpdatesHandler { network: &WireguardNetwork, pool: Option<&PgPool>, ) -> bool { - if network.mfa_enabled() { + if network.mfa_enabled { return true; } @@ -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, + mfa_enabled, ServiceLocationMode::Disabled, ) .with_id(1) @@ -1205,7 +1205,7 @@ mod tests { false, false, false, - LocationMfaMode::default(), + false, // mfa_enabled ServiceLocationMode::default(), ) .set_address([ @@ -1336,21 +1336,21 @@ 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); let mut handler = GatewayUpdatesHandler::new(network.id, network, "gateway".into(), None, events_rx, tx); - handler.session_authorization_required = handler.network.mfa_enabled(); + handler.session_authorization_required = handler.network.mfa_enabled; handler } #[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 3234824de4..91033b338f 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,12 +211,12 @@ 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 .expect("failed to enable MFA for test network"); - assert!(network.mfa_enabled()); + assert!(network.mfa_enabled); } pub(crate) async fn enable_linux_posture_for_network( diff --git a/crates/defguard_proto/src/lib.rs b/crates/defguard_proto/src/lib.rs index 8e32212149..c2d584e694 100644 --- a/crates/defguard_proto/src/lib.rs +++ b/crates/defguard_proto/src/lib.rs @@ -148,8 +148,6 @@ impl From for CoreError { impl From for client_types::DeviceConfig { fn from(config: DeviceConfig) -> Self { - // DEPRECATED(1.5): superseeded by location_mfa_mode - let mfa_enabled = config.location_mfa_mode == LocationMfaMode::Internal; Self { network_id: config.network_id, network_name: config.network_name, @@ -160,14 +158,14 @@ impl From for client_types::DeviceConfig { allowed_ips: config.allowed_ips.as_csv(), dns: config.dns, keepalive_interval: config.keepalive_interval, + // DEPRECATED(1.5): superseeded by location_mfa_mode #[allow(deprecated)] - mfa_enabled, - location_mfa_mode: Some( - >::into( - config.location_mfa_mode, - ) - .into(), - ), + mfa_enabled: config.mfa_enabled, + // Absent when the location's flow configuration has no legacy equivalent. Legacy + // client gating for that case is tracked separately (#3042). + location_mfa_mode: config.location_mfa_mode.map(|mode| { + >::into(mode).into() + }), service_location_mode: Some( >::into( config.service_location_mode, diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index 506d5cd1de..a1f550fc8a 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -253,11 +253,18 @@ impl EnrollmentServer { error!("Failed to get enterprise settings: {err}"); Status::internal("unexpected error") })?; - // check if any locations enforce internal MFA + // Check if any MFA-enabled location has at least one flow step that + // includes an internal MFA method (totp, email, biometric, or + // mobileapprove). The boolean mfa_enabled cannot distinguish + // internal from OIDC-only, so the predicate inspects the actual + // flow shape. let instance_has_internal_mfa = query_scalar!( "SELECT EXISTS( \ - SELECT 1 FROM wireguard_network \ - WHERE location_mfa_mode = 'internal'::location_mfa_mode \ + SELECT 1 FROM wireguard_network wn \ + JOIN location_mfa_flow lmf ON lmf.location_id = wn.id \ + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id \ + WHERE wn.mfa_enabled = true \ + AND mfs.methods && ARRAY['totp','email','biometric','mobileapprove']::vpn_client_mfa_method[] \ ) \"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 fbfc93d5dd..8fb206a53d 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 @@ -10,11 +10,12 @@ use defguard_common::{ Id, NoId, models::{ Device, DeviceType, User, WireguardNetwork, + mfa_flow::{LocationMfaFlowAssignment, MfaFlow}, polling_token::PollingToken, settings::{Settings, update_current_settings}, user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, - vpn_client_session::VpnClientSession, - wireguard::{LocationMfaMode, ServiceLocationMode}, + vpn_client_session::{VpnClientMfaMethod, VpnClientSession}, + wireguard::ServiceLocationMode, }, }, secret::SecretStringWrapper, @@ -186,7 +187,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") @@ -431,13 +432,41 @@ pub(crate) async fn start_enrollment_session(context: &mut HandlerTestContext, t } } -/// Insert a WireGuard network with `LocationMfaMode::Internal`, returning the -/// saved `WireguardNetwork`. Use this for any test that exercises the MFA -/// flow (the default `create_network` uses `LocationMfaMode::Disabled`). +/// Assign a single-step MFA flow to a location so that `MfaFlow::derive_legacy_mode` yields a +/// legacy mode for it. +/// +/// `mfa_enabled` alone is no longer enough: the legacy mode is derived from the location's flow +/// configuration, and a location with no flows derives `None`, which the MFA start path refuses. +async fn assign_legacy_mfa_flow( + pool: &PgPool, + location_id: Id, + title: &str, + methods: Vec, +) { + let mut conn = pool.acquire().await.expect("failed to acquire connection"); + let (flow, _steps) = MfaFlow::create(&mut conn, title.to_owned(), vec![methods]) + .await + .expect("failed to create test mfa flow"); + MfaFlow::assign_to_location( + &mut conn, + location_id, + &[LocationMfaFlowAssignment { + flow_id: flow.id, + is_default: true, + group_ids: Vec::new(), + }], + ) + .await + .expect("failed to assign test mfa flow to location"); +} + +/// Insert a WireGuard network that derives the legacy `Internal` MFA mode, returning the saved +/// `WireguardNetwork`. Use this for any test that exercises the MFA flow (the default +/// `create_network` leaves MFA disabled). pub(crate) async fn create_mfa_network(pool: &PgPool) -> WireguardNetwork { static NET_CTR: AtomicU16 = AtomicU16::new(0); let network_number = NET_CTR.fetch_add(1, Ordering::Relaxed); - WireguardNetwork::new( + let network = WireguardNetwork::new( format!("test-mfa-network-{network_number}"), 41820 + i32::from(network_number % 10_000), "10.1.0.1".to_owned(), @@ -447,21 +476,37 @@ 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") .expect("failed to set mfa network address") .save(pool) .await - .expect("failed to save test mfa wireguard network") + .expect("failed to save test mfa wireguard network"); + + // The full internal method set is what derives `LocationMfaMode::Internal`. + assign_legacy_mfa_flow( + pool, + network.id, + &format!("test-internal-mfa-flow-{network_number}"), + vec![ + VpnClientMfaMethod::Totp, + VpnClientMfaMethod::Email, + VpnClientMfaMethod::Biometric, + VpnClientMfaMethod::MobileApprove, + ], + ) + .await; + + network } -/// Insert a WireGuard network with `LocationMfaMode::External`. +/// Insert a WireGuard network that derives the legacy `External` MFA mode. pub(crate) async fn create_external_mfa_network(pool: &PgPool) -> WireguardNetwork { static NET_CTR: AtomicU16 = AtomicU16::new(0); let network_number = NET_CTR.fetch_add(1, Ordering::Relaxed); - WireguardNetwork::new( + let network = WireguardNetwork::new( format!("test-ext-mfa-network-{network_number}"), 31820 + i32::from(network_number % 10_000), "10.2.0.1".to_owned(), @@ -471,14 +516,25 @@ 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") .expect("failed to set ext mfa network address") .save(pool) .await - .expect("failed to save test external mfa wireguard network") + .expect("failed to save test external mfa wireguard network"); + + // A lone OIDC method is what derives `LocationMfaMode::External`. + assign_legacy_mfa_flow( + pool, + network.id, + &format!("test-external-mfa-flow-{network_number}"), + vec![VpnClientMfaMethod::Oidc], + ) + .await; + + network } /// Enable email MFA for `user`, returning the currently-valid MFA code. diff --git a/crates/defguard_session_manager/src/lib.rs b/crates/defguard_session_manager/src/lib.rs index 48c06fa7dd..a689d07c2f 100644 --- a/crates/defguard_session_manager/src/lib.rs +++ b/crates/defguard_session_manager/src/lib.rs @@ -220,7 +220,7 @@ impl SessionManager { ); let session_authorization_required = - location.mfa_enabled() || location.has_postures(&mut *transaction).await?; + location.mfa_enabled || location.has_postures(&mut *transaction).await?; // get all connected sessions which have become inactive let inactive_sessions = diff --git a/crates/defguard_session_manager/src/session_state.rs b/crates/defguard_session_manager/src/session_state.rs index 95bbf012f8..a7a821d479 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 385c431b23..f0a5b991c9 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 5d4f773a9b..2d898075e9 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 f75580b87d..8a36da341c 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 c2f1ffdd73..21bf0c4df3 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}, }, }, @@ -304,8 +303,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 +330,28 @@ pub async fn set_mfa_settings( )) })?; - network.location_mfa_mode = mfa_settings.mfa_mode; + // Enforce the precondition: MFA cannot be enabled until a default flow is assigned to the + // location, so "enabled with no policy" is unrepresentable. This is the same check + // `create_network` and `modify_network` apply, shared rather than reimplemented so the three + // entry points cannot drift. Validated before saving, so a refusal writes nothing. + if let Some(response) = defguard_core::handlers::wireguard::validate_mfa_flows_exist( + &pool, + mfa_settings.mfa_enabled, + Some(first_network_id), + ) + .await? + { + return Ok(response); + } + + 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 + "Auto-adoption MFA settings applied to network_id={} mfa_enabled={}", + 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 7637b498bf..e9ef7be0cb 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()]) @@ -177,7 +177,7 @@ async fn test_auto_adoption_full_flow(_: PgPoolOptions, options: PgConnectOption let resp = client .post("/api/v1/initial_setup/auto_wizard/mfa_settings") - .json(&json!({ "vpn_mfa_mode": "disabled" })) + .json(&json!({ "mfa_enabled": false })) .send() .await .expect("Failed to set MFA settings"); @@ -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!(!updated_network.mfa_enabled); let resp = client .get("/api/v1/initial_setup/auto_adoption") @@ -309,7 +309,7 @@ async fn test_auto_adoption_auth_enforcement(_: PgPoolOptions, options: PgConnec .post(format!( "{base_url}/api/v1/initial_setup/auto_wizard/mfa_settings" )) - .json(&json!({ "vpn_mfa_mode": "disabled" })) + .json(&json!({ "mfa_enabled": false })) .header(USER_AGENT, "test/0.0") .send() .await @@ -580,3 +580,85 @@ async fn test_attempt_auto_adoption_persists_actionable_gateway_failure_logs( gateway_result.logs ); } + +/// The wizard must reject enabling MFA when no flow exists to assign. +#[sqlx::test] +async fn test_auto_adoption_mfa_enabled_requires_flow(_: PgPoolOptions, options: PgConnectOptions) { + init_tracing_once(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + seed_wireguard_network(&pool).await; + Wizard::init(&pool, true, &DefGuardConfig::new_test_config()) + .await + .expect("Failed to init wizard"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + // Progress wizard through to the MFA settings step. + let resp = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Mfa", + "last_name": "Test", + "username": "mfa_test_admin", + "email": "mfa_test@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin"); + assert_eq!(resp.status(), StatusCode::CREATED); + + client + .post("/api/v1/initial_setup/auto_wizard/internal_url_settings") + .json(&json!({"defguard_url": "https://mfa-test.example.com", "ssl_type": "none"})) + .send() + .await + .expect("Failed"); + client + .post("/api/v1/initial_setup/auto_wizard/external_url_settings") + .json( + &json!({"public_proxy_url": "https://proxy.mfa-test.example.com", "ssl_type": "none"}), + ) + .send() + .await + .expect("Failed"); + client + .post("/api/v1/initial_setup/auto_wizard/vpn_settings") + .json(&json!({ + "vpn_public_ip": "6.6.6.6", + "vpn_wireguard_port": 51820, + "vpn_gateway_address": "10.11.0.1/24", + "vpn_allowed_ips": "0.0.0.0/0", + "vpn_dns_server_ip": "8.8.8.8" + })) + .send() + .await + .expect("Failed"); + + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::MfaSettings).await; + + // Enabling MFA with no flows must be rejected. + let resp = client + .post("/api/v1/initial_setup/auto_wizard/mfa_settings") + .json(&json!({ "mfa_enabled": true })) + .send() + .await + .expect("Failed to set MFA settings"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "validation_failed"); + assert_eq!(body["fields"][0]["field"], "mfa_enabled"); + assert_eq!(body["fields"][0]["code"], "no_flows_exist"); + + // Disabling MFA is still allowed (mfa_enabled=false with no flows is fine). + let resp = client + .post("/api/v1/initial_setup/auto_wizard/mfa_settings") + .json(&json!({ "mfa_enabled": false })) + .send() + .await + .expect("Failed to set MFA settings"); + assert_eq!(resp.status(), StatusCode::CREATED); +} 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 5482a3a4f6..aec030b71b 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()]) @@ -519,7 +519,7 @@ async fn test_auto_adoption_full_flow_new_url_steps(_: PgPoolOptions, options: P let resp = client .post("/api/v1/initial_setup/auto_wizard/mfa_settings") - .json(&json!({ "vpn_mfa_mode": "disabled" })) + .json(&json!({ "mfa_enabled": false })) .send() .await .unwrap(); diff --git a/crates/defguard_setup/tests/integration/wizard_state.rs b/crates/defguard_setup/tests/integration/wizard_state.rs index 59446faf94..fdb1fbe049 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()]) @@ -271,7 +271,7 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt let resp = client .post("/api/v1/initial_setup/auto_wizard/mfa_settings") - .json(&json!({ "vpn_mfa_mode": "disabled" })) + .json(&json!({ "mfa_enabled": false })) .send() .await .expect("Failed to set MFA settings"); diff --git a/e2e/tests/gatewayAdoption.spec.ts b/e2e/tests/gatewayAdoption.spec.ts index d73ed58951..b5f337c4fb 100644 --- a/e2e/tests/gatewayAdoption.spec.ts +++ b/e2e/tests/gatewayAdoption.spec.ts @@ -43,7 +43,7 @@ test.describe('Gateway Adoption', () => { acl_enabled: false, acl_default_allow: false, allowed_ips_from_acl: false, - location_mfa_mode: 'disabled', + mfa_enabled: false, service_location_mode: 'disabled', }, }); diff --git a/e2e/types.ts b/e2e/types.ts index 2d4be58414..0d764e0bd3 100644 --- a/e2e/types.ts +++ b/e2e/types.ts @@ -69,7 +69,7 @@ export type NetworkForm = { port: string; allowed_ips?: string[]; dns?: string; - location_mfa_mode?: string; + mfa_enabled?: boolean; }; export type DeviceForm = { diff --git a/e2e/utils/controllers/vpn/createNetwork.ts b/e2e/utils/controllers/vpn/createNetwork.ts index 29a32db95b..865e073cbb 100644 --- a/e2e/utils/controllers/vpn/createNetwork.ts +++ b/e2e/utils/controllers/vpn/createNetwork.ts @@ -37,18 +37,8 @@ export const createRegularLocation = async (browser: Browser, network: NetworkFo await page.getByTestId('continue').click(); - if (network.location_mfa_mode) { - switch (network.location_mfa_mode) { - case 'internal': - await page.getByTestId('enforce-internal-mfa').click(); - break; - case 'external': - await page.getByTestId('enforce-external-mfa').click(); - break; - default: - await page.getByTestId('do-not-enforce-mfa').click(); - break; - } + if (network.mfa_enabled) { + await page.getByTestId('toggle-mfa').click(); } await page.getByTestId('finish').click(); 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 0000000000..ff223116e9 --- /dev/null +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.down.sql @@ -0,0 +1,76 @@ +-- Recreate the legacy enum type (dropped in the up migration). Safe to re-run +-- because the type may still exist when testing against a pre-A2 database. +DROP TYPE IF EXISTS location_mfa_mode; +CREATE TYPE location_mfa_mode AS ENUM ('disabled', 'internal', 'external'); + +-- Add the legacy column. Nullable because the next steps populate it. +ALTER TABLE wireguard_network ADD COLUMN location_mfa_mode location_mfa_mode; + +-- Disabled is the safe default: mfa_enabled=false or no flows assigned. +UPDATE wireguard_network +SET location_mfa_mode = 'disabled' +WHERE NOT mfa_enabled + OR id NOT IN (SELECT location_id FROM location_mfa_flow); + +-- Derive a best-effort legacy mode from the existing flow configuration, +-- using the same shape logic as MfaFlow::derive_legacy_mode. +-- +-- A location is legacy-derivable when exactly one flow is assigned and that +-- flow has exactly one step whose method set is the full internal set +-- ({totp, email, biometric, mobileapprove}) or exactly {oidc}. + +-- Internal: single-flow, single-step, full internal method set. +UPDATE wireguard_network wn +SET location_mfa_mode = 'internal' +WHERE wn.mfa_enabled + AND wn.location_mfa_mode IS NULL + AND (SELECT COUNT(*) FROM location_mfa_flow WHERE location_id = wn.id) = 1 + AND (SELECT COUNT(*) FROM location_mfa_flow lmf + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id + WHERE lmf.location_id = wn.id) = 1 + AND EXISTS ( + SELECT 1 FROM location_mfa_flow lmf + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id + WHERE lmf.location_id = wn.id + AND mfs.methods @> ARRAY['totp','email','biometric','mobileapprove']::vpn_client_mfa_method[] + AND ARRAY['totp','email','biometric','mobileapprove']::vpn_client_mfa_method[] @> mfs.methods + ); + +-- External: single-flow, single-step, OIDC only. +UPDATE wireguard_network wn +SET location_mfa_mode = 'external' +WHERE wn.mfa_enabled + AND wn.location_mfa_mode IS NULL + AND (SELECT COUNT(*) FROM location_mfa_flow WHERE location_id = wn.id) = 1 + AND (SELECT COUNT(*) FROM location_mfa_flow lmf + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id + WHERE lmf.location_id = wn.id) = 1 + AND EXISTS ( + SELECT 1 FROM location_mfa_flow lmf + JOIN mfa_flow_step mfs ON mfs.flow_id = lmf.flow_id + WHERE lmf.location_id = wn.id + AND mfs.methods @> ARRAY['oidc']::vpn_client_mfa_method[] + AND ARRAY['oidc']::vpn_client_mfa_method[] @> mfs.methods + ); + +-- Remaining mfa_enabled=true locations have no legacy-equivalent shape +-- (multi-flow, multi-step, or subset-of-internal-methods). +-- Best-effort fallback: internal. This loses fidelity for multi-step and +-- multi-flow configurations, which collapse to the single internal mode. +-- The same is true for subset-of-internal-methods configurations whose +-- available method set is narrower than the full internal set. The +-- overriding goal is to never leave an MFA-enforcing location as disabled. +UPDATE wireguard_network +SET location_mfa_mode = 'internal' +WHERE mfa_enabled AND location_mfa_mode IS NULL; + +-- All rows are now populated. +ALTER TABLE wireguard_network ALTER COLUMN location_mfa_mode SET NOT NULL; + +-- Drop the flow tables and mfa_enabled, which are no longer needed after the +-- repopulation above. +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 new file mode 100644 index 0000000000..0c83784664 --- /dev/null +++ b/migrations/20260811125537_[2.2.0]_mfa_flow.up.sql @@ -0,0 +1,84 @@ +-- MFA Flow config: entity table +CREATE TABLE mfa_flow ( + id bigserial PRIMARY KEY, + title text NOT NULL, + created_at timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 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), + CONSTRAINT mfa_flow_step_flow_position_unique UNIQUE (flow_id, position) +); +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), + CONSTRAINT location_mfa_flow_position_unique UNIQUE (location_id, position) +); + +-- At most one assignment per location may be flagged default. The API enforces "exactly one", +-- this index enforces "at most one" so a bug or a race cannot leave two defaults. +CREATE UNIQUE INDEX idx_location_mfa_flow_single_default + ON location_mfa_flow (location_id) + WHERE is_default; + +-- 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 +); + +-- 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'; + +ALTER TABLE wireguard_network DROP COLUMN location_mfa_mode; +DROP TYPE location_mfa_mode; diff --git a/web/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json index 23295b6be7..e5652aff5d 100644 --- a/web/messages/en/initial_wizard.json +++ b/web/messages/en/initial_wizard.json @@ -117,12 +117,6 @@ "initial_setup_auto_adoption_step_mfa_setup_description": "You can enable Multi-Factor Authentication (MFA) for your VPN.", "initial_setup_auto_adoption_step_summary_label": "Summary", "initial_setup_auto_adoption_step_summary_description": "Everything is set up and ready to go!", - "initial_setup_auto_adoption_mfa_option_disabled_title": "Do not enforce MFA", - "initial_setup_auto_adoption_mfa_option_internal_title": "Internal Defguard Multi-Factor Authentication", - "initial_setup_auto_adoption_mfa_option_internal_content": "Uses the MFA methods configured in your Defguard profile.", - "initial_setup_auto_adoption_mfa_option_internal_warning": "After completing the initial DefGuard setup, configure MFA in your profile to enable it.", - "initial_setup_auto_adoption_mfa_option_external_title": "External Identity Provider Authentication", - "initial_setup_auto_adoption_mfa_option_external_content": "Requires configuring an external identity provider in the settings, such as Google, Microsoft Entra ID, Okta, or JumpCloud.", "initial_setup_auto_adoption_url_settings_defguard_description": "Enter the URL you've set up for Defguard, including the port if needed. Defguard needs to be reachable at this address on your internal or VPN network and it should not be exposed to the internet. Once setup is complete, you'll be redirected there automatically. Note that if you configure SSL below, Defguard will serve HTTPS on the same port it's currently running on.", "initial_setup_auto_adoption_url_settings_public_proxy_description": "The Edge component is meant to be exposed publicly to the Internet. It handles various tasks, such as enabling automated user enrollment and sending automated configuration updates to desktop and mobile clients. It requires a dedicated URL and must be publicly accessible on the Internet. You can change the public URL later in General Settings.", "initial_setup_auto_adoption_internal_url_settings_url_description": "Enter the URL you've set up for Defguard, including the port if needed. Defguard needs to be reachable at this address on your internal or VPN network and it should not be exposed to the internet. Once setup is complete, you'll be redirected there automatically. Note that if you configure SSL below, Defguard will serve HTTPS on the same port it's currently running on.", diff --git a/web/messages/en/location.json b/web/messages/en/location.json index 08a0de2288..14a0e6a976 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -46,11 +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_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.", - "add_location_mfa_external_title": "External Identity Provider Authentication", - "add_location_mfa_external_content": "Requires configuring an external identity provider in the settings, such as Google, Microsoft Entra ID, Okta, or JumpCloud.", + "add_location_mfa_toggle_label": "Enforce Multi-Factor Authentication for this location", "add_location_postures_assign": "Assign posture check to newly created location", "add_location_postures_dont_assign": "Don’t add any posture check for location", "add_location_postures_create_postures_warning": "You need to create at least one posture check in order to assign it to the location. You can do this in the Posture Checks section and add it to the current location later.", @@ -64,9 +60,6 @@ "location_col_fwmark": "FWMark", "location_col_mtu": "MTU", "location_col_allowed_groups": "Allowed groups", - "location_mfa_none": "No MFA", - "location_mfa_external": "External", - "location_mfa_internal": "Internal", "location_mfa_label_client_disconnect_threshold": "Client disconnect threshold (seconds)", "location_mfa_helper_client_disconnect_threshold": "The Client disconnect threshold (seconds) is the inactivity timeout after which Defguard drops a VPN connection. If no WireGuard® traffic is detected for this duration, the client is disconnected and will be required to re-authorize via MFA when reconnecting.", "location_allowed_groups_all": "All allowed", @@ -95,8 +88,6 @@ "location_form_helper_name": "This will be visible on the dashboard and in the desktop and mobile clients to help users identify where they are connecting.", "location_form_label_endpoint": "Gateway IP address or domain name", "location_form_helper_endpoint": "", - "location_mfa_option_internal": "Internal MFA", - "location_mfa_option_external": "External MFA", "location_mfa_service_location_warning": "MFA can't be enabled for service locations. To enforce MFA, select the Regular location type.", "location_edit_section_location_type": "Location type", "location_service_mode_mfa_warning": "MFA-protected locations can't be set as service locations. Disable MFA to use a service location type.", diff --git a/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx index 3e292a66a8..88404f7490 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 { useEffect, 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) { + 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 + /> )}