diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 29b8239c91..5403bca308 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## 📝 New contributors -- [ ] I have read, understand, and agree to the Contributor Agreement. By checking this box, I confirm I have the right to contribute this work and I grant Defguard sp. z o.o. the necessary rights to use my contribution as outlined in the full agreement.: https://tnt.sh/s/defguard-contribution-agreement +- [ ] I have read, understand, and agree to the Contributor Agreement. By checking this box, I confirm I have the right to contribute this work and I grant Defguard sp. z o.o. the necessary rights to use my contribution as outlined in the full agreement.: https://github.com/DefGuard/defguard/blob/stable/2.x/CLA.md ⚠︎ If the checkbox will not be confirmed - we can't include your contribution in our codebase. diff --git a/.sqlx/query-15c8f3ee6b25004e0fb071d16139af6b458b04bc4f9a79cc4e61cae24ab01bfc.json b/.sqlx/query-15c8f3ee6b25004e0fb071d16139af6b458b04bc4f9a79cc4e61cae24ab01bfc.json new file mode 100644 index 0000000000..fc44efc0a6 --- /dev/null +++ b/.sqlx/query-15c8f3ee6b25004e0fb071d16139af6b458b04bc4f9a79cc4e61cae24ab01bfc.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_directory_identity (user_id, provider_id, external_id) VALUES ($1, $2, $3) ON CONFLICT (user_id, provider_id) DO UPDATE SET external_id = $3 RETURNING id, user_id, provider_id, external_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "provider_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "external_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "15c8f3ee6b25004e0fb071d16139af6b458b04bc4f9a79cc4e61cae24ab01bfc" +} diff --git a/.sqlx/query-25fc8e2aa0f499a5de8d74289cc88cbc388f0ffa8cb01431bc1614134b6351a8.json b/.sqlx/query-25fc8e2aa0f499a5de8d74289cc88cbc388f0ffa8cb01431bc1614134b6351a8.json new file mode 100644 index 0000000000..42a4f1d3f2 --- /dev/null +++ b/.sqlx/query-25fc8e2aa0f499a5de8d74289cc88cbc388f0ffa8cb01431bc1614134b6351a8.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, provider_id, external_id FROM user_directory_identity WHERE user_id = $1 AND provider_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "provider_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "external_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "25fc8e2aa0f499a5de8d74289cc88cbc388f0ffa8cb01431bc1614134b6351a8" +} diff --git a/.sqlx/query-2d151847217947a7ac4579ae8bb50c851535ff09e16de1629c3239e49d73f8a8.json b/.sqlx/query-2d151847217947a7ac4579ae8bb50c851535ff09e16de1629c3239e49d73f8a8.json new file mode 100644 index 0000000000..243019eb9e --- /dev/null +++ b/.sqlx/query-2d151847217947a7ac4579ae8bb50c851535ff09e16de1629c3239e49d73f8a8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS( SELECT 1 FROM wireguard_network wn WHERE NOT EXISTS( SELECT 1 FROM device_posture_location dpl WHERE dpl.location_id = wn.id ) 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": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2d151847217947a7ac4579ae8bb50c851535ff09e16de1629c3239e49d73f8a8" +} diff --git a/.sqlx/query-3dbd4e294c27eb97e30350859dae06de9b667f396a9895f012e4ba0723db50f0.json b/.sqlx/query-3dbd4e294c27eb97e30350859dae06de9b667f396a9895f012e4ba0723db50f0.json new file mode 100644 index 0000000000..8baf19a468 --- /dev/null +++ b/.sqlx/query-3dbd4e294c27eb97e30350859dae06de9b667f396a9895f012e4ba0723db50f0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"user\" SET recovery_codes = '{}' WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3dbd4e294c27eb97e30350859dae06de9b667f396a9895f012e4ba0723db50f0" +} diff --git a/.sqlx/query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json b/.sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json similarity index 62% rename from .sqlx/query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json rename to .sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json index ce38b0f991..6e400d69b3 100644 --- a/.sqlx/query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json +++ b/.sqlx/query-3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, smtp_authentication = $15, smtp_oauth_issuer_url = $16, smtp_oauth_client_id = $17, smtp_oauth_client_secret = $18, smtp_oauth_refresh_token = $19, enrollment_vpn_step_optional = $20, enrollment_welcome_message = $21, enrollment_welcome_email = $22, enrollment_welcome_email_subject = $23, enrollment_use_welcome_message_as_email = $24, enrollment_send_welcome_email = $25, uuid = $26, ldap_url = $27, ldap_bind_username = $28, ldap_bind_password = $29, ldap_group_search_base = $30, ldap_user_search_base = $31, ldap_user_obj_class = $32, ldap_group_obj_class = $33, ldap_username_attr = $34, ldap_groupname_attr = $35, ldap_group_member_attr = $36, ldap_member_attr = $37, ldap_use_starttls = $38, ldap_tls_verify_cert = $39, openid_create_account = $40, license = $41, gateway_disconnect_notifications_enabled = $42, gateway_disconnect_notifications_inactivity_threshold = $43, gateway_disconnect_notifications_reconnect_notification_enabled = $44, ldap_sync_status = $45, ldap_enabled = $46, ldap_sync_enabled = $47, ldap_is_authoritative = $48, ldap_sync_interval = $49, ldap_user_auxiliary_obj_classes = $50, ldap_uses_ad = $51, ldap_user_rdn_attr = $52, ldap_sync_groups = $53, ldap_remote_enrollment_enabled = $54, ldap_remote_enrollment_send_invite = $55, openid_username_handling = $56, defguard_url = $57, default_admin_group_name = $58, authentication_period_days = $59, mfa_code_timeout_seconds = $60, public_proxy_url = $61, default_admin_id = $62, secret_key = $63, openid_signing_key_der = $64, enable_stats_purge = $65, stats_purge_frequency_hours = $66, stats_purge_threshold_days = $67, enrollment_token_timeout_hours = $68, password_reset_token_timeout_hours = $69, enrollment_session_timeout_minutes = $70, password_reset_session_timeout_minutes = $71, ldap_sync_account_status = $72, ldap_disable_password_management = $73, smtp_oauth_tenant_id = $74, smtp_tls_verify_cert = $75 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, smtp_authentication = $15, smtp_oauth_issuer_url = $16, smtp_oauth_client_id = $17, smtp_oauth_client_secret = $18, smtp_oauth_refresh_token = $19, enrollment_vpn_step_optional = $20, enrollment_welcome_message = $21, enrollment_welcome_email = $22, enrollment_welcome_email_subject = $23, enrollment_use_welcome_message_as_email = $24, enrollment_send_welcome_email = $25, enrollment_display_welcome_message = $26, uuid = $27, ldap_url = $28, ldap_bind_username = $29, ldap_bind_password = $30, ldap_group_search_base = $31, ldap_user_search_base = $32, ldap_user_obj_class = $33, ldap_group_obj_class = $34, ldap_username_attr = $35, ldap_groupname_attr = $36, ldap_group_member_attr = $37, ldap_member_attr = $38, ldap_use_starttls = $39, ldap_tls_verify_cert = $40, openid_create_account = $41, license = $42, gateway_disconnect_notifications_enabled = $43, gateway_disconnect_notifications_inactivity_threshold = $44, gateway_disconnect_notifications_reconnect_notification_enabled = $45, ldap_sync_status = $46, ldap_enabled = $47, ldap_sync_enabled = $48, ldap_is_authoritative = $49, ldap_sync_interval = $50, ldap_user_auxiliary_obj_classes = $51, ldap_uses_ad = $52, ldap_user_rdn_attr = $53, ldap_sync_groups = $54, ldap_remote_enrollment_enabled = $55, ldap_remote_enrollment_send_invite = $56, openid_username_handling = $57, defguard_url = $58, default_admin_group_name = $59, authentication_period_days = $60, mfa_code_timeout_seconds = $61, public_proxy_url = $62, default_admin_id = $63, secret_key = $64, openid_signing_key_der = $65, enable_stats_purge = $66, stats_purge_frequency_hours = $67, stats_purge_threshold_days = $68, enrollment_token_timeout_hours = $69, password_reset_token_timeout_hours = $70, enrollment_session_timeout_minutes = $71, password_reset_session_timeout_minutes = $72, ldap_sync_account_status = $73, ldap_disable_password_management = $74, smtp_oauth_tenant_id = $75, smtp_tls_verify_cert = $76 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -52,6 +52,7 @@ "Text", "Bool", "Bool", + "Bool", "Uuid", "Text", "Text", @@ -127,5 +128,5 @@ }, "nullable": [] }, - "hash": "a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e" + "hash": "3e465851dfa15717c0546a2e2fbbd332de9e8014d6cb1df0ba17fdf6d3a44ef4" } diff --git a/.sqlx/query-4e56d679555dcaa12b341673cb37d47d60a29d7da6e2fe36d2afaf07156b31be.json b/.sqlx/query-4e56d679555dcaa12b341673cb37d47d60a29d7da6e2fe36d2afaf07156b31be.json new file mode 100644 index 0000000000..f00c443c1b --- /dev/null +++ b/.sqlx/query-4e56d679555dcaa12b341673cb37d47d60a29d7da6e2fe36d2afaf07156b31be.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM \"user\" u JOIN group_user gu ON gu.user_id = u.id JOIN \"group\" g ON gu.group_id = g.id WHERE u.id = $1 AND u.is_active AND g.\"name\" IN (SELECT * FROM UNNEST($2::text[])))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4e56d679555dcaa12b341673cb37d47d60a29d7da6e2fe36d2afaf07156b31be" +} diff --git a/.sqlx/query-4eff9d8ff25cf6e593614007e4dc5b67690a2377004d34a901650f2e91db1f2c.json b/.sqlx/query-4eff9d8ff25cf6e593614007e4dc5b67690a2377004d34a901650f2e91db1f2c.json new file mode 100644 index 0000000000..6cb1b29041 --- /dev/null +++ b/.sqlx/query-4eff9d8ff25cf6e593614007e4dc5b67690a2377004d34a901650f2e91db1f2c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_id FROM user_directory_identity WHERE provider_id = $1 AND external_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4eff9d8ff25cf6e593614007e4dc5b67690a2377004d34a901650f2e91db1f2c" +} diff --git a/.sqlx/query-dabc9db014c9d8416c0721db030bd0b2df0e0e8ae3546ef6f92d248cec389ad5.json b/.sqlx/query-5bdcdb353fdea5254f37ca9bc7e94b5edef58b0748f6eb53e0492503b0ded34f.json similarity index 79% rename from .sqlx/query-dabc9db014c9d8416c0721db030bd0b2df0e0e8ae3546ef6f92d248cec389ad5.json rename to .sqlx/query-5bdcdb353fdea5254f37ca9bc7e94b5edef58b0748f6eb53e0492503b0ded34f.json index da4bfc72c8..9f8d00fdfc 100644 --- a/.sqlx/query-dabc9db014c9d8416c0721db030bd0b2df0e0e8ae3546ef6f92d248cec389ad5.json +++ b/.sqlx/query-5bdcdb353fdea5254f37ca9bc7e94b5edef58b0748f6eb53e0492503b0ded34f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT admin_device_management, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\", only_client_activation, display_download_step, display_password_reset FROM \"enterprisesettings\" WHERE id = 1", + "query": "SELECT admin_device_management, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\", only_client_activation, disable_tunnels, display_download_step, display_password_reset FROM \"enterprisesettings\" WHERE id = 1", "describe": { "columns": [ { @@ -31,11 +31,16 @@ }, { "ordinal": 3, - "name": "display_download_step", + "name": "disable_tunnels", "type_info": "Bool" }, { "ordinal": 4, + "name": "display_download_step", + "type_info": "Bool" + }, + { + "ordinal": 5, "name": "display_password_reset", "type_info": "Bool" } @@ -48,8 +53,9 @@ false, false, false, + false, false ] }, - "hash": "dabc9db014c9d8416c0721db030bd0b2df0e0e8ae3546ef6f92d248cec389ad5" + "hash": "5bdcdb353fdea5254f37ca9bc7e94b5edef58b0748f6eb53e0492503b0ded34f" } diff --git a/.sqlx/query-621b0cc73cb20b63869a88484c884b1a0eacb767cec6723c6fcd16f4377b3735.json b/.sqlx/query-621b0cc73cb20b63869a88484c884b1a0eacb767cec6723c6fcd16f4377b3735.json new file mode 100644 index 0000000000..111ff05960 --- /dev/null +++ b/.sqlx/query-621b0cc73cb20b63869a88484c884b1a0eacb767cec6723c6fcd16f4377b3735.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_directory_identity WHERE user_id = $1 AND provider_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "621b0cc73cb20b63869a88484c884b1a0eacb767cec6723c6fcd16f4377b3735" +} diff --git a/.sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json b/.sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json new file mode 100644 index 0000000000..41428929e1 --- /dev/null +++ b/.sqlx/query-64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT group_id, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" FROM group_client_traffic_policy ORDER BY group_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "64e2cd0e9e56065007eeac8cf038b68cb9f0fd6d689f3eb34de23f5859797ca2" +} diff --git a/.sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json b/.sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json new file mode 100644 index 0000000000..e3fba057ac --- /dev/null +++ b/.sqlx/query-93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM group_client_traffic_policy", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "93ddc32a014dc4e232842416fa6b1b277daa23b14073edba46e4f9bde969bb91" +} diff --git a/.sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json b/.sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json new file mode 100644 index 0000000000..3d90726fee --- /dev/null +++ b/.sqlx/query-a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63.json @@ -0,0 +1,51 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_client_traffic_policy (group_id, client_traffic_policy) VALUES ($1, $2) ON CONFLICT (group_id) DO UPDATE SET client_traffic_policy = EXCLUDED.client_traffic_policy RETURNING group_id, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8", + { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a7fe7af3323984a260dbc3fab701781eebf0ffb1687bc97d0f42e0a97f420d63" +} diff --git a/.sqlx/query-a95174e19c53d1b9ed330da92898112f7dd42775456d99fdab5e8bc2f1ffac92.json b/.sqlx/query-a95174e19c53d1b9ed330da92898112f7dd42775456d99fdab5e8bc2f1ffac92.json new file mode 100644 index 0000000000..715f31fa38 --- /dev/null +++ b/.sqlx/query-a95174e19c53d1b9ed330da92898112f7dd42775456d99fdab5e8bc2f1ffac92.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO device_posture (name) VALUES ($1) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a95174e19c53d1b9ed330da92898112f7dd42775456d99fdab5e8bc2f1ffac92" +} diff --git a/.sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json b/.sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json new file mode 100644 index 0000000000..4eb0915b44 --- /dev/null +++ b/.sqlx/query-bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM group_client_traffic_policy WHERE group_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bb5d85fbd4f8cfa89b452e7cb1d359a8568238ea6b2bdc2f48933369609f00ac" +} diff --git a/.sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json b/.sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json new file mode 100644 index 0000000000..1bef7bdca8 --- /dev/null +++ b/.sqlx/query-c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78.json @@ -0,0 +1,39 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT gctp.group_id, gctp.client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" FROM group_client_traffic_policy gctp JOIN group_user gu ON gu.group_id = gctp.group_id WHERE gu.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "group_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "c355c9c90181e0da78602ec73c4cbcc6a6226737625c37347d4d907703552d78" +} diff --git a/.sqlx/query-c471ac17a519490c139b05a603ddfa30e5ff7e37553b73a75ca79de524c0cf4c.json b/.sqlx/query-c471ac17a519490c139b05a603ddfa30e5ff7e37553b73a75ca79de524c0cf4c.json new file mode 100644 index 0000000000..300361770a --- /dev/null +++ b/.sqlx/query-c471ac17a519490c139b05a603ddfa30e5ff7e37553b73a75ca79de524c0cf4c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM \"user\" u WHERE u.id = $1 AND u.is_active)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c471ac17a519490c139b05a603ddfa30e5ff7e37553b73a75ca79de524c0cf4c" +} diff --git a/.sqlx/query-dd8c22fcb987041a34ee5091ba7ba01bc6727437c7c6aee51fd66ae3c121be16.json b/.sqlx/query-dd8c22fcb987041a34ee5091ba7ba01bc6727437c7c6aee51fd66ae3c121be16.json new file mode 100644 index 0000000000..a628c1e51d --- /dev/null +++ b/.sqlx/query-dd8c22fcb987041a34ee5091ba7ba01bc6727437c7c6aee51fd66ae3c121be16.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO device_posture_location (posture_id, location_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dd8c22fcb987041a34ee5091ba7ba01bc6727437c7c6aee51fd66ae3c121be16" +} diff --git a/.sqlx/query-98a56e24232072cd077a73905e326f76c57218db49e9fcbbafd580443ebd87e8.json b/.sqlx/query-e815862412333871e08a862a9c15b1d41a836bbabc4975689c61727e96eebf2e.json similarity index 76% rename from .sqlx/query-98a56e24232072cd077a73905e326f76c57218db49e9fcbbafd580443ebd87e8.json rename to .sqlx/query-e815862412333871e08a862a9c15b1d41a836bbabc4975689c61727e96eebf2e.json index e2bd7000f2..99b31573dd 100644 --- a/.sqlx/query-98a56e24232072cd077a73905e326f76c57218db49e9fcbbafd580443ebd87e8.json +++ b/.sqlx/query-e815862412333871e08a862a9c15b1d41a836bbabc4975689c61727e96eebf2e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"enterprisesettings\" SET admin_device_management = $1, client_traffic_policy = $2, only_client_activation = $3, display_download_step = $4, display_password_reset = $5 WHERE id = 1", + "query": "UPDATE \"enterprisesettings\" SET admin_device_management = $1, client_traffic_policy = $2, only_client_activation = $3, disable_tunnels = $4, display_download_step = $5, display_password_reset = $6 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -20,10 +20,11 @@ }, "Bool", "Bool", + "Bool", "Bool" ] }, "nullable": [] }, - "hash": "98a56e24232072cd077a73905e326f76c57218db49e9fcbbafd580443ebd87e8" + "hash": "e815862412333871e08a862a9c15b1d41a836bbabc4975689c61727e96eebf2e" } diff --git a/.sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json b/.sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json new file mode 100644 index 0000000000..dcc9537ad3 --- /dev/null +++ b/.sqlx/query-eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM \"group\" WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "eefacbad5c1a6edcb81c569bc287d864722cff2cf1ea01507edf40ba12cdbd69" +} diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000000..c22de4180a --- /dev/null +++ b/CLA.md @@ -0,0 +1,171 @@ +**Individual Contributor Assignment Agreement** + +Thank you for your interest in contributing to defguard project owned by Defguard sp. z o.o., Cyfrowa 6/317, 71-441 Szczecin, Poland KRS: 0001168794 NIP (*tax ID*): 8513329206, ("We" or "Us"). + +This Contributor Agreement ("Agreement") details the rights you grant Us. **Checking the acceptance box (e.g., on GitHub) confirms your agreement and grants Us the extensive licenses** detailed herein (Note: Full copyright assignment requires a separate "wet ink" signature under Polish law). While this digital acceptance is sufficient for granting the necessary license, **we also welcome and appreciate contributors who choose to complete the full copyright assignment by signing this document physically and sending it to Us** via mail or email. The choice of method is entirely yours. + +This is a legally binding document, so please read it carefully before agreeing to it. The Agreement may cover more than one software project managed by Us. + +**1. Definitions** + +"**You**" means the individual who Submits a Contribution to Us. + +"**Contribution**" means any work of authorship that is Submitted by You to Us in which You own or assert ownership of the Copyright. The Contribution was made available to Us on GitHub in digital form by providing data to a profile on the GitHub platform. The information on the GitHub platform confirms specific task data. + +"**Copyright**" means all rights protecting works of authorship owned or controlled by You, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence including any extensions by You. + +"**Material**" means the work of authorship which is made available by Us to third parties. When this Agreement covers more than one software project, the Material means the work of authorship to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. + +"**Submit**" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Material, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +"**Submission Date**" means the date on which You Submit a Contribution to Us. + +"**Effective Date**" means the date You execute this Agreement or the date You first Submit a Contribution to Us, whichever is earlier. + +"**Media**" means any portion of a Contribution which is not software. + +**2. Grant of Rights** + +**2.1 Copyright Assignment and license** + +**(a)** At the time the Contribution is Submitted, You assign to Us all right, title, and interest worldwide in all Copyright covering the Contribution; provided that this transfer is conditioned upon compliance with Section 2.4. + +**(b)** To the extent that any of the rights in Section 2.1(a) cannot be assigned by You to Us, You grant to Us a perpetual, worldwide, exclusive, royalty-free, transferable, irrevocable license under such non-assigned rights, with rights to sublicense through multiple tiers of sublicensees, to practice such non-assigned rights, including, but not limited to, the right to reproduce, modify, display, perform and distribute the Contribution; provided that this license is conditioned upon compliance with Section 2.3. + +**(c)** To the extent that any of the rights in Section 2.1(b) cannot be assigned by You to Us, You grant to Us a perpetual, worldwide, non-exclusive, royalty-free, transferable, irrevocable license under such non-assigned rights, with rights to sublicense through multiple tiers of sublicensees, to practice such non-assigned rights, including, but not limited to, the right to reproduce, modify, display, perform and distribute the Contribution; provided that this license is conditioned upon compliance with Section 2.3. + +**(d)** To the extent that any of the rights in Section 2.1(a), Section 2.1(b), or Section 2.1(c) can neither be assigned nor licensed by You to Us, You irrevocably waive and agree never to assert such rights against Us, any of our successors in interest, or any of our licensees, either direct or indirect; provided that this agreement not to assert is conditioned upon compliance with Section 2.3. + +**(e)** Upon such transfer of rights to Us, to the maximum extent possible, We immediately grant to You a perpetual, worldwide, non-exclusive, royalty-free, transferable, irrevocable license under such rights covering the Contribution, with rights to sublicense through multiple tiers of sublicensees, to reproduce, modify, display, perform, and distribute the Contribution. The intention of the parties is that this license will be as broad as possible and to provide You with rights as similar as possible to the owner of the rights that You transferred. This license granted back is limited to the Contribution and does not provide any rights to the Material. + +**2.2 Fields of use** + +The assignment, and in the case specified in Section 2.1(b) or Section 2.1(c) of the Agreement license of the Copyright and/or the relevant licenses to the Contribution are granted within all the fields of exploitation, in particular including: + +**(1)** with respect to the Contribution constituting computer programs in the fields of exploitation listed in Article 74(4) of the Act of February 4, 1994, on Copyright and Related Rights and indicated below: + +**(i)** in terms of recording and reproducing the Contribution - production by any technique; + +**(ii)** in terms of trading in the original or copies of the Contribution - including marketing, lending, renting the original, selling licenses or copies of the Work itself; + +**(iii)** placing on the market recording media of all types, including e.g. CDs, DVDs, Blu-ray, as well as publications based on the Contribution or with its use; + +**(iv)** any distribution, including entering the records of the Contribution into the memory of computers and computer network servers, including those generally available such as the Internet, and making them available to users of such networks; + +**(v)** transfer or transmission of records of the Contribution between computers, servers and users (beneficiaries), other recipients, by all kinds of means and techniques; + +**(vi)** making the Contribution available to the public, both against payment and free of charge, during presentations, conferences, lectures and in such a way that everyone can have access to it at a place and time chosen by them, including telecommunications and computer networks or in connection with the provision of telecommunications services, including the use of interactive services for this purpose; + +**(vii)** use of the Contribution by Us, Ours employees or associates as well as contractors, other users and related entities. + +**(2)** with respect to Contribution other than computer programs in the fields of exploitation listed in Article 50 of the Act of February 4, 1994, on Copyright and indicated below: + +**(i)** recording of the Contribution on any data media; + +**(ii)** displaying, using, transferring, entering and storing the Contribution in the memory of a computer, artificial intelligence engine, and other devices; + +**(iii)** translations, adaptation, rearrangement or any other change to the Contribution; + +**(iv)** installing and uninstalling Contribution on computers owned or indicated by Us; + +**(v)** permanent and/ or temporary reproduction, copying of both the whole and part of the Contribution by any technical means using any techniques and on any number of carriers, including making back-up copies of the Contribution that can be used simultaneously with the Contribution; + +**(vi)** displaying, using, transmitting, storing the Contribution in the memory of a computer and other devices; + +**(vii)** using, displaying, transmitting and storing regardless of format, system or standard; + +**(viii)** creating new versions and adaptations (translation, adaptation, rearrangement or any other changes); + +**(ix)** public dissemination, in particular making it available in such a way that everyone can have access to it at a place and time chosen by them, in particular electronic sharing on request as well as lending and renting; + +**(x)** disseminating, publishing on the Internet and in closed networks; + +**(xi)** entering data, updating data, deleting data, exporting data; + +**(xii)** receiving the source code for the Contribution and the right to use it for the maintenance and development of the Contribution. + +whereby it is the intention of the Parties to transfer as fully as possible the author's economic rights in the Contribution to Us with respect to both the subject of the transfer and the fields of exploitation. In the event that the Fields of Exploitation specified in the above section prove to be insufficient for the purposes of performing this Agreement, the Parties agree to amend the Agreement in order to expand the Fields of Exploitation. The expansion of the Fields of Exploitation will not be accompanied by any additional remuneration. + +**2.3 Patent License** + +For patent claims including, without limitation, method, process, and apparatus claims which You own, control or have the right to grant, now or in the future, You grant to Us a perpetual, *worldwide*, non-exclusive, transferable, royalty-free, irrevocable patent license, with the right to sublicense these rights to multiple tiers of sublicensees, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with the Material (and portions of such combination). This license is granted only to the extent that the exercise of the licensed rights infringes such patent claims; and provided that this license is conditioned upon compliance with Section 2.4. + +**2.4 Outbound License** + +Based on the grant of rights in Sections 2.1 and 2.3, if We include Your Contribution in a Material, We may license the Contribution under any license, including copyleft, permissive, commercial, or proprietary licenses. + +**2.5 Moral Rights** + +If moral rights apply to the Contribution, to the maximum extent permitted by law, You waive and agree not to assert such moral rights against Us or our successors in interest, or any of our licensees, either direct or indirect. + +**2.6 Our Rights** + +You acknowledge that We are not obligated to use Your Contribution as part of the Material and may decide to include only those Contributions We deem appropriate. + +**2.7 Derivative Rights** + +You agree and authorize Us to make any changes, alterations, updates, additions and other modifications to the Contribution, hereinafter referred to as "**Derivative Work(s)**" on the Fields of Exploitation, by Us or other third parties on Our behalf. All rights, including author's economic rights to the Derivative Works made by Us, shall be owned by Us. You consent to the use of the Derivative Works and disposition of the rights to them by Us. We may authorize other entities in the scope specified above. The abovementioned provision shall constitute the assignment of Your right to exercise derivative copyrights to Us. + +**2.8 Reservation of Rights** + +Any rights not expressly assigned or licensed under this section are expressly reserved by You. + +**3. License** + +**3.1 Grant of the License** + +If, under applicable law or due to the nature of the Contribution, such assignment is not valid or enforceable (in whole or in part), the You grant to Us a non-exclusive, irrevocable, royalty-free, sublicensable, worldwide license to use, reproduce, distribute, publicly display, modify, sublicense, and prepare derivative works with regard to the Contribution for a period of fifteen (15) years from the date of submission. This license shall automatically renew for successive five (5) year periods unless You provide Us with written notice of Your intent to opt out of such renewal no later than ninety (90) days prior to the end of the current license term. + +**3.2 Second tier License** + +If and to the extent that any provision of the license granted above is held to be invalid, unenforceable, or inapplicable under applicable law, including but not limited to Polish copyright law, such license shall automatically be limited, narrowed, or otherwise modified to the minimum extent necessary for it to be valid and enforceable in that jurisdiction. The remainder of the license shall remain in full force and effect. + +This clause is intended to preserve the maximum legally permissible scope of rights granted to Us in the Contribution, consistent with Your intent as expressed herein. + +**4. Waiver of claims** + +To the fullest extent permitted by applicable law, and in the event that neither the assignment of Copyright nor the license granted above is valid, enforceable, or effective for any reason, You hereby irrevocably waive any and all present or future claims against Us, our affiliates, licensees, successors, and assignees, arising out of or relating to the use, reproduction, modification, distribution, sublicensing, or other exploitation of the Contribution or any Derivative Works. + +This waiver includes, but is not limited to, any claims for compensation, attribution, damages, or injunctive relief, and applies regardless of the form of action, whether contractual, tortious, statutory, or otherwise. + +Nothing in this clause shall be construed as a waiver of rights that cannot be waived under applicable law. + +**5. Representations and Warranties** + +You confirm that: + +**(a)** You have the legal authority to enter into this Agreement. + +**(b)** You represent and warrant that the Copyright in the work has been created by You, or You own the Copyright and patent claims covering the Contribution which are required to grant the rights under Section 2. + +**(c)** You represent and warrant that the Contribution will not infringe the rights or protected interests of any third parties, in particular intellectual property rights or personal rights. + +**(d)** the Copyright will not be subject to any third-party rights or claims; + +**(e)** The grant of rights under Section 2 does not violate any grant of rights which You have made to third parties, including Your employer. If You are an employee, You have had Your employer approve this Agreement or sign the Entity version of this document. If You are less than eighteen years old, please have Your parents or guardian sign the Agreement. + +**6. Disclaimer** + +EXCEPT FOR THE EXPRESS WARRANTIES IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US AND BY US TO YOU. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION TO THE MINIMUM PERIOD PERMITTED BY LAW. + +**7. Consequential Damage Waiver** + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU OR US BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. + +**8. Miscellaneous** + +**(a)** This Agreement will be governed by and construed in accordance with the laws of Poland excluding its conflicts of law provisions. Under certain circumstances, the governing law in this section might be superseded by the United Nations Convention on Contracts for the International Sale of Goods ("**UN Convention**") and the parties intend to avoid the application of the UN Convention to this Agreement and, thus, exclude the application of the UN Convention in its entirety to this Agreement. + +**(b)** This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings. + +**(c)** If You or We assign the rights or obligations received through this Agreement to a third party, as a condition of the assignment, that third party must agree in writing to abide by all the rights and obligations in the Agreement. + +**(d)** The failure of either party to require performance by the other party of any provision of this Agreement in one situation shall not affect the right of a party to require such performance at any time in the future. A waiver of performance under a provision in one situation shall not be considered a waiver of the performance of the provision in the future or a waiver of the provision in its entirety. + +**(e)** If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and which is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. + +--- + +By commenting "I have read the Individual Contributor Assignment Agreement and I hereby sign the Individual Contributor Assignment Agreement" on a pull +request, You accept and agree to the terms above for Your present and future +Contributions submitted to Defguard. diff --git a/Cargo.lock b/Cargo.lock index 8525588458..b57351eede 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,9 +88,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ammonia" -version = "4.1.3" +version = "4.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b9d3370580a12f4b7a10fdcc18b28942c083ba570e3d954fe59d10951b85a2" +checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d" dependencies = [ "cssparser", "html5ever", @@ -287,6 +287,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -317,7 +327,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -895,9 +905,9 @@ checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" [[package]] name = "clap" -version = "4.6.2" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -917,14 +927,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1405,6 +1415,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "defguard" version = "0.0.0" @@ -1569,6 +1597,7 @@ dependencies = [ "webauthn-authenticator-rs", "webauthn-rs", "webauthn-rs-proto", + "wiremock", "x25519-dalek", ] @@ -2770,6 +2799,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2896,9 +2931,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -3132,9 +3167,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" dependencies = [ "crossbeam-deque", "globset", @@ -3433,9 +3468,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -3449,9 +3484,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", @@ -3897,6 +3932,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -4401,9 +4446,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -4411,9 +4456,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -4421,9 +4466,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", @@ -4434,9 +4479,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", ] @@ -5260,7 +5305,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -5736,7 +5781,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -6460,9 +6505,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -6597,7 +6642,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -6625,9 +6670,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "libc", @@ -6647,9 +6692,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -6691,9 +6736,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -6739,9 +6784,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -6751,13 +6796,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -7044,9 +7090,9 @@ dependencies = [ [[package]] name = "trait-variant" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" dependencies = [ "proc-macro2", "quote", @@ -7845,6 +7891,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -7963,18 +8032,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 063aa04d2d..a617250bfe 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, fs::read_to_string, sync::{Arc, Mutex, RwLock}, }; @@ -7,12 +8,13 @@ use anyhow::bail; use bytes::Bytes; use defguard_common::{ CARGO_VERSION, VERSION, - config::{Command, DefGuardConfig, SERVER_CONFIG}, + config::{Command, DefGuardConfig, ManageCommand, SERVER_CONFIG}, db::{ init_db, models::{ - ActiveWizard, Certificates, Settings, Wizard, + ActiveWizard, Certificates, Settings, User, Wizard, gateway::Gateway, + group::{Group, Permission}, proxy::Proxy, settings::{initialize_current_settings, update_current_settings}, }, @@ -22,9 +24,11 @@ use defguard_common::{ types::proxy::ProxyControlMessage, }; use defguard_core::{ + add_user_to_group, auth::failed_login::FailedLoginMap, - change_user_password, + change_user_password, create_admin_user, create_new_group, db::AppEvent, + disable_ldap_integration, disable_oidc_directory_sync, enterprise::{ activity_log_stream::activity_log_stream_manager::run_activity_log_stream_manager, license::{License, run_periodic_license_check, set_cached_license}, @@ -33,7 +37,7 @@ use defguard_core::{ events::{ApiEvent, BidiStreamEvent}, gateway_config, grpc::{WorkerState, run_grpc_server}, - init_dev_env, init_vpn_location, run_web_server, + init_dev_env, init_vpn_location, run_web_server, set_admin_group, setup_logs::CoreSetupLogLayer, utility_thread::run_utility_thread, version::IncompatibleComponents, @@ -126,10 +130,89 @@ async fn main() -> Result<(), anyhow::Error> { let config = gateway_config(&pool, args).await?; println!("{config:?}"); } - Command::ChangePassword(args) => { - change_user_password(&pool, args).await?; - println!("Password for user {} changed", args.username); - } + Command::Manage(command) => match command { + ManageCommand::CreateAdmin(args) => { + create_admin_user(&pool, args).await?; + println!("Admin user '{}' created successfully.", args.username); + } + ManageCommand::ChangePassword(args) => { + change_user_password(&pool, args).await?; + println!( + "Password for user '{}' changed successfully.", + args.username + ); + } + ManageCommand::ListUsers => { + let mut admin_ids = HashSet::new(); + for group in Group::find_by_permission(&pool, Permission::IsAdmin).await? { + for member in group.members(&pool).await? { + admin_ids.insert(member.id); + } + } + let users = User::all(&pool).await?; + let username_header = "Username"; + let username_width = users + .iter() + .map(|user| user.username.len()) + .max() + .unwrap_or(0) + .max(username_header.len()); + println!("{username_header: { + let name = set_admin_group(&pool, args).await?; + println!("Group '{name}' is now a Defguard admin group."); + } + ManageCommand::CreateGroup(args) => { + create_new_group(&pool, args).await?; + println!("Group '{}' created successfully.", args.name); + } + ManageCommand::ListGroups => { + let groups = Group::all(&pool).await?; + let id_header = "ID"; + let name_header = "Group name"; + let id_width = groups + .iter() + .map(|group| group.id) + .max() + .map_or(0, |id| id.ilog10() as usize + 1) + .max(id_header.len()); + let name_width = groups + .iter() + .map(|group| group.name.len()) + .max() + .unwrap_or(0) + .max(name_header.len()); + println!( + "{id_header: { + let group_name = add_user_to_group(&pool, args).await?; + println!("User '{}' added to group '{group_name}'.", args.username); + } + ManageCommand::DisableLdapIntegration => { + disable_ldap_integration(&pool).await?; + println!( + "LDAP integration disabled. Make sure your Defguard instance is offline \ + when running this command for the change to persist." + ); + } + ManageCommand::DisableOidcDirectorySync => { + disable_oidc_directory_sync(&pool).await?; + println!("OIDC external identity provider directory sync disabled."); + } + }, } // return early @@ -209,6 +292,8 @@ async fn main() -> Result<(), anyhow::Error> { unbounded_channel::(); let (ldap_tx, ldap_rx) = unbounded_channel(); let (dirsync_tx, dirsync_rx) = unbounded_channel(); + let (gateway_connection_event_tx, gateway_connection_event_rx) = unbounded_channel(); + let (proxy_connection_event_tx, proxy_connection_event_rx) = unbounded_channel(); // Activity log stream setup let (activity_log_messages_tx, activity_log_messages_rx) = broadcast::channel::(100); @@ -256,7 +341,8 @@ async fn main() -> Result<(), anyhow::Error> { ldap_tx.clone(), dirsync_tx.clone(), api_event_tx.clone(), - ), + ) + .with_connection_events(proxy_connection_event_tx), Arc::clone(&incompatible_components), proxy_control_rx, proxy_secret_key, @@ -264,7 +350,8 @@ async fn main() -> Result<(), anyhow::Error> { let mut gateway_manager = GatewayManager::new( pool.clone(), - GatewayTxSet::new(gateway_tx.clone(), peer_stats_tx), + GatewayTxSet::new(gateway_tx.clone(), peer_stats_tx) + .with_connection_events(gateway_connection_event_tx), ); debug!("Resetting proxy connection state on startup"); @@ -316,6 +403,8 @@ async fn main() -> Result<(), anyhow::Error> { session_manager_event_rx, ldap_rx, dirsync_rx, + gateway_connection_event_rx, + proxy_connection_event_rx, activity_log_stream_reload_notify.clone(), activity_log_messages_tx.clone() ) => bail!("Activity log event logger returned early: {res:?}"), diff --git a/crates/defguard_common/Cargo.toml b/crates/defguard_common/Cargo.toml index 45e3a45cb0..c37b118856 100644 --- a/crates/defguard_common/Cargo.toml +++ b/crates/defguard_common/Cargo.toml @@ -44,6 +44,12 @@ webauthn-rs.workspace = true x25519-dalek.workspace = true url = "2.5" +[features] +# Test-only helpers (e.g. the mock SMTP server) meant to be shared with other +# crates' test suites. Enable via `[dev-dependencies]` so it never reaches +# production builds. +test-support = ["tokio/net", "tokio/io-util"] + [dev-dependencies] matches.workspace = true diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 0067228767..c92c5a1b30 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -221,16 +221,39 @@ pub enum Command { InitVpnLocation(InitVpnLocationArgs), #[command(about = "Output the gateway gRPC configuration payload for a VPN location by ID.")] GatewayConfig(GatewayConfigArgs), - #[command(about = "Change a user's password.")] - ChangePassword(ChangePasswordArgs), + #[command( + subcommand, + arg_required_else_help = true, + about = "Manage users, groups, and external identity provider integrations." + )] + Manage(ManageCommand), } -#[derive(Args, Debug, Clone)] -pub struct ChangePasswordArgs { - #[arg(long)] - pub username: String, - #[arg(long)] - pub password: String, +#[derive(Clone, Debug, Subcommand)] +pub enum ManageCommand { + #[command(about = "Create a new admin user account and add it to an admin group.")] + CreateAdmin(CreateAdminArgs), + #[command(about = "Change a user's password.")] + ChangePassword(ChangePasswordArgs), + #[command(about = "List all users.")] + ListUsers, + #[command( + arg_required_else_help = true, + about = "Mark an existing group as a Defguard admin group, granting its members admin privileges." + )] + SetAdminGroup(SetAdminGroupArgs), + #[command(about = "Create a new group.")] + CreateGroup(CreateGroupArgs), + #[command(about = "List all groups and their admin status.")] + ListGroups, + #[command(about = "Add a user to a group by username.")] + AddUserToGroup(AddUserToGroupArgs), + #[command(about = "Disable the LDAP integration.")] + DisableLdapIntegration, + #[command( + about = "Disable directory synchronization for the configured OIDC external identity provider." + )] + DisableOidcDirectorySync, } #[derive(Args, Debug, Clone)] @@ -261,6 +284,53 @@ pub struct GatewayConfigArgs { pub location_id: Id, } +#[derive(Args, Debug, Clone)] +pub struct CreateAdminArgs { + #[arg(long)] + pub username: String, + #[arg(long)] + pub password: SecretString, + #[command(flatten)] + pub group: Option, +} + +#[derive(Args, Debug, Clone)] +pub struct ChangePasswordArgs { + #[arg(long)] + pub username: String, + #[arg(long)] + pub password: SecretString, +} + +#[derive(Args, Debug, Clone)] +#[group(required = false, multiple = false)] +pub struct GroupSelector { + #[arg(long)] + pub group_name: Option, + #[arg(long)] + pub group_id: Option, +} + +#[derive(Args, Debug, Clone)] +pub struct SetAdminGroupArgs { + #[command(flatten)] + pub group: GroupSelector, +} + +#[derive(Args, Debug, Clone)] +pub struct CreateGroupArgs { + #[arg(long)] + pub name: String, +} + +#[derive(Args, Debug, Clone)] +pub struct AddUserToGroupArgs { + #[arg(long)] + pub username: String, + #[command(flatten)] + pub group: GroupSelector, +} + impl DefGuardConfig { #[must_use] pub fn new() -> Self { diff --git a/crates/defguard_common/src/db/models/authentication_key.rs b/crates/defguard_common/src/db/models/authentication_key.rs index 875dd39b17..a03b71694c 100644 --- a/crates/defguard_common/src/db/models/authentication_key.rs +++ b/crates/defguard_common/src/db/models/authentication_key.rs @@ -3,10 +3,11 @@ use std::fmt; use model_derive::Model; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, Type, query_as}; +use utoipa::ToSchema; use crate::db::{Id, NoId}; -#[derive(Clone, Debug, Deserialize, Serialize, Type, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema, Type, PartialEq)] #[sqlx(type_name = "authentication_key_type", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum AuthenticationKeyType { diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 5a7e860dda..9a56447a6f 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -74,6 +74,7 @@ impl From for String { #[derive(Clone, Debug, Deserialize, FromRow, Model, Serialize, ToSchema, PartialEq)] pub struct Device { + #[schema(value_type = i64)] pub id: I, pub name: String, pub wireguard_pubkey: String, @@ -82,11 +83,9 @@ pub struct Device { #[model(enum)] pub device_type: DeviceType, pub description: Option, - /// Whether the device should be considered as setup and ready to use - /// or does it require some additional steps to be taken. Not configured devices - /// won't be sent to the gateway. It is assumed that an unconfigured device is already - /// added to all networks it should be in, but it's not ready to be used yet due to - /// e.g. public key not properly set up yet. + /// Whether the device is ready to use. Unconfigured devices are not sent to the gateway. + /// Such a device is already added to all its networks, but is still missing something, + /// for example its public key. pub configured: bool, } diff --git a/crates/defguard_common/src/db/models/gateway.rs b/crates/defguard_common/src/db/models/gateway.rs index 01317b3325..179f87a5b5 100644 --- a/crates/defguard_common/src/db/models/gateway.rs +++ b/crates/defguard_common/src/db/models/gateway.rs @@ -4,11 +4,13 @@ use chrono::{NaiveDateTime, Timelike, Utc}; use model_derive::Model; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, query, query_as, query_scalar}; +use utoipa::ToSchema; use crate::db::{Id, NoId}; -#[derive(Clone, Deserialize, Model, Serialize, PartialEq)] +#[derive(Clone, Deserialize, Model, Serialize, ToSchema, PartialEq)] pub struct Gateway { + #[schema(value_type = i64)] pub id: I, pub location_id: Id, pub name: String, diff --git a/crates/defguard_common/src/db/models/mfa_info.rs b/crates/defguard_common/src/db/models/mfa_info.rs index d18543b2c5..65a7cbf59c 100644 --- a/crates/defguard_common/src/db/models/mfa_info.rs +++ b/crates/defguard_common/src/db/models/mfa_info.rs @@ -1,12 +1,13 @@ use serde::{Deserialize, Serialize}; use sqlx::{PgPool, query_as}; +use utoipa::ToSchema; use crate::db::{ Id, models::{MFAMethod, user::User}, }; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct MFAInfo { pub mfa_method: MFAMethod, totp_available: bool, diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs index 536d159a36..d10e3dc8e7 100644 --- a/crates/defguard_common/src/db/models/proxy.rs +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -10,6 +10,7 @@ use crate::db::{Id, NoId}; #[derive(Clone, Deserialize, Model, Serialize, ToSchema, PartialEq)] pub struct Proxy { + #[schema(value_type = i64)] pub id: I, pub name: String, pub address: String, diff --git a/crates/defguard_common/src/db/models/settings/mod.rs b/crates/defguard_common/src/db/models/settings/mod.rs index 119bc4c25b..e9d51d1313 100644 --- a/crates/defguard_common/src/db/models/settings/mod.rs +++ b/crates/defguard_common/src/db/models/settings/mod.rs @@ -63,10 +63,6 @@ pub enum SettingsValidationError { CannotEnableGatewayNotifications, #[error("Cannot enable remote enrollment for LDAP. LDAP and SMTP must both be configured")] CannotEnableLdapRemoteEnrollment, - #[error( - "Cannot enable automatic invites for LDAP remote enrollment. LDAP remote enrollment is not enabled" - )] - CannotEnableLdapRemoteEnrollmentInvite, #[error("Cannot enable LDAP. Required LDAP fields are not configured")] CannotEnableLdap, #[error("Invalid defguard_url `{0}`, url has to be a domain, not IP")] @@ -125,7 +121,7 @@ pub enum OpenIdUsernameHandling { PruneEmailDomain, } -#[derive(Clone, Debug, Copy, PartialEq, Deserialize, Serialize, Default, Type)] +#[derive(Clone, Debug, Copy, PartialEq, Deserialize, Serialize, Default, ToSchema, Type)] #[sqlx(type_name = "ldap_sync_status", rename_all = "lowercase")] pub enum LdapSyncStatus { InSync, @@ -161,7 +157,8 @@ where Ok(Some(Option::deserialize(deserializer)?)) } -#[derive(Clone, Default, Deserialize, FromRow, PartialEq, Patch, Serialize)] +/// Instance settings. +#[derive(Clone, Default, Deserialize, FromRow, PartialEq, Patch, Serialize, ToSchema)] #[patch(attribute(derive(Deserialize, Serialize)))] pub struct Settings { // Modules @@ -187,12 +184,14 @@ pub struct Settings { pub enrollment_welcome_email_subject: Option, pub enrollment_use_welcome_message_as_email: bool, pub enrollment_send_welcome_email: bool, + pub enrollment_display_welcome_message: bool, // Instance UUID needed for desktop client #[serde(skip)] pub uuid: Uuid, // LDAP pub ldap_url: Option, pub ldap_bind_username: Option, + #[schema(value_type = Option)] pub ldap_bind_password: Option, pub ldap_group_search_base: Option, pub ldap_user_search_base: Option, @@ -284,6 +283,10 @@ impl fmt::Debug for Settings { "enrollment_send_welcome_email", &self.enrollment_send_welcome_email, ) + .field( + "enrollment_display_welcome_message", + &self.enrollment_display_welcome_message, + ) .field("uuid", &self.uuid) .field("ldap_url", &self.ldap_url) .field("ldap_bind_username", &self.ldap_bind_username) @@ -491,6 +494,7 @@ impl Settings { enrollment_vpn_step_optional, enrollment_welcome_message, \ enrollment_welcome_email, enrollment_welcome_email_subject, \ enrollment_use_welcome_message_as_email, enrollment_send_welcome_email, \ + enrollment_display_welcome_message, \ uuid, ldap_url, ldap_bind_username, ldap_bind_password, \ ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, \ ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, \ @@ -546,12 +550,6 @@ impl Settings { warn!("Cannot enable remote enrollment for LDAP. LDAP is not configured."); return Err(SettingsValidationError::CannotEnableLdapRemoteEnrollment); } - if self.ldap_remote_enrollment_send_invite && !self.ldap_remote_enrollment_enabled { - warn!( - "Cannot enable automatic invites for LDAP remote enrollment. LDAP remote enrollment is not enabled" - ); - return Err(SettingsValidationError::CannotEnableLdapRemoteEnrollmentInvite); - } Ok(()) } @@ -587,56 +585,57 @@ impl Settings { enrollment_welcome_email_subject = $23, \ enrollment_use_welcome_message_as_email = $24, \ enrollment_send_welcome_email = $25, \ - uuid = $26, \ - ldap_url = $27, \ - ldap_bind_username = $28, \ - ldap_bind_password = $29, \ - ldap_group_search_base = $30, \ - ldap_user_search_base = $31, \ - ldap_user_obj_class = $32, \ - ldap_group_obj_class = $33, \ - ldap_username_attr = $34, \ - ldap_groupname_attr = $35, \ - ldap_group_member_attr = $36, \ - ldap_member_attr = $37, \ - ldap_use_starttls = $38, \ - ldap_tls_verify_cert = $39, \ - openid_create_account = $40, \ - license = $41, \ - gateway_disconnect_notifications_enabled = $42, \ - gateway_disconnect_notifications_inactivity_threshold = $43, \ - gateway_disconnect_notifications_reconnect_notification_enabled = $44, \ - ldap_sync_status = $45, \ - ldap_enabled = $46, \ - ldap_sync_enabled = $47, \ - ldap_is_authoritative = $48, \ - ldap_sync_interval = $49, \ - ldap_user_auxiliary_obj_classes = $50, \ - ldap_uses_ad = $51, \ - ldap_user_rdn_attr = $52, \ - ldap_sync_groups = $53, \ - ldap_remote_enrollment_enabled = $54, \ - ldap_remote_enrollment_send_invite = $55, \ - openid_username_handling = $56, \ - defguard_url = $57, \ - default_admin_group_name = $58, \ - authentication_period_days = $59, \ - mfa_code_timeout_seconds = $60, \ - public_proxy_url = $61, \ - default_admin_id = $62, \ - secret_key = $63, \ - openid_signing_key_der = $64, \ - enable_stats_purge = $65, \ - stats_purge_frequency_hours = $66, \ - stats_purge_threshold_days = $67, \ - enrollment_token_timeout_hours = $68, \ - password_reset_token_timeout_hours = $69, \ - enrollment_session_timeout_minutes = $70, \ - password_reset_session_timeout_minutes = $71, \ - ldap_sync_account_status = $72, \ - ldap_disable_password_management = $73, \ - smtp_oauth_tenant_id = $74, \ - smtp_tls_verify_cert = $75 \ + enrollment_display_welcome_message = $26, \ + uuid = $27, \ + ldap_url = $28, \ + ldap_bind_username = $29, \ + ldap_bind_password = $30, \ + ldap_group_search_base = $31, \ + ldap_user_search_base = $32, \ + ldap_user_obj_class = $33, \ + ldap_group_obj_class = $34, \ + ldap_username_attr = $35, \ + ldap_groupname_attr = $36, \ + ldap_group_member_attr = $37, \ + ldap_member_attr = $38, \ + ldap_use_starttls = $39, \ + ldap_tls_verify_cert = $40, \ + openid_create_account = $41, \ + license = $42, \ + gateway_disconnect_notifications_enabled = $43, \ + gateway_disconnect_notifications_inactivity_threshold = $44, \ + gateway_disconnect_notifications_reconnect_notification_enabled = $45, \ + ldap_sync_status = $46, \ + ldap_enabled = $47, \ + ldap_sync_enabled = $48, \ + ldap_is_authoritative = $49, \ + ldap_sync_interval = $50, \ + ldap_user_auxiliary_obj_classes = $51, \ + ldap_uses_ad = $52, \ + ldap_user_rdn_attr = $53, \ + ldap_sync_groups = $54, \ + ldap_remote_enrollment_enabled = $55, \ + ldap_remote_enrollment_send_invite = $56, \ + openid_username_handling = $57, \ + defguard_url = $58, \ + default_admin_group_name = $59, \ + authentication_period_days = $60, \ + mfa_code_timeout_seconds = $61, \ + public_proxy_url = $62, \ + default_admin_id = $63, \ + secret_key = $64, \ + openid_signing_key_der = $65, \ + enable_stats_purge = $66, \ + stats_purge_frequency_hours = $67, \ + stats_purge_threshold_days = $68, \ + enrollment_token_timeout_hours = $69, \ + password_reset_token_timeout_hours = $70, \ + enrollment_session_timeout_minutes = $71, \ + password_reset_session_timeout_minutes = $72, \ + ldap_sync_account_status = $73, \ + ldap_disable_password_management = $74, \ + smtp_oauth_tenant_id = $75, \ + smtp_tls_verify_cert = $76 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -663,6 +662,7 @@ impl Settings { self.enrollment_welcome_email_subject, self.enrollment_use_welcome_message_as_email, self.enrollment_send_welcome_email, + self.enrollment_display_welcome_message, self.uuid, self.ldap_url, self.ldap_bind_username, @@ -1409,6 +1409,27 @@ mod test { )); } + /// Regression test for https://github.com/DefGuard/defguard/issues/3394 + /// + /// Disabling LDAP remote enrollment while the dependent "send invite" option + /// is still set must not fail validation. The value is left untouched - the + /// email-sending path guards on both flags, so no invite is sent regardless. + #[test] + fn test_validate_accepts_send_invite_when_remote_enrollment_disabled() { + let mut settings = Settings { + defguard_url: "https://defguard.example.com".into(), + ldap_remote_enrollment_enabled: false, + ldap_remote_enrollment_send_invite: true, + ..Default::default() + }; + + assert!( + settings.validate().is_ok(), + "disabling remote enrollment must not fail validation when send invite is still set" + ); + assert!(settings.ldap_remote_enrollment_send_invite); + } + #[test] #[allow(deprecated)] fn test_apply_from_config_invalid_secret_key_generates_new() { diff --git a/crates/defguard_common/src/db/models/settings/smtp.rs b/crates/defguard_common/src/db/models/settings/smtp.rs index c4a9f0aff0..ce25615b94 100644 --- a/crates/defguard_common/src/db/models/settings/smtp.rs +++ b/crates/defguard_common/src/db/models/settings/smtp.rs @@ -3,11 +3,12 @@ use std::fmt; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgExecutor, Type, query}; use struct_patch::Patch; +use utoipa::ToSchema; use super::deserialize_optional_field; use crate::secret::SecretStringWrapper; -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Type)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, ToSchema, Type)] #[sqlx(type_name = "smtp_authentication", rename_all = "lowercase")] pub enum SmtpAuthentication { #[default] @@ -16,7 +17,7 @@ pub enum SmtpAuthentication { XOAuth2, } -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Type)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, ToSchema, Type)] #[sqlx(type_name = "smtp_encryption", rename_all = "lowercase")] pub enum SmtpEncryption { #[default] @@ -25,7 +26,7 @@ pub enum SmtpEncryption { ImplicitTls, } -#[derive(Clone, Default, Deserialize, FromRow, PartialEq, Patch, Serialize)] +#[derive(Clone, Default, Deserialize, FromRow, PartialEq, Patch, Serialize, ToSchema)] #[patch(attribute(derive(Deserialize, Serialize)))] pub struct SmtpSettings { #[serde(rename = "smtp_server")] @@ -55,6 +56,7 @@ pub struct SmtpSettings { deserialize_with = "deserialize_optional_field", default )))] + #[schema(value_type = Option)] pub password: Option, #[serde(rename = "smtp_sender")] #[sqlx(rename = "smtp_sender")] @@ -77,6 +79,7 @@ pub struct SmtpSettings { #[serde(rename = "smtp_oauth_client_secret")] #[sqlx(rename = "smtp_oauth_client_secret")] #[patch(attribute(serde(rename = "smtp_oauth_client_secret")))] + #[schema(value_type = Option)] pub oauth_client_secret: Option, #[serde(rename = "smtp_oauth_refresh_token")] #[sqlx(rename = "smtp_oauth_refresh_token")] diff --git a/crates/defguard_common/src/db/models/user.rs b/crates/defguard_common/src/db/models/user.rs index d13288c92c..5cefb00459 100644 --- a/crates/defguard_common/src/db/models/user.rs +++ b/crates/defguard_common/src/db/models/user.rs @@ -72,7 +72,7 @@ impl fmt::Display for MFAMethod { } } -/// Only `id` and `name` from [`WebAuthn`]. +/// A registered security key. #[derive(Deserialize, Serialize, ToSchema)] pub struct SecurityKey { pub id: Id, @@ -403,6 +403,22 @@ impl User { Ok(()) } + /// Clear recovery codes so they can be regenerated after MFA is reconfigured. + pub async fn clear_recovery_codes<'e, E>(&mut self, executor: E) -> sqlx::Result<()> + where + E: PgExecutor<'e>, + { + query!( + "UPDATE \"user\" SET recovery_codes = '{}' WHERE id = $1", + self.id, + ) + .execute(executor) + .await?; + self.recovery_codes.clear(); + + Ok(()) + } + pub async fn set_mfa_method<'e, E>( &mut self, executor: E, @@ -689,8 +705,8 @@ impl User { User, "SELECT id, username, password_hash, last_name, first_name, email, phone, mfa_enabled, \ totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, \ - mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_sub, from_ldap, \ - ldap_pass_randomized, ldap_rdn, ldap_user_path, ldap_remote_enrollment_completed, enrollment_pending \ + mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_sub, \ + from_ldap, ldap_pass_randomized, ldap_rdn, ldap_user_path, ldap_remote_enrollment_completed, enrollment_pending \ FROM \"user\" \ WHERE is_active" ) @@ -870,8 +886,8 @@ impl User { Self, "SELECT id, username, password_hash, last_name, first_name, email, phone, mfa_enabled, \ totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, \ - mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_sub, from_ldap, \ - ldap_pass_randomized, ldap_rdn, ldap_user_path, ldap_remote_enrollment_completed, enrollment_pending \ + mfa_method \"mfa_method: _\", recovery_codes, is_active, openid_sub, \ + from_ldap, ldap_pass_randomized, ldap_rdn, ldap_user_path, ldap_remote_enrollment_completed, enrollment_pending \ FROM \"user\" WHERE email ILIKE $1", email ) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index 433b1b97e8..e08c920f2d 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -41,11 +41,12 @@ pub const DEFAULT_WIREGUARD_MTU: i32 = 1420; // TODO: use u32 once sqlx supports const DEFAULT_FWMARK: i64 = 0; // Zero means: don't use firewall mark. // Used in process of importing network from WireGuard config. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct MappedDevice { pub user_id: Id, pub name: String, pub wireguard_pubkey: String, + #[schema(value_type = Vec)] pub wireguard_ips: Vec, } @@ -98,10 +99,11 @@ pub enum ServiceLocationMode { AlwaysOn, } -/// Stores configuration required to setup a WireGuard network +/// Stores the configuration required to set up a WireGuard network. #[derive(Clone, Deserialize, Eq, Hash, Model, PartialEq, Serialize, ToSchema)] #[table(wireguard_network)] pub struct WireguardNetwork { + #[schema(value_type = i64)] pub id: I, pub name: String, #[model(ref)] @@ -451,9 +453,12 @@ impl WireguardNetwork { } } - /// Get a list of all devices belonging to users in allowed groups. + /// Get a list of all devices belonging to active users in allowed groups. /// Admin users should always be allowed to access a network. /// Note: Doesn't check if the devices are really in the network. + /// + /// For a single device, use [`Self::is_device_allowed_in_network`] instead of + /// fetching this whole list. pub async fn get_allowed_devices( &self, transaction: &mut PgConnection, @@ -468,7 +473,7 @@ impl WireguardNetwork { JOIN \"user\" u ON d.user_id = u.id \ WHERE u.is_active \ AND d.device_type = 'user'::device_type \ - ORDER BY d.id ASC" + ORDER BY d.id ASC", ) .fetch_all(&mut *transaction) .await @@ -488,13 +493,49 @@ impl WireguardNetwork { AND u.is_active \ AND d.device_type = 'user'::device_type \ ORDER BY d.id ASC", - &allowed_groups + &allowed_groups, ) .fetch_all(&mut *transaction) .await?; Ok(devices) } + /// Checks if a single device is allowed in this network: its user must be active + /// and, unless the network allows all groups, a member of one of its allowed groups. + pub async fn is_device_allowed_in_network( + &self, + conn: &mut PgConnection, + device: &Device, + ) -> Result { + if device.device_type != DeviceType::User { + return Ok(false); + } + if self.allow_all_groups { + let allowed = query_scalar!( + "SELECT EXISTS(SELECT 1 FROM \"user\" u WHERE u.id = $1 AND u.is_active)", + device.user_id + ) + .fetch_one(&mut *conn) + .await?; + return Ok(allowed.unwrap_or(false)); + } + + let allowed_groups = self.get_allowed_groups(&mut *conn).await?; + let allowed = query_scalar!( + "SELECT EXISTS(SELECT 1 FROM \"user\" u \ + JOIN group_user gu ON gu.user_id = u.id \ + JOIN \"group\" g ON gu.group_id = g.id \ + WHERE u.id = $1 \ + AND u.is_active \ + AND g.\"name\" IN (SELECT * FROM UNNEST($2::text[])))", + device.user_id, + &allowed_groups + ) + .fetch_one(&mut *conn) + .await?; + Ok(allowed.unwrap_or(false)) + } + /// Get a list of devices belonging to a user which are also in the network's allowed groups. /// Admin users should always be allowed to access a network. /// Note: Doesn't check if the devices are really in the network. @@ -574,11 +615,12 @@ impl WireguardNetwork { reserved_ips: Option<&[IpAddr]>, ) -> Result { info!("Assigning IP in network {self} for {device}"); - let allowed_devices = self.get_allowed_devices(&mut *conn).await?; - let allowed_device_ids = allowed_devices.iter().map(|dev| dev.id).collect::>(); - let used_ips = self.all_used_ips_for_network(&mut *conn).await?; + let allowed = self + .is_device_allowed_in_network(&mut *conn, device) + .await?; - if allowed_device_ids.contains(&device.id) { + if allowed { + let used_ips = self.all_used_ips_for_network(&mut *conn).await?; let wireguard_network_device = device .assign_next_network_ip(&mut *conn, self, &used_ips, reserved_ips, None) .await?; @@ -1510,7 +1552,7 @@ impl Default for WireguardNetwork { } } -#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub struct WireguardStatsRow { pub collected_at: Option, pub upload: Option, @@ -1566,7 +1608,7 @@ pub struct WireguardNetworkStats { pub transfer_series: Vec, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct LocationConnectedUserStats { user_id: Id, first_name: String, @@ -1575,6 +1617,7 @@ pub struct LocationConnectedUserStats { connected_devices_count: u16, // oldest active session data public_ip: String, + #[schema(value_type = Vec)] vpn_ips: Vec, connected_at: NaiveDateTime, // agregated traffic stats @@ -1583,12 +1626,13 @@ pub struct LocationConnectedUserStats { stats: Vec, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct LocationConnectedNetworkDevice { device_id: Id, device_name: String, // active session data public_ip: String, + #[schema(value_type = Vec)] vpn_ips: Vec, connected_at: NaiveDateTime, // agregated traffic stats diff --git a/crates/defguard_common/src/lib.rs b/crates/defguard_common/src/lib.rs index 7e4322c822..a564a9d453 100644 --- a/crates/defguard_common/src/lib.rs +++ b/crates/defguard_common/src/lib.rs @@ -14,6 +14,8 @@ pub mod hex; pub mod messages; pub mod random; pub mod secret; +#[cfg(feature = "test-support")] +pub mod testing; pub mod types; pub mod utils; @@ -43,6 +45,7 @@ pub const KEY_LENGTH: usize = 32; /// Compute the RFC 7638 JWK thumbprint for an RSA private key. /// /// Used as the key ID (`kid`) for OpenID Connect signing keys. +#[must_use] pub fn rsa_jwk_thumbprint(key: &RsaPrivateKey) -> String { rsa_jwk_thumbprint_from_public(key.n(), key.e()) } diff --git a/crates/defguard_common/src/testing/mod.rs b/crates/defguard_common/src/testing/mod.rs new file mode 100644 index 0000000000..8f2cc8e1e6 --- /dev/null +++ b/crates/defguard_common/src/testing/mod.rs @@ -0,0 +1,7 @@ +//! Test-only helpers shared across the workspace. +//! +//! Everything under this module is gated behind the `test-support` feature and +//! is intended to be pulled in via `[dev-dependencies]` by other crates' +//! integration tests. + +pub mod smtp; diff --git a/crates/defguard_common/src/testing/smtp.rs b/crates/defguard_common/src/testing/smtp.rs new file mode 100644 index 0000000000..c9a61b7a47 --- /dev/null +++ b/crates/defguard_common/src/testing/smtp.rs @@ -0,0 +1,295 @@ +//! In-process mock SMTP server for integration tests. +//! +//! [`MockSmtpServer`] speaks just enough of the SMTP dialogue (plaintext, no +//! authentication) for lettre's `AsyncSmtpTransport` to deliver a message, +//! captures every delivered message, and can point the current settings at +//! itself so that `Mail::send()` reaches it. +//! +//! Because most mail is sent fire-and-forget (`tokio::spawn`) the delivery +//! happens after the triggering request returns, so tests should wait for a +//! captured message with [`MockSmtpServer::wait_for`] / +//! [`MockSmtpServer::wait_for_count`] rather than reading synchronously. + +use std::{ + net::SocketAddr, + sync::{Arc, Mutex}, + time::Duration, +}; + +use sqlx::PgPool; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{TcpListener, TcpStream}, + time::{sleep, timeout}, +}; +use tracing::debug; + +use crate::db::models::settings::{ + Settings, + smtp::{SmtpAuthentication, SmtpEncryption}, + update_current_settings, +}; + +/// Default time [`MockSmtpServer::wait_for`] and friends will poll before +/// giving up. +pub const DEFAULT_MAIL_TIMEOUT: Duration = Duration::from_secs(5); + +/// A single message delivered to a [`MockSmtpServer`]. +#[derive(Debug, Clone)] +pub struct CapturedMail { + /// Address from the `MAIL FROM` command. + pub from: String, + /// Addresses from the `RCPT TO` commands. + pub recipients: Vec, + /// Raw payload sent after `DATA` (MIME headers + body), with SMTP + /// dot-unstuffing applied and the trailing `.` removed. + pub body: String, +} + +impl CapturedMail { + /// Whether `recipients` contains `address`. + #[must_use] + pub fn sent_to(&self, address: &str) -> bool { + self.recipients.iter().any(|r| r == address) + } + + /// Whether the raw payload contains `needle`. + #[must_use] + pub fn body_contains(&self, needle: &str) -> bool { + self.body.contains(needle) + } +} + +/// In-process SMTP server that accepts any message and records it. +/// +/// The listener is owned by a background task, so the server keeps running even +/// if this handle is dropped; it stops when the test's tokio runtime shuts +/// down. +pub struct MockSmtpServer { + addr: SocketAddr, + received: Arc>>, +} + +impl MockSmtpServer { + /// Bind an ephemeral port on localhost and start accepting connections. + #[must_use = "the returned handle exposes the captured messages"] + pub async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind mock SMTP listener"); + let addr = listener + .local_addr() + .expect("failed to read mock SMTP local address"); + debug!("Mock SMTP server listening on {addr}"); + + let received = Arc::new(Mutex::new(Vec::new())); + let received_bg = Arc::clone(&received); + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let received_conn = Arc::clone(&received_bg); + tokio::spawn(handle_connection(stream, received_conn)); + } + }); + + Self { addr, received } + } + + /// Address the server is listening on. + #[must_use] + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Point the current settings (global cache + database) at this server, + /// using plaintext transport with no authentication. + pub async fn configure(&self, pool: &PgPool) { + let mut settings = Settings::get_current_settings(); + settings.smtp.server = Some(self.addr.ip().to_string()); + settings.smtp.port = Some(i32::from(self.addr.port())); + settings.smtp.sender = Some("noreply@example.com".into()); + settings.smtp.encryption = SmtpEncryption::None; + settings.smtp.authentication = SmtpAuthentication::None; + update_current_settings(pool, settings) + .await + .expect("failed to persist mock SMTP settings"); + } + + /// Snapshot of all messages received so far. + #[must_use] + pub fn messages(&self) -> Vec { + self.received.lock().unwrap().clone() + } + + /// Number of messages received so far. + #[must_use] + pub fn message_count(&self) -> usize { + self.received.lock().unwrap().len() + } + + /// Wait until at least `n` messages have been received, then return a + /// snapshot of all captured messages. + /// + /// # Panics + /// Panics if [`DEFAULT_MAIL_TIMEOUT`] elapses first. + pub async fn wait_for_count(&self, n: usize) -> Vec { + let poll = async { + loop { + { + let guard = self.received.lock().unwrap(); + if guard.len() >= n { + return guard.clone(); + } + } + sleep(Duration::from_millis(20)).await; + } + }; + timeout(DEFAULT_MAIL_TIMEOUT, poll) + .await + .unwrap_or_else(|_| { + panic!( + "timed out waiting for {n} mail(s); received {}", + self.message_count() + ) + }) + } + + /// Wait for the first captured message matching `predicate` and return it. + /// + /// Searches the whole capture buffer on every poll, so it is robust to + /// unrelated fire-and-forget mails (e.g. new-device-login notifications) + /// arriving in between. + /// + /// # Panics + /// Panics if [`DEFAULT_MAIL_TIMEOUT`] elapses before a match appears. + pub async fn wait_for(&self, predicate: F) -> CapturedMail + where + F: Fn(&CapturedMail) -> bool, + { + let (_, mail) = self.wait_for_from(0, predicate).await; + mail + } + + /// Like [`wait_for`](Self::wait_for), but only considers messages at index + /// `>= start` in arrival order. Returns the matched message together with + /// its absolute index, so a caller stepping through a multi-mail flow can + /// advance a cursor (`start = index + 1`) and ignore already-consumed mail. + /// + /// # Panics + /// Panics if [`DEFAULT_MAIL_TIMEOUT`] elapses before a match appears. + pub async fn wait_for_from(&self, start: usize, predicate: F) -> (usize, CapturedMail) + where + F: Fn(&CapturedMail) -> bool, + { + let poll = async { + loop { + { + let guard = self.received.lock().unwrap(); + if let Some((index, mail)) = guard + .iter() + .enumerate() + .skip(start) + .find(|(_, m)| predicate(m)) + { + return (index, mail.clone()); + } + } + sleep(Duration::from_millis(20)).await; + } + }; + timeout(DEFAULT_MAIL_TIMEOUT, poll) + .await + .unwrap_or_else(|_| panic!("timed out waiting for a matching mail")) + } +} + +/// Convenience wrapper: start a server and point the current settings at it. +/// +/// Mirrors the previous per-crate helper of the same name; prefer constructing +/// a [`MockSmtpServer`] directly when you need to inspect captured mail. +pub async fn configure_working_smtp(pool: &PgPool) -> MockSmtpServer { + let server = MockSmtpServer::start().await; + server.configure(pool).await; + server +} + +/// Extract the `<...>` address from a `MAIL FROM` / `RCPT TO` command line. +fn extract_address(line: &str) -> String { + match (line.find('<'), line.find('>')) { + (Some(start), Some(end)) if start < end => line[start + 1..end].to_string(), + // Fall back to whatever follows the ':' if the client omitted brackets. + _ => line + .split_once(':') + .map_or_else(String::new, |(_, rest)| rest.trim().to_string()), + } +} + +/// Handle a single SMTP connection, recording any delivered message. +async fn handle_connection(stream: TcpStream, received: Arc>>) { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + if writer.write_all(b"220 localhost ESMTP\r\n").await.is_err() { + return; + } + + let mut from = String::new(); + let mut recipients = Vec::new(); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + let upper = line.trim_end().to_ascii_uppercase(); + + if upper.starts_with("EHLO") || upper.starts_with("HELO") { + let _ = writer.write_all(b"250 localhost\r\n").await; + } else if upper.starts_with("MAIL FROM") { + from = extract_address(line.trim_end()); + let _ = writer.write_all(b"250 OK\r\n").await; + } else if upper.starts_with("RCPT TO") { + recipients.push(extract_address(line.trim_end())); + let _ = writer.write_all(b"250 OK\r\n").await; + } else if upper.starts_with("RSET") { + from.clear(); + recipients.clear(); + let _ = writer.write_all(b"250 OK\r\n").await; + } else if upper.starts_with("DATA") { + let _ = writer + .write_all(b"354 End data with .\r\n") + .await; + let mut body = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + if line == ".\r\n" || line == ".\n" { + break; + } + // Undo SMTP dot-stuffing of lines that begin with '.'. + let content = line.strip_prefix('.').unwrap_or(&line); + body.push_str(content); + } + // Record the message before acknowledging, so that an awaited + // `send()` observes the capture as soon as it returns. + received.lock().unwrap().push(CapturedMail { + from: std::mem::take(&mut from), + recipients: std::mem::take(&mut recipients), + body, + }); + let _ = writer.write_all(b"250 OK message queued\r\n").await; + } else if upper.starts_with("QUIT") { + let _ = writer.write_all(b"221 Bye\r\n").await; + return; + } else { + let _ = writer.write_all(b"250 OK\r\n").await; + } + } +} diff --git a/crates/defguard_common/src/types/user_info.rs b/crates/defguard_common/src/types/user_info.rs index 40fe3fa213..8c98a147dd 100644 --- a/crates/defguard_common/src/types/user_info.rs +++ b/crates/defguard_common/src/types/user_info.rs @@ -39,6 +39,7 @@ pub struct UserInfo { pub password_management_disabled: bool, pub devices: Vec, pub has_non_mfa_location_access: bool, + pub has_non_posture_location_access: bool, } /// Check whether any network with MFA disabled is accessible to a user @@ -65,6 +66,32 @@ async fn has_non_mfa_location_access(pool: &PgPool, groups: &[String]) -> sqlx:: .map(|v| v.unwrap_or(false)) } +/// Check whether any network without posture checks assigned is accessible to a +/// user based on their group names. +async fn has_non_posture_location_access(pool: &PgPool, groups: &[String]) -> sqlx::Result { + query_scalar!( + "SELECT EXISTS( \ + SELECT 1 FROM wireguard_network wn \ + WHERE NOT EXISTS( \ + SELECT 1 FROM device_posture_location dpl WHERE dpl.location_id = wn.id \ + ) \ + 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) \ + ) \ + ) \ + )", + groups, + ) + .fetch_one(pool) + .await + .map(|v| v.unwrap_or(false)) +} + impl UserInfo { /// Convert [`User`] to [`UserInfo`]. pub async fn from_user( @@ -87,6 +114,8 @@ impl UserInfo { ); let has_non_mfa_location_access = has_non_mfa_location_access(pool, &groups).await?; + let has_non_posture_location_access = + has_non_posture_location_access(pool, &groups).await?; Ok(Self { id: user.id, @@ -109,6 +138,7 @@ impl UserInfo { password_management_disabled, devices, has_non_mfa_location_access, + has_non_posture_location_access, }) } @@ -656,4 +686,162 @@ mod test { let result = has_non_mfa_location_access(&pool, &groups).await.unwrap(); assert!(result); } + + #[sqlx::test] + async fn test_no_posture_location_returns_true(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + WireguardNetwork::new( + "no-posture-net".to_owned(), + 50057, + String::new(), + None, + [IpNetwork::from_str("10.7.1.0/24").unwrap()], + true, // allow_all_groups + false, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .set_address([IpNetwork::from_str("10.7.1.1/24").unwrap()]) + .unwrap() + .save(&pool) + .await + .unwrap(); + + let groups = Vec::new(); + let result = has_non_posture_location_access(&pool, &groups) + .await + .unwrap(); + assert!(result); + } + + /// A location with posture checks assigned should not grant access, even + /// when it is otherwise accessible to the user. + #[sqlx::test] + async fn test_posture_location_returns_false(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let network = WireguardNetwork::new( + "posture-net".to_owned(), + 50058, + String::new(), + None, + [IpNetwork::from_str("10.8.1.0/24").unwrap()], + true, // allow_all_groups + false, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .set_address([IpNetwork::from_str("10.8.1.1/24").unwrap()]) + .unwrap() + .save(&pool) + .await + .unwrap(); + + let posture_id: i64 = query_scalar!( + "INSERT INTO device_posture (name) VALUES ($1) RETURNING id", + "test-posture" + ) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query!( + "INSERT INTO device_posture_location (posture_id, location_id) VALUES ($1, $2)", + posture_id, + network.id, + ) + .execute(&pool) + .await + .unwrap(); + + let groups = Vec::new(); + let result = has_non_posture_location_access(&pool, &groups) + .await + .unwrap(); + assert!(!result); + } + + /// Two networks accessible: one with posture checks, one without = true. + #[sqlx::test] + async fn test_mixed_posture_networks_when_one_has_no_posture( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let group = Group::new("qa").save(&pool).await.unwrap(); + + // Location without posture checks, restricted to the qa group. + let no_posture_net = WireguardNetwork::new( + "qa-net".to_owned(), + 50059, + String::new(), + None, + [IpNetwork::from_str("10.9.1.0/24").unwrap()], + false, + false, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .set_address([IpNetwork::from_str("10.9.1.1/24").unwrap()]) + .unwrap() + .save(&pool) + .await + .unwrap(); + + // Location with posture checks, accessible to everyone. + let posture_net = WireguardNetwork::new( + "posture-net-2".to_owned(), + 50060, + String::new(), + None, + [IpNetwork::from_str("10.10.1.0/24").unwrap()], + true, // allow_all_groups + false, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .set_address([IpNetwork::from_str("10.10.1.1/24").unwrap()]) + .unwrap() + .save(&pool) + .await + .unwrap(); + + let posture_id: i64 = query_scalar!( + "INSERT INTO device_posture (name) VALUES ($1) RETURNING id", + "test-posture-2" + ) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query!( + "INSERT INTO device_posture_location (posture_id, location_id) VALUES ($1, $2)", + posture_id, + posture_net.id, + ) + .execute(&pool) + .await + .unwrap(); + + let mut transaction = pool.begin().await.unwrap(); + no_posture_net + .set_allowed_groups(&mut transaction, from_ref(&group.name)) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + let groups = vec!["qa".to_owned()]; + let result = has_non_posture_location_access(&pool, &groups) + .await + .unwrap(); + assert!(result); + } } diff --git a/crates/defguard_common/src/utils.rs b/crates/defguard_common/src/utils.rs index 3b271dfca0..23a1dbe449 100644 --- a/crates/defguard_common/src/utils.rs +++ b/crates/defguard_common/src/utils.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use ipnetwork::IpNetwork; use serde::Serialize; use url::Url; +use utoipa::ToSchema; /// Strip any `http://` or `https://` scheme prefix a user may have accidentally /// included in a hostname/IP field that expects a bare host, not a URL. @@ -68,7 +69,7 @@ pub fn parse_network_address_list(ips: &str) -> Vec { .collect() } -#[derive(Debug, Serialize, PartialEq)] +#[derive(Debug, Serialize, PartialEq, ToSchema)] pub struct SplitIp { network_part: String, modifiable_part: String, diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index 36776bbcce..e04340545f 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -96,6 +96,8 @@ uaparser = "0.6" async-stream = "0.3" [dev-dependencies] +# Enable the shared test-only helpers (mock SMTP server) for the test build. +defguard_common = { workspace = true, features = ["test-support"] } claims.workspace = true hyper-util = "0.1" matches.workspace = true @@ -109,6 +111,7 @@ reqwest = { version = "0.12", features = [ serde_qs = "1.0" tower = { workspace = true } webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } +wiremock = "0.6" [build-dependencies] tonic-prost-build.workspace = true diff --git a/crates/defguard_core/examples/openapi.rs b/crates/defguard_core/examples/openapi.rs new file mode 100644 index 0000000000..235537d1d4 --- /dev/null +++ b/crates/defguard_core/examples/openapi.rs @@ -0,0 +1,12 @@ +use std::fs; + +use defguard_core::openapi::ApiDoc; +use utoipa::OpenApi; + +fn main() -> anyhow::Result<()> { + let mut spec = ApiDoc::openapi().to_pretty_json()?; + spec.push('\n'); + fs::write("openapi.json", spec)?; + + Ok(()) +} diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index 9a87e2ea9e..c74fe07cb6 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -18,7 +18,7 @@ use crate::{ enterprise::db::models::{ activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, api_tokens::ApiToken, - enterprise_settings::EnterpriseSettings, + enterprise_settings::EnterpriseSettingsInfo, openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider}, snat::UserSnatBinding, }, @@ -357,8 +357,8 @@ pub struct SettingsUpdateMetadata { #[derive(Serialize)] pub struct EnterpriseSettingsUpdateMetadata { - pub before: EnterpriseSettings, - pub after: EnterpriseSettings, + pub before: EnterpriseSettingsInfo, + pub after: EnterpriseSettingsInfo, } #[derive(Serialize)] 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 d3a15fd9e4..0132c728ec 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -3,10 +3,11 @@ use defguard_common::db::{Id, NoId}; use ipnetwork::IpNetwork; use model_derive::Model; use sqlx::{FromRow, Type}; +use utoipa::ToSchema; pub mod metadata; -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Type)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema, Type)] #[sqlx(type_name = "activity_log_module", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum ActivityLogModule { @@ -132,6 +133,10 @@ pub enum EventType { // Gateway management GatewayModified, GatewayDeleted, + GatewayConnected, + GatewayDisconnected, + ProxyConnected, + ProxyDisconnected, // Device posture management DevicePostureCreated, DevicePostureUpdated, @@ -160,6 +165,7 @@ pub enum EventType { // OIDC directory sync events OidcDirectorySyncUserCreated, OidcDirectorySyncUserDeleted, + OidcDirectorySyncUserModified, OidcDirectorySyncUserEnabled, OidcDirectorySyncUserDisabled, OidcDirectorySyncGroupCreated, diff --git a/crates/defguard_core/src/db/models/webhook.rs b/crates/defguard_core/src/db/models/webhook.rs index ff4040b006..1d004473c4 100644 --- a/crates/defguard_core/src/db/models/webhook.rs +++ b/crates/defguard_core/src/db/models/webhook.rs @@ -4,6 +4,7 @@ use defguard_common::{ }; use model_derive::Model; use sqlx::{FromRow, PgPool, query_as}; +use utoipa::ToSchema; /// App events which triggers webhook action #[derive(Debug)] @@ -48,8 +49,9 @@ impl AppEvent { } } -#[derive(Clone, Debug, Deserialize, FromRow, Model, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, FromRow, Model, Serialize, ToSchema, PartialEq)] pub struct WebHook { + #[schema(value_type = i64)] pub id: I, pub url: String, pub description: String, diff --git a/crates/defguard_core/src/device_access/mod.rs b/crates/defguard_core/src/device_access/mod.rs index 3d4ac42e2c..b67e8d2f00 100644 --- a/crates/defguard_core/src/device_access/mod.rs +++ b/crates/defguard_core/src/device_access/mod.rs @@ -17,6 +17,7 @@ use defguard_common::{ device_config_gen::create_wireguard_config, }; use sqlx::PgConnection; +use tracing::warn; use crate::enterprise::allowed_ips::get_effective_allowed_ips; @@ -105,7 +106,13 @@ pub async fn join_device_to_all_networks( .await { Ok(d) => d, - Err(WireguardNetworkError::DeviceNotAllowed(_)) => continue, + Err(WireguardNetworkError::DeviceNotAllowed(_)) => { + warn!( + "Device {device} not allowed in network {network}, skipping config \ + generation for this network" + ); + continue; + } Err(WireguardNetworkError::DeviceError(DeviceError::NetworkFull(_))) => { return Err(DeviceError::NetworkFull(network.name.clone())); } diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index 5de523b634..66592f2f8e 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -1608,10 +1608,10 @@ pub enum AliasState { Modified, } -/// ACL alias can be of one of the following types: -/// - Destination: the alias defines a complete destination that an ACL rule applies to -/// - Component: the alias defines parts of a destination and will be combined with other parts -/// manually defined in an ACL rule +/// Type of an ACL alias: +/// - `destination`: the alias defines a complete destination that an ACL rule applies to. +/// - `component`: the alias defines parts of a destination and is combined with the parts +/// defined in the ACL rule itself. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, ToSchema, Type)] #[sqlx(type_name = "aclalias_kind", rename_all = "lowercase")] pub enum AliasKind { diff --git a/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs b/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs index 80a18e9585..d458eee3cf 100644 --- a/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs +++ b/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs @@ -6,10 +6,11 @@ use model_derive::Model; use serde::Serialize; use sqlx::{FromRow, PgExecutor, Type, query_as}; use strum_macros::{Display, EnumString}; +use utoipa::ToSchema; use crate::enterprise::activity_log_stream::error::ActivityLogStreamError; -#[derive(Debug, Serialize, Deserialize, Type, EnumString, Display, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Type, EnumString, Display, Clone, PartialEq, ToSchema)] #[sqlx(type_name = "text", rename_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum ActivityLogStreamType { diff --git a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs index 9c0af50b74..afbcd0f70e 100644 --- a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs @@ -2,6 +2,7 @@ use chrono::NaiveDateTime; use defguard_common::db::{Id, NoId}; use model_derive::Model; use sqlx::{PgExecutor, query_as}; +use utoipa::ToSchema; #[derive(Clone, Debug, Deserialize, Model, Serialize, PartialEq)] #[table(api_token)] @@ -68,7 +69,7 @@ impl ApiToken { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct ApiTokenInfo { pub id: Id, pub name: String, diff --git a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs index e251f12908..087bdca017 100644 --- a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs @@ -1,4 +1,4 @@ -use defguard_common::db::models::Settings; +use defguard_common::db::{Id, models::Settings}; use sqlx::{PgExecutor, Type, query, query_as}; use struct_patch::Patch; @@ -13,12 +13,41 @@ pub struct EnterpriseSettings { pub client_traffic_policy: ClientTrafficPolicy, /// If true, manual WireGuard setup is disabled pub only_client_activation: bool, + /// If true, bare WireGuard tunnels are disabled in the desktop client and CLI. + pub disable_tunnels: bool, /// If true, the client download page is shown during enrollment. pub display_download_step: bool, /// If true, the password reset option is displayed on the Edge home page. pub display_password_reset: bool, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct GroupClientTrafficPolicies { + pub none: Vec, + pub disable_all_traffic: Vec, + pub force_all_traffic: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct EnterpriseSettingsInfo { + #[serde(flatten)] + pub settings: EnterpriseSettings, + pub group_client_traffic_policies: GroupClientTrafficPolicies, +} + +impl EnterpriseSettingsInfo { + #[must_use] + pub fn new( + settings: EnterpriseSettings, + group_client_traffic_policies: GroupClientTrafficPolicies, + ) -> Self { + Self { + settings, + group_client_traffic_policies, + } + } +} + // We want to be conscious of what the defaults are here #[allow(clippy::derivable_impls)] impl Default for EnterpriseSettings { @@ -27,6 +56,7 @@ impl Default for EnterpriseSettings { admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::default(), only_client_activation: false, + disable_tunnels: false, display_download_step: true, display_password_reset: true, } @@ -48,6 +78,7 @@ impl EnterpriseSettings { "SELECT admin_device_management, \ client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\", \ only_client_activation, \ + disable_tunnels, \ display_download_step, \ display_password_reset \ FROM \"enterprisesettings\" WHERE id = 1", @@ -78,12 +109,14 @@ impl EnterpriseSettings { admin_device_management = $1, \ client_traffic_policy = $2, \ only_client_activation = $3, \ - display_download_step = $4, \ - display_password_reset = $5 \ + disable_tunnels = $4, \ + display_download_step = $5, \ + display_password_reset = $6 \ WHERE id = 1", self.admin_device_management, self.client_traffic_policy as ClientTrafficPolicy, self.only_client_activation, + self.disable_tunnels, self.display_download_step, self.display_password_reset, ) @@ -107,3 +140,85 @@ pub enum ClientTrafficPolicy { /// Clients are forced to route all traffic through the VPN. ForceAllTraffic, } + +/// Resolves group policies over the instance-level policy. +/// +/// A configured group policy takes precedence over the instance policy. When a user belongs to +/// multiple groups, disabling all traffic takes precedence over forcing all traffic. An explicit +/// `None` group policy takes precedence over the instance policy when no restrictive group policy +/// is present. +#[must_use] +pub fn resolve_client_traffic_policy( + instance_policy: ClientTrafficPolicy, + group_policies: impl IntoIterator, +) -> ClientTrafficPolicy { + let mut has_force_all_traffic = false; + let mut has_none = false; + + for policy in group_policies { + match policy { + ClientTrafficPolicy::DisableAllTraffic => { + return ClientTrafficPolicy::DisableAllTraffic; + } + ClientTrafficPolicy::ForceAllTraffic => has_force_all_traffic = true, + ClientTrafficPolicy::None => has_none = true, + } + } + + if has_force_all_traffic { + ClientTrafficPolicy::ForceAllTraffic + } else if has_none { + ClientTrafficPolicy::None + } else { + instance_policy + } +} + +#[cfg(test)] +mod tests { + use super::{ClientTrafficPolicy, resolve_client_traffic_policy}; + + #[test] + fn instance_policy_is_used_without_group_overrides() { + assert_eq!( + resolve_client_traffic_policy(ClientTrafficPolicy::ForceAllTraffic, []), + ClientTrafficPolicy::ForceAllTraffic + ); + } + + #[test] + fn group_policy_overrides_instance_policy() { + assert_eq!( + resolve_client_traffic_policy( + ClientTrafficPolicy::DisableAllTraffic, + [ClientTrafficPolicy::ForceAllTraffic] + ), + ClientTrafficPolicy::ForceAllTraffic + ); + } + + #[test] + fn disable_all_traffic_wins_conflicting_group_policies() { + assert_eq!( + resolve_client_traffic_policy( + ClientTrafficPolicy::ForceAllTraffic, + [ + ClientTrafficPolicy::ForceAllTraffic, + ClientTrafficPolicy::DisableAllTraffic, + ] + ), + ClientTrafficPolicy::DisableAllTraffic + ); + } + + #[test] + fn explicit_none_group_policy_overrides_instance_policy() { + assert_eq!( + resolve_client_traffic_policy( + ClientTrafficPolicy::ForceAllTraffic, + [ClientTrafficPolicy::None] + ), + ClientTrafficPolicy::None + ); + } +} diff --git a/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs b/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs new file mode 100644 index 0000000000..d8e7bf89cb --- /dev/null +++ b/crates/defguard_core/src/enterprise/db/models/group_client_traffic_policy.rs @@ -0,0 +1,126 @@ +use defguard_common::db::Id; +use sqlx::{FromRow, PgConnection, PgExecutor, query, query_as}; + +use super::enterprise_settings::{ClientTrafficPolicy, GroupClientTrafficPolicies}; + +#[derive(Clone, Debug, FromRow, PartialEq)] +/// A traffic policy assigned to a single group. +pub struct GroupClientTrafficPolicy { + pub group_id: Id, + pub client_traffic_policy: ClientTrafficPolicy, +} + +impl GroupClientTrafficPolicy { + pub async fn all<'e, E>(executor: E) -> sqlx::Result> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "SELECT group_id, \ + client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" \ + FROM group_client_traffic_policy ORDER BY group_id" + ) + .fetch_all(executor) + .await + } + + /// Converts database assignments into the API's policy-grouped representation. + #[must_use] + pub fn grouped(policies: Vec) -> GroupClientTrafficPolicies { + let mut grouped = GroupClientTrafficPolicies::default(); + for policy in policies { + match policy.client_traffic_policy { + ClientTrafficPolicy::None => grouped.none.push(policy.group_id), + ClientTrafficPolicy::DisableAllTraffic => { + grouped.disable_all_traffic.push(policy.group_id); + } + ClientTrafficPolicy::ForceAllTraffic => { + grouped.force_all_traffic.push(policy.group_id); + } + } + } + grouped + } + + /// Replaces all group policy assignments within an existing transaction. + pub async fn replace_all( + transaction: &mut PgConnection, + policies: &GroupClientTrafficPolicies, + ) -> sqlx::Result<()> { + query!("DELETE FROM group_client_traffic_policy") + .execute(&mut *transaction) + .await?; + for (group_ids, policy) in [ + (&policies.none, ClientTrafficPolicy::None), + ( + &policies.disable_all_traffic, + ClientTrafficPolicy::DisableAllTraffic, + ), + ( + &policies.force_all_traffic, + ClientTrafficPolicy::ForceAllTraffic, + ), + ] { + for &group_id in group_ids { + Self::upsert(&mut *transaction, group_id, policy).await?; + } + } + Ok(()) + } + + /// Returns policy assignments for groups belonging to a user. + pub async fn find_by_user_id<'e, E>(executor: E, user_id: Id) -> sqlx::Result> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "SELECT gctp.group_id, \ + gctp.client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\" \ + FROM group_client_traffic_policy gctp \ + JOIN group_user gu ON gu.group_id = gctp.group_id \ + WHERE gu.user_id = $1", + user_id + ) + .fetch_all(executor) + .await + } + + pub async fn upsert<'e, E>( + executor: E, + group_id: Id, + policy: ClientTrafficPolicy, + ) -> sqlx::Result + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "INSERT INTO group_client_traffic_policy \ + (group_id, client_traffic_policy) \ + VALUES ($1, $2) \ + ON CONFLICT (group_id) DO UPDATE SET \ + client_traffic_policy = EXCLUDED.client_traffic_policy \ + RETURNING group_id, \ + client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\"", + group_id, + policy as ClientTrafficPolicy + ) + .fetch_one(executor) + .await + } + + pub async fn delete<'e, E>(executor: E, group_id: Id) -> sqlx::Result<()> + where + E: PgExecutor<'e>, + { + query!( + "DELETE FROM group_client_traffic_policy WHERE group_id = $1", + group_id + ) + .execute(executor) + .await?; + Ok(()) + } +} diff --git a/crates/defguard_core/src/enterprise/db/models/mod.rs b/crates/defguard_core/src/enterprise/db/models/mod.rs index e835e1f65a..81d6601e71 100644 --- a/crates/defguard_core/src/enterprise/db/models/mod.rs +++ b/crates/defguard_core/src/enterprise/db/models/mod.rs @@ -3,5 +3,7 @@ pub mod activity_log_stream; pub mod api_tokens; pub mod device_posture; pub mod enterprise_settings; +pub mod group_client_traffic_policy; pub mod openid_provider; pub mod snat; +pub mod user_directory_identity; diff --git a/crates/defguard_core/src/enterprise/db/models/snat.rs b/crates/defguard_core/src/enterprise/db/models/snat.rs index 033052cb22..cb029c2a63 100644 --- a/crates/defguard_core/src/enterprise/db/models/snat.rs +++ b/crates/defguard_core/src/enterprise/db/models/snat.rs @@ -11,6 +11,7 @@ use crate::enterprise::snat::error::UserSnatBindingError; #[derive(Clone, Debug, Deserialize, Model, Serialize, ToSchema, PartialEq)] #[table(user_snat_binding)] pub struct UserSnatBinding { + #[schema(value_type = i64)] pub id: I, pub user_id: Id, pub location_id: Id, diff --git a/crates/defguard_core/src/enterprise/db/models/user_directory_identity.rs b/crates/defguard_core/src/enterprise/db/models/user_directory_identity.rs new file mode 100644 index 0000000000..44c0137ab6 --- /dev/null +++ b/crates/defguard_core/src/enterprise/db/models/user_directory_identity.rs @@ -0,0 +1,94 @@ +use defguard_common::db::{Id, NoId}; +use sqlx::{FromRow, PgExecutor, query, query_as, query_scalar}; + +#[derive(Clone, Debug, PartialEq, FromRow)] +pub struct UserDirectoryIdentity { + pub id: I, + pub user_id: i64, + pub provider_id: i64, + pub external_id: String, +} + +impl UserDirectoryIdentity { + /// Find a user by their directory identity (provider + external_id). + pub async fn find_user_by_provider_external_id<'e, E>( + executor: E, + provider_id: i64, + external_id: &str, + ) -> sqlx::Result> + where + E: PgExecutor<'e>, + { + query_scalar!( + "SELECT user_id FROM user_directory_identity WHERE provider_id = $1 AND external_id = $2", + provider_id, + external_id + ) + .fetch_optional(executor) + .await + } + + /// Get the directory identity for a user and provider if it exists. + pub async fn find_by_user_and_provider<'e, E>( + executor: E, + user_id: i64, + provider_id: i64, + ) -> sqlx::Result> + where + E: PgExecutor<'e>, + { + query_as!( + UserDirectoryIdentity, + "SELECT id, user_id, provider_id, external_id FROM user_directory_identity \ + WHERE user_id = $1 AND provider_id = $2", + user_id, + provider_id + ) + .fetch_optional(executor) + .await + } + + /// Create or update the directory identity for a user and provider. + /// If the mapping already exists, it updates the external_id. + pub async fn upsert<'e, E>( + executor: E, + user_id: i64, + provider_id: i64, + external_id: &str, + ) -> sqlx::Result + where + E: PgExecutor<'e>, + { + query_as!( + UserDirectoryIdentity, + "INSERT INTO user_directory_identity (user_id, provider_id, external_id) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (user_id, provider_id) DO UPDATE SET external_id = $3 \ + RETURNING id, user_id, provider_id, external_id", + user_id, + provider_id, + external_id + ) + .fetch_one(executor) + .await + } + + /// Delete the directory identity mapping for a user and provider. + pub async fn delete_by_user_and_provider<'e, E>( + executor: E, + user_id: i64, + provider_id: i64, + ) -> sqlx::Result<()> + where + E: PgExecutor<'e>, + { + query!( + "DELETE FROM user_directory_identity WHERE user_id = $1 AND provider_id = $2", + user_id, + provider_id + ) + .execute(executor) + .await?; + Ok(()) + } +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/groups_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/groups_response.json new file mode 100644 index 0000000000..5e4666ef89 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/groups_response.json @@ -0,0 +1,28 @@ +{ + "kind": "admin#directory#groups", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/AbCdEfGroups\"", + "groups": [ + { + "kind": "admin#directory#group", + "id": "01302m9251m2vt3", + "etag": "\"XyZ333\"", + "email": "engineering@example.com", + "name": "Engineering", + "description": "Engineering team distribution list", + "adminCreated": true, + "directMembersCount": "12", + "aliases": ["eng@example.com"], + "nonEditableAliases": ["engineering@example.com.test-google-a.com"] + }, + { + "kind": "admin#directory#group", + "id": "01302m9251m2vt4", + "etag": "\"XyZ444\"", + "email": "sales@example.com", + "name": "Sales", + "description": "Sales team distribution list", + "adminCreated": true, + "directMembersCount": "5" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/members_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/members_response.json new file mode 100644 index 0000000000..a06f78f8e9 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/members_response.json @@ -0,0 +1,22 @@ +{ + "kind": "admin#directory#members", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/AbCdEfMembers\"", + "members": [ + { + "kind": "admin#directory#member", + "etag": "\"XyZ555\"", + "id": "111987654321098765432", + "email": "jane.doe@example.com", + "role": "MEMBER", + "type": "USER", + "status": "ACTIVE" + }, + { + "kind": "admin#directory#member", + "etag": "\"XyZ666\"", + "email": "external.contact@partner.com", + "role": "MEMBER", + "type": "EXTERNAL" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/test_private_key.pem b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/test_private_key.pem new file mode 100644 index 0000000000..e4ddfc9acf --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/test_private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCqBWLhtfXF8o1k +vSGuI9kRkilVsChk46jCE4IzWHCrPyr5KE54q6KiB5xW1sCnAzM1m/c8L8XYMUjl +JgYqj1LLNwi7CEgR1wRFJzI2lfhvsb9+mviPLaa8vTRfjW0Bzwfz1vizFVsb252j +H6ivgKO3Sz/nmlZwm8Giz6aeKuCB6X2Jed7EvqIon5NO8jfSQCiTs1mvW++Xjd7A +tY1t7/k61ijACPSKl+0dXkURH/rxfL8DBOOus26u4LaWRlCWufB9s6bJn0nocZ+d +4QAXvSadOLk2Qo/TLMTAa1B9CsVAr0200mW/HGxMGL15tlFBblndpNDJYeyvkM83 +R/k7UtaLAgMBAAECggEAGL8t9FJq0oQKZoZM167qEm+myo0YZA7q6kyyn8yYoaRR +GfDu8wtwYYsgC1e4tNZ3Zq8r3jvJ3TzXMltFfH5d+6wn/iknbElSmsooud88SO2j +RMdDtHtdkI0tiVbpbeD3dFtu2YobN8PQfwTFpb3glnZCd/Bz8nIJTixRQSZWhTH9 +QKRRCHUr1TKs+zSW2dIV1u4SJmJZIQcd303/WlivTIrpN038iOAyX5kueNAwdO7g +FUAt5B6dWh64uuGeoyglKRmcbjLjEAUhdRl8PUa12rFZFGjAZQhGD+Um3zqKN/SM +JHS+r7i5SqS/2wKoTr5USo/zpsoGJ5ETJR3egGWWUQKBgQDbC7LZHD2IHMGh+9EF +7le7UwWok0Uc+KJKp4lood5kvG9obsFNB2t3SkMyeGK7fWgYseZyiQH0lFV90avQ +xer55tmQaQsF+diRioXf5Py3MbFQW9XBLRONc59XqtFBy337BM39a76WfUTjBw+q +O4xlxF+Mc0P7Gbu58AZ/9/ptOQKBgQDGtGA3sFXVF3lMYvLD2MWuhTI9FI+15RfI +NHFJGUBd5prEqMph9D74onY8mNQvuTsv/QENK7D0U8/7kCCeHu/255x0eTvpCXTr +V5Pgmbo0m+JXERHxFjTUNminXC8CFtbhCC4wcCbfTGXOsxmkKt/AfmL4UEZxSaKp +7KcriRDl4wKBgQCM9KlBdx/NgBvGniIy+94u6E0C3sohy4m8GrWcxFU1Y+EZRfRU +Gks4d2xohPmcByY+hfRyjeHZPr8I+Szkawg7iidE+6OhU9beSVK+8j8BsBOXT5Fs +/xHO+7F1G7IO8eD+8GeSBYyekLwMkBWviSRetJB79AyyACxCicfU/4GCWQKBgQC3 +2RqRTBEOeYqcl4ygkkfNQqYKB6U0GgSYE5WhmcCJYR1fygB/AzrkV4Toaksg4NSX +gRLg+cTfO1DbJC5ZvqwuGXwT9DvvUPWh7kT/cZzsojKYdk3Qb8hvdOv9tXg1CabF +TQlXfd0pjAlCZ0nZMGYYN5ggwj17AjOD31eJG2fMGwKBgQDTP4lWS5gRvrz8UT16 +DDsPUdaYasB3L1vv45fRFjxDqzdXK8esls0DicDBLJNfcC8lAYUPUv7BeKZkPu5H +GD2+k4Ywxg1xUw9GHvKESSgdfMRTRUcMe1x2Klzs1KnNawfOQ1r6mMemeSM+zt/F +i+5Et8i9Zm4+ShhLzHTiCJS5fg== +-----END PRIVATE KEY----- diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/token_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/token_response.json new file mode 100644 index 0000000000..b4cb387442 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/token_response.json @@ -0,0 +1,6 @@ +{ + "access_token": "ya29.c.b0Aaekm1KfR7fake_opaque_token_value", + "scope": "https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.user.readonly", + "token_type": "Bearer", + "expires_in": 3600 +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_empty.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_empty.json new file mode 100644 index 0000000000..dc4f1e9a54 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_empty.json @@ -0,0 +1,5 @@ +{ + "kind": "admin#directory#users", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/Empty\"", + "users": [] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_page1.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_page1.json new file mode 100644 index 0000000000..28e5c1c31b --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_page1.json @@ -0,0 +1,32 @@ +{ + "kind": "admin#directory#users", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/AbCdEf123\"", + "users": [ + { + "kind": "admin#directory#user", + "id": "108234567890123456789", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/XyZ111\"", + "primaryEmail": "jane.doe@example.com", + "name": { + "givenName": "Jane", + "familyName": "Doe", + "fullName": "Jane Doe" + }, + "isAdmin": false, + "isDelegatedAdmin": false, + "lastLoginTime": "2026-07-18T09:12:33.000Z", + "creationTime": "2021-03-04T14:02:11.000Z", + "agreedToTerms": true, + "suspended": false, + "archived": false, + "changePasswordAtNextLogin": false, + "ipWhitelisted": false, + "emails": [{ "address": "jane.doe@example.com", "primary": true }], + "nonEditableAliases": ["jane.doe@example.com.test-google-a.com"], + "customerId": "C01abc234", + "orgUnitPath": "/Engineering", + "isMailboxSetup": true + } + ], + "nextPageToken": "EAIaBhACGgtGkAAfirstpage" +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_page2.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_page2.json new file mode 100644 index 0000000000..56ef91f3e9 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/google/users_page2.json @@ -0,0 +1,29 @@ +{ + "kind": "admin#directory#users", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/AbCdEf456\"", + "users": [ + { + "kind": "admin#directory#user", + "id": "108234567890123456790", + "etag": "\"O2K9aXtIm5oBnlpiYgtHF0O9rBs/XyZ222\"", + "primaryEmail": "john.smith@example.com", + "name": { + "givenName": "John", + "familyName": "Smith", + "fullName": "John Smith" + }, + "isAdmin": false, + "isDelegatedAdmin": false, + "creationTime": "2020-11-19T08:44:02.000Z", + "agreedToTerms": true, + "suspended": true, + "archived": false, + "changePasswordAtNextLogin": false, + "ipWhitelisted": false, + "emails": [{ "address": "john.smith@example.com", "primary": true }], + "customerId": "C01abc234", + "orgUnitPath": "/Suspended Users", + "isMailboxSetup": true + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/group_members_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/group_members_response.json new file mode 100644 index 0000000000..80cb5025e7 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/group_members_response.json @@ -0,0 +1,14 @@ +[ + { + "to": { + "id": "user123", + "type": "user" + } + }, + { + "to": { + "id": "user456", + "type": "user" + } + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/groups_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/groups_response.json new file mode 100644 index 0000000000..fff81d47f3 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/groups_response.json @@ -0,0 +1,10 @@ +[ + { + "id": "group1", + "name": "Engineering" + }, + { + "id": "group2", + "name": "Sales" + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/user_groups_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/user_groups_response.json new file mode 100644 index 0000000000..0b74138091 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/user_groups_response.json @@ -0,0 +1,22 @@ +[ + { + "id": "group1", + "compiledAttributes": { + "ldapGroups": [ + { + "name": "Engineering" + } + ] + } + }, + { + "id": "group2", + "compiledAttributes": { + "ldapGroups": [ + { + "name": "Sales" + } + ] + } + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_empty.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_empty.json new file mode 100644 index 0000000000..178704c55c --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_empty.json @@ -0,0 +1,4 @@ +{ + "results": [], + "totalCount": 0 +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_page1.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_page1.json new file mode 100644 index 0000000000..d75d48f609 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_page1.json @@ -0,0 +1,15 @@ +{ + "results": [ + { + "id": "user123", + "email": "jane.doe@example.com", + "firstname": "Jane", + "lastname": "Doe", + "activated": true, + "account_locked": false, + "state": "ACTIVATED", + "created": "2021-03-04T14:02:11.000Z" + } + ], + "totalCount": 2 +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_page2.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_page2.json new file mode 100644 index 0000000000..05f0217db1 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/jumpcloud/users_page2.json @@ -0,0 +1,15 @@ +{ + "results": [ + { + "id": "user456", + "email": "john.smith@example.com", + "firstname": "John", + "lastname": "Smith", + "activated": false, + "account_locked": false, + "state": "ACTIVATED", + "created": "2021-06-15T10:30:22.000Z" + } + ], + "totalCount": 2 +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/groups_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/groups_response.json new file mode 100644 index 0000000000..48e3df90bb --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/groups_response.json @@ -0,0 +1,23 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups", + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "displayName": "Engineering", + "groupTypes": ["Unified"], + "mailEnabled": true, + "securityEnabled": false, + "description": "Engineering team", + "mail": "engineering@example.com" + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "displayName": "Sales", + "groupTypes": ["Unified"], + "mailEnabled": true, + "securityEnabled": false, + "description": "Sales team", + "mail": "sales@example.com" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/members_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/members_response.json new file mode 100644 index 0000000000..165da6ffd6 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/members_response.json @@ -0,0 +1,17 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects", + "value": [ + { + "id": "87654321-1234-5678-1234-567812345678", + "displayName": "Jane Doe", + "mail": "jane.doe@example.com", + "accountEnabled": true, + "otherMails": [], + "givenName": "Jane", + "surname": "Doe", + "mobilePhone": "+1-555-123-4567", + "businessPhones": ["+1-555-987-6543"], + "userPrincipalName": "jane.doe@example.com" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/token_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/token_response.json new file mode 100644 index 0000000000..436ab390a9 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/token_response.json @@ -0,0 +1,7 @@ +{ + "access_token": "EwAoA8l6BAAR...", + "token_type": "Bearer", + "expires_in": 3599, + "ext_expires_in": 3599, + "scope": "https://graph.microsoft.com/.default" +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/user_groups_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/user_groups_response.json new file mode 100644 index 0000000000..d44d0999bc --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/user_groups_response.json @@ -0,0 +1,12 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects", + "value": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "displayName": "Engineering", + "groupTypes": ["Unified"], + "mailEnabled": true, + "securityEnabled": false + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/user_search_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/user_search_response.json new file mode 100644 index 0000000000..18ef89eb51 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/user_search_response.json @@ -0,0 +1,8 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [ + { + "id": "87654321-1234-5678-1234-567812345678" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_empty.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_empty.json new file mode 100644 index 0000000000..4ea3b51c16 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_empty.json @@ -0,0 +1,4 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_page1.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_page1.json new file mode 100644 index 0000000000..7e3aeff4b9 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_page1.json @@ -0,0 +1,19 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "@odata.nextLink": "MOCK_SERVER_URI/users?$top=200&$skiptoken=firstpage", + "value": [ + { + "id": "87654321-1234-5678-1234-567812345678", + "displayName": "Jane Doe", + "mail": "jane.doe@example.com", + "accountEnabled": true, + "otherMails": [], + "givenName": "Jane", + "surname": "Doe", + "mobilePhone": "+1-555-123-4567", + "businessPhones": ["+1-555-987-6543"], + "userPrincipalName": "jane.doe@example.com", + "jobTitle": "Senior Engineer" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_page2.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_page2.json new file mode 100644 index 0000000000..0780fbcaf0 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/microsoft/users_page2.json @@ -0,0 +1,18 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [ + { + "id": "12345678-1234-5678-1234-567812345679", + "displayName": "John Smith", + "mail": null, + "accountEnabled": false, + "otherMails": ["john.smith@example.com"], + "givenName": "John", + "surname": "Smith", + "mobilePhone": null, + "businessPhones": [], + "userPrincipalName": "john.smith@example.com", + "jobTitle": "Manager" + } + ] +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/group_members_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/group_members_response.json new file mode 100644 index 0000000000..08db7baabe --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/group_members_response.json @@ -0,0 +1,44 @@ +[ + { + "id": "00u4s64d9MYvzKmLW0g7", + "status": "ACTIVE", + "created": "2022-01-15T10:30:00.000Z", + "activated": "2022-01-15T10:35:00.000Z", + "statusChanged": "2022-01-15T10:35:00.000Z", + "lastLogin": "2026-07-20T14:22:15.000Z", + "lastUpdated": "2026-07-20T14:22:15.000Z", + "passwordChanged": "2022-01-15T10:35:00.000Z", + "type": { + "id": "oty5lqjjma4vvnx0P0g7" + }, + "profile": { + "firstName": "Jane", + "lastName": "Doe", + "mobilePhone": null, + "primaryPhone": null, + "secondEmail": null, + "secondPhone": null, + "preferredLanguage": null, + "userSegment": null, + "department": null, + "costCenter": null, + "organization": null, + "manager": null, + "managerId": null, + "login": "jane.doe@example.com", + "email": "jane.doe@example.com" + }, + "credentials": { + "password": {}, + "provider": { + "type": "OKTA", + "name": "OKTA" + } + }, + "_links": { + "self": { + "href": "https://trial-okta.okta.com/api/v1/users/00u4s64d9MYvzKmLW0g7" + } + } + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/groups_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/groups_response.json new file mode 100644 index 0000000000..17ad702695 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/groups_response.json @@ -0,0 +1,70 @@ +[ + { + "id": "00gjitxyt9yJW2FKR0g7", + "created": "2022-01-10T08:00:00.000Z", + "lastUpdated": "2026-07-15T12:30:00.000Z", + "lastMembershipUpdated": "2026-07-15T12:30:00.000Z", + "objectClass": [ + "okta:user_group" + ], + "type": "OKTA_GROUP", + "profile": { + "name": "Engineering", + "description": "Engineering team" + }, + "_links": { + "logo": [ + { + "name": "medium", + "href": "https://trial-okta.okta.com/img/logos/groups/okta-medium.png", + "type": "image/png" + }, + { + "name": "large", + "href": "https://trial-okta.okta.com/img/logos/groups/okta-large.png", + "type": "image/png" + } + ], + "users": { + "href": "https://trial-okta.okta.com/api/v1/groups/00gjitxyt9yJW2FKR0g7/users" + }, + "apps": { + "href": "https://trial-okta.okta.com/api/v1/groups/00gjitxyt9yJW2FKR0g7/apps" + } + } + }, + { + "id": "00gk5zz0j3fKYZ7VJ0g8", + "created": "2022-02-05T07:00:00.000Z", + "lastUpdated": "2026-07-14T11:20:00.000Z", + "lastMembershipUpdated": "2026-07-14T11:20:00.000Z", + "objectClass": [ + "okta:user_group" + ], + "type": "OKTA_GROUP", + "profile": { + "name": "Sales", + "description": "Sales team" + }, + "_links": { + "logo": [ + { + "name": "medium", + "href": "https://trial-okta.okta.com/img/logos/groups/okta-medium.png", + "type": "image/png" + }, + { + "name": "large", + "href": "https://trial-okta.okta.com/img/logos/groups/okta-large.png", + "type": "image/png" + } + ], + "users": { + "href": "https://trial-okta.okta.com/api/v1/groups/00gk5zz0j3fKYZ7VJ0g8/users" + }, + "apps": { + "href": "https://trial-okta.okta.com/api/v1/groups/00gk5zz0j3fKYZ7VJ0g8/apps" + } + } + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/test_jwk_private_key.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/test_jwk_private_key.json new file mode 100644 index 0000000000..3a1cd17ff2 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/test_jwk_private_key.json @@ -0,0 +1,13 @@ +{ + "kty": "RSA", + "use": "sig", + "kid": "test-okta-key-2024", + "n": "uNJyzQk6ZPmT1wuD7YjzZW2TzjaDmhFSj3N78ISWN-D1wmEKRoW5db3JwlXkdSV2sGzNZ-tcUat5u3ru5VUpFbZ21ySj1NjsLErY8J7YhFO_Fpindc0ixMF-NLMrxDodsNPQettBuHyi0xE3FvumliP5IUjD5wMbSMKyFXuGRPizY3Dxi9uWMPGaTKQq2FO88_A7oNoTEbH-KpmzQQDHRnumYnBKCXQFvc-PXWDb3YQ0lEZY8rT-j8xUvUDkIbk3x9a9XNtc1Ve7yJxxjXnsi5RvhUv4zUObHN-zyiOumJM2tlqtzn2zj0rUEAkkM4E8_rsMP6DkjSdllG830dGK9w", + "e": "AQAB", + "d": "H46fV27573_FxHQM5KP7mxfoos8gDVBj3maDhP--fnAExlm3XW87yWTMbEgqJ-5ro5gQQmwnaTJ0KJTG8ZVm3HQ5OmbhKliXisCE5iDZK075U6xg-3Y5N4sqN2lYUbrykty3viJtyEhPrOZFl8EEIqKlyQ8Zk_ot0HCd45khxei-GcpELzLHUhPxt7xX8PqxlcJOEWLbYN-8rAMw0w8fmOGYeeAqhPe6kHLZl5QCpOEyyqxq626NHmkNTmvz6riSseDVbcJdykonO24-4wnJLUAgqQwclQAN19K9kzI9K31nSYpMCAV8ONWl0EZMzeclrbXF13VXRNfs6Uci7q7pQQ", + "p": "-Ezlpn1K2xTWvAunNJkm2IY23i2n7TpiO69rI-fhGsZEzr1td8juJ3BiVH9II2anY8tk2hGO1HkHiUBxbeDAU1SxjMBFudWGKehssciG7PO3IoSNWlHXIQyaLS5BmToHvws035achqfHU41Ym0hGPRSGWL2j8KqaP2iNju-tf8c", + "q": "vo2i50b1m-5SJSLJ0KbL1S6J68FqgCr44Y39iXxbO2TfAdfI1yrruuJIE2q2EUEyBOcY6hZUIr8Bp9s4Xuc8Nih70fn5tqizv5g5wyA3H3X5zuut-QGQMTlkYOh7Q2e6fGfOPXEVpqbF7HLbD_u_ymhRz81R24n844Y_Las8-1E", + "dp": "WwrnEbrTHa1wZxbmwjR9t9rOd3zKHAPSTQwu_sVhFo4oYJ24a7lzCWb9xvm8m65iJgdH_WV-t42BI-RLMvy7yFI_n9pINrGCNg-Q-0cJogxi0_dCVSG4_dc_GN3jE6nheT1Er87rqbwPLCWhnZ5MkszZzeQLHo2PbTyK2yQqu7U", + "dq": "A1REKVhDCENkqgNulTekzgevHzBU5QJbMM2jRVin22TKxKR5fNDiJ3S2n6g4oBqMfitvcv-K3sSJid45oObXVXblmuiMJVXTNiC5uaABdopAIA5cDsKpO0LubwcAyE-yoWPK5aXpI-07_gKPszrJwgaKj1_d80D46xy4U9rvCNE", + "qi": "YyPE_s8vvsNUtvkMu3DwtPUuLZ9Ov4e8-A-8hEiJNdmU_nTQM9Rbuq0ARUmu6xuafk8QDCz27c_CdtNW4c7KahrzAGiw4hmNoBhq16gLf3A5PHocE0pjDzsPc3DF-C0rY7jrwZLLi4Sb0PDoo0t8Ca8BdbDvcp8ZjHDKdRqi8fo" +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/token_response.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/token_response.json new file mode 100644 index 0000000000..43d101157c --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/token_response.json @@ -0,0 +1,5 @@ +{ + "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlRlc3RAMjAyNCJ9.eyJpc3MiOiJodHRwczovL3RyaWFsLW9rdGEuY29tIiwiYXV0IjoiaW50ZXJuYWwiLCJhdWQiOiJodHRwczovL3RyaWFsLW9rdGEuY29tL2FwaS92MS91c2VycyIsInN1YiI6ImM0MDFuMjYwbnNvZkpiMDBk","token_type": "Bearer", + "expires_in": 3600, + "scope": "okta.users.read okta.groups.read" +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/users_page1.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/users_page1.json new file mode 100644 index 0000000000..5be211324f --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/users_page1.json @@ -0,0 +1,50 @@ +[ + { + "id": "00u4s64d9MYvzKmLW0g7", + "status": "ACTIVE", + "created": "2022-01-15T10:30:00.000Z", + "activated": "2022-01-15T10:35:00.000Z", + "statusChanged": "2022-01-15T10:35:00.000Z", + "lastLogin": "2026-07-20T14:22:15.000Z", + "lastUpdated": "2026-07-20T14:22:15.000Z", + "passwordChanged": "2022-01-15T10:35:00.000Z", + "type": { + "id": "oty5lqjjma4vvnx0P0g7" + }, + "profile": { + "firstName": "Jane", + "lastName": "Doe", + "mobilePhone": null, + "primaryPhone": null, + "secondEmail": null, + "secondPhone": null, + "preferredLanguage": null, + "userSegment": null, + "department": null, + "costCenter": null, + "organization": null, + "manager": null, + "managerId": null, + "login": "jane.doe@example.com", + "email": "jane.doe@example.com" + }, + "credentials": { + "password": {}, + "provider": { + "type": "OKTA", + "name": "OKTA" + } + }, + "_links": { + "self": { + "href": "https://trial-okta.okta.com/api/v1/users/00u4s64d9MYvzKmLW0g7" + }, + "activate": { + "href": "https://trial-okta.okta.com/api/v1/users/00u4s64d9MYvzKmLW0g7/lifecycle/activate" + }, + "deactivate": { + "href": "https://trial-okta.okta.com/api/v1/users/00u4s64d9MYvzKmLW0g7/lifecycle/deactivate" + } + } + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/users_page2.json b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/users_page2.json new file mode 100644 index 0000000000..0bd158b6df --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/fixtures/okta/users_page2.json @@ -0,0 +1,50 @@ +[ + { + "id": "00u5s74e0NZvzKmMX1h8", + "status": "DEPROVISIONED", + "created": "2022-02-10T09:15:00.000Z", + "activated": "2022-02-10T09:20:00.000Z", + "statusChanged": "2026-06-15T10:00:00.000Z", + "lastLogin": "2026-06-14T16:45:00.000Z", + "lastUpdated": "2026-06-15T10:00:00.000Z", + "passwordChanged": "2022-02-10T09:20:00.000Z", + "type": { + "id": "oty5lqjjma4vvnx0P0g7" + }, + "profile": { + "firstName": "John", + "lastName": "Smith", + "mobilePhone": null, + "primaryPhone": null, + "secondEmail": null, + "secondPhone": null, + "preferredLanguage": null, + "userSegment": null, + "department": null, + "costCenter": null, + "organization": null, + "manager": null, + "managerId": null, + "login": "john.smith@example.com", + "email": "john.smith@example.com" + }, + "credentials": { + "password": {}, + "provider": { + "type": "OKTA", + "name": "OKTA" + } + }, + "_links": { + "self": { + "href": "https://trial-okta.okta.com/api/v1/users/00u5s74e0NZvzKmMX1h8" + }, + "activate": { + "href": "https://trial-okta.okta.com/api/v1/users/00u5s74e0NZvzKmMX1h8/lifecycle/activate" + }, + "deactivate": { + "href": "https://trial-okta.okta.com/api/v1/users/00u5s74e0NZvzKmMX1h8/lifecycle/deactivate" + } + } + } +] diff --git a/crates/defguard_core/src/enterprise/directory_sync/google.rs b/crates/defguard_core/src/enterprise/directory_sync/google.rs index 530042abe8..475a45de5f 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/google.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/google.rs @@ -56,6 +56,9 @@ pub(crate) struct GoogleDirectorySync { access_token: Option, token_expiry: Option>, admin_email: String, + access_token_url: String, + groups_url: String, + all_users_url: String, } /// Google Directory API responses @@ -95,6 +98,7 @@ impl From for Vec { #[derive(Debug, Serialize, Deserialize)] struct User { + id: String, #[serde(rename = "primaryEmail")] primary_email: String, suspended: bool, @@ -105,7 +109,7 @@ impl From for DirectoryUser { Self { email: val.primary_email, active: !val.suspended, - id: None, + id: Some(val.id), // TODO: currently not supported for Google user_details: None, } @@ -143,9 +147,21 @@ impl GoogleDirectorySync { access_token: None, token_expiry: None, admin_email: admin_email.into(), + access_token_url: ACCESS_TOKEN_URL.into(), + groups_url: GROUPS_URL.into(), + all_users_url: ALL_USERS_URL.into(), } } + /// Overrides the Google API URLs so tests can point them at a mock server. + #[cfg(test)] + fn with_urls(mut self, access_token_url: &str, groups_url: &str, all_users_url: &str) -> Self { + self.access_token_url = access_token_url.into(); + self.groups_url = groups_url.into(); + self.all_users_url = all_users_url.into(); + self + } + pub async fn refresh_access_token(&mut self) -> Result<(), DirectorySyncError> { let token_response = self.query_access_token().await?; let expires_in = TimeDelta::seconds(token_response.expires_in); @@ -166,7 +182,7 @@ impl GoogleDirectorySync { .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; let response = make_get_request( - ALL_USERS_URL, + &self.all_users_url, access_token, Some(&[ ("customer", "my_customer"), @@ -196,7 +212,7 @@ impl GoogleDirectorySync { for _ in 0..MAX_REQUESTS { let response = make_get_request( - GROUPS_URL, + &self.groups_url, access_token, Some( &query @@ -248,7 +264,7 @@ impl GoogleDirectorySync { for _ in 0..MAX_REQUESTS { let response = make_get_request( - GROUPS_URL, + &self.groups_url, access_token, Some( &query @@ -295,10 +311,7 @@ impl GoogleDirectorySync { .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; - let url = format!( - "https://admin.googleapis.com/admin/directory/v1/groups/{}/members", - group.id - ); + let url = format!("{}/{}/members", self.groups_url, group.id); let mut combined_response = GroupMembersResponse::default(); let mut query = HashMap::from([ ("includeDerivedMembership".to_owned(), "true".to_owned()), @@ -356,7 +369,7 @@ impl GoogleDirectorySync { let token = self.build_token()?; let client = reqwest::Client::new(); let response = client - .post(ACCESS_TOKEN_URL) + .post(&self.access_token_url) .query(&[("grant_type", GRANT_TYPE), ("assertion", &token)]) .header(reqwest::header::CONTENT_LENGTH, 0) .timeout(REQUEST_TIMEOUT) @@ -382,7 +395,7 @@ impl GoogleDirectorySync { for _ in 0..MAX_REQUESTS { let response = make_get_request( - ALL_USERS_URL, + &self.all_users_url, access_token, Some( &query @@ -479,84 +492,37 @@ impl DirectorySync for GoogleDirectorySync { } #[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_token() { - let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); - - // no token - assert!(dirsync.is_token_expired()); +const TEST_RSA_PRIVATE_KEY: &str = include_str!("fixtures/google/test_private_key.pem"); - // expired token - dirsync.access_token = Some("test_token".into()); - dirsync.token_expiry = Some(Utc::now() - TimeDelta::seconds(10000)); - assert!(dirsync.is_token_expired()); - - // valid token - dirsync.access_token = Some("test_token".into()); - dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(10000)); - assert!(!dirsync.is_token_expired()); - } - - #[tokio::test] - async fn test_group_members_parse() { - let response = GroupMembersResponse { - members: Some(vec![ - GroupMember { - email: "email@email.com".into(), - status: Some("active".into()), - }, - GroupMember { - email: "email2@email.com".into(), - status: Some("active".into()), - }, - GroupMember { - email: "email3@email.com".into(), - status: Some("suspended".into()), - }, - GroupMember { - email: "email4@email.com".into(), - status: None, - }, - ]), - page_token: None, - }; - - let members: Vec = response.into(); - assert_eq!(members.len(), 3); - assert!(members.contains(&"email@email.com".into())); - assert!(members.contains(&"email2@email.com".into())); - assert!(members.contains(&"email3@email.com".into())); - } +#[cfg(test)] +pub(crate) fn response_from_fixture(name: &str) -> wiremock::ResponseTemplate { + let body = match name { + "token_response.json" => include_str!("fixtures/google/token_response.json"), + "users_page1.json" => include_str!("fixtures/google/users_page1.json"), + "users_page2.json" => include_str!("fixtures/google/users_page2.json"), + "users_empty.json" => include_str!("fixtures/google/users_empty.json"), + "groups_response.json" => include_str!("fixtures/google/groups_response.json"), + "members_response.json" => include_str!("fixtures/google/members_response.json"), + other => panic!("unknown fixture: {other}"), + }; + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string(body) +} - #[tokio::test] - async fn test_all_users_parse() { - let response = UsersResponse { - users: vec![ - User { - primary_email: "email@email.com".into(), - suspended: false, - }, - User { - primary_email: "email2@email.com".into(), - suspended: true, - }, - User { - primary_email: "email3@email.com".into(), - suspended: false, - }, - ], - page_token: None, - }; - - let users: Vec = response.into(); - assert_eq!(users.len(), 3); - let disabled_user = users - .iter() - .find(|u| u.email == "email2@email.com") - .unwrap(); - assert!(!disabled_user.active); - } +#[cfg(test)] +pub(crate) fn dirsync_with_mock_server(mock_server: &wiremock::MockServer) -> GoogleDirectorySync { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email") + .with_urls( + &format!("{}/token", mock_server.uri()), + &format!("{}/groups", mock_server.uri()), + &format!("{}/users", mock_server.uri()), + ); + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(3600)); + dirsync } + +#[cfg(test)] +#[path = "provider_tests/google.rs"] +mod tests; diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs index d500917587..55fff20506 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -127,6 +127,7 @@ struct GroupMemberThing { pub(crate) struct JumpCloudDirectorySync { api_key: String, api_host: &'static str, + base_url_override: Option, } impl JumpCloudDirectorySync { @@ -136,26 +137,53 @@ impl JumpCloudDirectorySync { "Initializing JumpCloud directory sync with API key length: {}", api_key.len() ); - Self { api_key, api_host } + Self { + api_key, + api_host, + base_url_override: None, + } + } + + /// Overrides the JumpCloud API base URL so tests can point it at a mock server. + #[cfg(test)] + fn with_base_url(mut self, base_url: &str) -> Self { + self.base_url_override = Some(base_url.into()); + self } fn groups_url(&self) -> String { - format!("https://{}/api/v2/usergroups", self.api_host) + if let Some(override_url) = &self.base_url_override { + format!("{override_url}/api/v2/usergroups") + } else { + format!("https://{}/api/v2/usergroups", self.api_host) + } } fn all_users_url(&self) -> String { - format!("https://{}/api/systemusers", self.api_host) + if let Some(override_url) = &self.base_url_override { + format!("{override_url}/api/systemusers") + } else { + format!("https://{}/api/systemusers", self.api_host) + } } fn user_groups_url(&self, user_id: &str) -> String { - format!("https://{}/api/v2/users/{user_id}/memberof", self.api_host) + if let Some(override_url) = &self.base_url_override { + format!("{override_url}/api/v2/users/{user_id}/memberof") + } else { + format!("https://{}/api/v2/users/{user_id}/memberof", self.api_host) + } } fn user_group_members_url(&self, group_id: &str) -> String { - format!( - "https://{}/api/v2/usergroups/{group_id}/members", - self.api_host - ) + if let Some(override_url) = &self.base_url_override { + format!("{override_url}/api/v2/usergroups/{group_id}/members") + } else { + format!( + "https://{}/api/v2/usergroups/{group_id}/members", + self.api_host + ) + } } async fn query_group_members( @@ -652,203 +680,33 @@ impl DirectorySync for JumpCloudDirectorySync { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_user_to_directory_user_conversions() { - // Test active user (activated=true, account_locked=false, state=ACTIVATED) - let active_user = User { - email: "active@example.com".to_owned(), - activated: true, - account_locked: false, - id: "user123".to_owned(), - state: UserState::Activated, - }; - let active_directory_user: DirectoryUser = active_user.into(); - assert_eq!(active_directory_user.email, "active@example.com"); - assert!(active_directory_user.active); - assert_eq!(active_directory_user.id, Some("user123".to_owned())); - - // Test inactive user (activated=false) - let inactive_user = User { - email: "inactive@example.com".to_owned(), - activated: false, - account_locked: false, - id: "user456".to_owned(), - state: UserState::Activated, - }; - let inactive_directory_user: DirectoryUser = inactive_user.into(); - assert_eq!(inactive_directory_user.email, "inactive@example.com"); - assert!(!inactive_directory_user.active); - assert_eq!(inactive_directory_user.id, Some("user456".to_owned())); - - // Test locked user (account_locked=true) - let locked_user = User { - email: "locked@example.com".to_owned(), - activated: true, - account_locked: true, - id: "user789".to_owned(), - state: UserState::Activated, - }; - let locked_directory_user: DirectoryUser = locked_user.into(); - assert_eq!(locked_directory_user.email, "locked@example.com"); - assert!(!locked_directory_user.active); - assert_eq!(locked_directory_user.id, Some("user789".to_owned())); - - // Test suspended user (state=SUSPENDED) - let suspended_user = User { - email: "suspended@example.com".to_owned(), - activated: true, - account_locked: false, - id: "user999".to_owned(), - state: UserState::Suspended, - }; - let suspended_directory_user: DirectoryUser = suspended_user.into(); - assert_eq!(suspended_directory_user.email, "suspended@example.com"); - assert!(!suspended_directory_user.active); - assert_eq!(suspended_directory_user.id, Some("user999".to_owned())); - - // Test staged user (state=STAGED) - let staged_user = User { - email: "staged@example.com".to_owned(), - activated: true, - account_locked: false, - id: "user888".to_owned(), - state: UserState::Staged, - }; - let staged_directory_user: DirectoryUser = staged_user.into(); - assert_eq!(staged_directory_user.email, "staged@example.com"); - assert!(!staged_directory_user.active); - assert_eq!(staged_directory_user.id, Some("user888".to_owned())); - - // Test both inactive and locked user - let both_user = User { - email: "both@example.com".to_owned(), - activated: false, - account_locked: true, - id: "user000".to_owned(), - state: UserState::Activated, - }; - let both_directory_user: DirectoryUser = both_user.into(); - assert_eq!(both_directory_user.email, "both@example.com"); - assert!(!both_directory_user.active); - assert_eq!(both_directory_user.id, Some("user000".to_owned())); - } - - #[test] - fn test_user_group_to_directory_group_conversions() { - // Test group with LDAP groups (uses first LDAP group name) - let group_with_ldap = UserGroup { - id: "group123".to_owned(), - compiled_attributes: CompiledAttributes { - ldap_groups: vec![ - LdapGroup { - name: "LDAP Group Name".to_owned(), - }, - LdapGroup { - name: "Second LDAP Group".to_owned(), - }, - ], - }, - }; - let directory_group_with_ldap: DirectoryGroup = group_with_ldap.into(); - assert_eq!(directory_group_with_ldap.id, "group123"); - assert_eq!(directory_group_with_ldap.name, "LDAP Group Name"); - - // Test group with empty LDAP groups (falls back to group ID) - let group_empty_ldap = UserGroup { - id: "group789".to_owned(), - compiled_attributes: CompiledAttributes { - ldap_groups: Vec::new(), - }, - }; - let directory_group_empty_ldap: DirectoryGroup = group_empty_ldap.into(); - assert_eq!(directory_group_empty_ldap.id, "group789"); - assert_eq!(directory_group_empty_ldap.name, "group789"); - } +pub(crate) fn response_from_fixture(name: &str) -> wiremock::ResponseTemplate { + let body = match name { + "users_page1.json" => include_str!("fixtures/jumpcloud/users_page1.json"), + "users_page2.json" => include_str!("fixtures/jumpcloud/users_page2.json"), + "users_empty.json" => include_str!("fixtures/jumpcloud/users_empty.json"), + "groups_response.json" => include_str!("fixtures/jumpcloud/groups_response.json"), + "group_members_response.json" => { + include_str!("fixtures/jumpcloud/group_members_response.json") + } + "user_groups_response.json" => { + include_str!("fixtures/jumpcloud/user_groups_response.json") + } + other => panic!("unknown fixture: {other}"), + }; + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string(body) +} - #[test] - fn test_response_collection_conversions() { - // Test empty UsersResponse conversion - let empty_users_response = UsersResponse { - results: Vec::new(), - total_count: 0, - }; - let empty_directory_users: Vec = empty_users_response.into(); - assert!(empty_directory_users.is_empty()); - - // Test single user UsersResponse conversion - let single_users_response = UsersResponse { - results: vec![User { - email: "single@example.com".to_owned(), - activated: true, - account_locked: false, - id: "single123".to_owned(), - state: UserState::Activated, - }], - total_count: 1, - }; - let single_directory_users: Vec = single_users_response.into(); - assert_eq!(single_directory_users.len(), 1); - assert_eq!(single_directory_users[0].email, "single@example.com"); - assert!(single_directory_users[0].active); - assert_eq!(single_directory_users[0].id, Some("single123".to_owned())); - - // Test multiple users with mixed states - let multiple_users_response = UsersResponse { - results: vec![ - User { - email: "user1@example.com".to_owned(), - activated: true, - account_locked: false, - id: "user1".to_owned(), - state: UserState::Activated, - }, - User { - email: "user2@example.com".to_owned(), - activated: false, - account_locked: false, - id: "user2".to_owned(), - state: UserState::Activated, - }, - User { - email: "user3@example.com".to_owned(), - activated: true, - account_locked: true, - id: "user3".to_owned(), - state: UserState::Activated, - }, - ], - total_count: 3, - }; - let multiple_directory_users: Vec = multiple_users_response.into(); - assert_eq!(multiple_directory_users.len(), 3); - assert_eq!(multiple_directory_users[0].email, "user1@example.com"); - assert!(multiple_directory_users[0].active); - assert_eq!(multiple_directory_users[1].email, "user2@example.com"); - assert!(!multiple_directory_users[1].active); - assert_eq!(multiple_directory_users[2].email, "user3@example.com"); - assert!(!multiple_directory_users[2].active); - - // Test GroupsResponse conversion - let groups_response = GroupsResponse { - results: vec![ - DirectoryGroup { - id: "group1".to_owned(), - name: "Group 1".to_owned(), - }, - DirectoryGroup { - id: "group2".to_owned(), - name: "Group 2".to_owned(), - }, - ], - }; - let directory_groups: Vec = groups_response.into(); - assert_eq!(directory_groups.len(), 2); - assert_eq!(directory_groups[0].id, "group1"); - assert_eq!(directory_groups[0].name, "Group 1"); - assert_eq!(directory_groups[1].id, "group2"); - assert_eq!(directory_groups[1].name, "Group 2"); - } +#[cfg(test)] +pub(crate) fn dirsync_with_mock_server( + mock_server: &wiremock::MockServer, +) -> JumpCloudDirectorySync { + JumpCloudDirectorySync::new("test_api_key".into(), "console.jumpcloud.com") + .with_base_url(&mock_server.uri()) } + +#[cfg(test)] +#[path = "provider_tests/jumpcloud.rs"] +mod tests; diff --git a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs index e969a3b961..b2d010f373 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs @@ -15,6 +15,10 @@ pub(crate) struct MicrosoftDirectorySync { client_secret: String, url: String, group_filter: Vec, + #[cfg(test)] + access_token_url_override: Option, + #[cfg(test)] + graph_base_url_override: Option, } const ACCESS_TOKEN_URL: &str = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"; @@ -174,7 +178,7 @@ struct IdResponse { } impl MicrosoftDirectorySync { - pub(crate) const fn new( + pub(crate) fn new( client_id: String, client_secret: String, url: String, @@ -187,9 +191,21 @@ impl MicrosoftDirectorySync { url, token_expiry: None, group_filter: match_groups, + #[cfg(test)] + access_token_url_override: None, + #[cfg(test)] + graph_base_url_override: None, } } + /// Overrides the Microsoft API URLs so tests can point them at a mock server. + #[cfg(test)] + fn with_urls(mut self, access_token_url: &str, graph_base_url: &str) -> Self { + self.access_token_url_override = Some(access_token_url.into()); + self.graph_base_url_override = Some(graph_base_url.into()); + self + } + fn extract_tenant(&self) -> Result { debug!("Extracting tenant ID from Microsoft base URL: {}", self.url); let parts: Vec<&str> = self.url.split('/').collect(); @@ -233,8 +249,22 @@ impl MicrosoftDirectorySync { .access_token .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; + let url = { + #[cfg(test)] + { + if let Some(base) = &self.graph_base_url_override { + format!("{base}/users") + } else { + ALL_USERS_URL.to_owned() + } + } + #[cfg(not(test))] + { + ALL_USERS_URL.to_owned() + } + }; let response = make_get_request( - ALL_USERS_URL, + &url, access_token, Some(&[("$top", "1"), ("$select", USER_QUERY_FIELDS)]), ) @@ -246,8 +276,20 @@ impl MicrosoftDirectorySync { async fn query_access_token(&self) -> Result { debug!("Querying Microsoft directory sync access token."); - let tenant_id = self.extract_tenant()?; - let token_url = ACCESS_TOKEN_URL.replace("{tenant_id}", &tenant_id); + let token_url = { + #[cfg(test)] + if let Some(override_url) = &self.access_token_url_override { + override_url.clone() + } else { + let tenant_id = self.extract_tenant()?; + ACCESS_TOKEN_URL.replace("{tenant_id}", &tenant_id) + } + #[cfg(not(test))] + { + let tenant_id = self.extract_tenant()?; + ACCESS_TOKEN_URL.replace("{tenant_id}", &tenant_id) + } + }; let client = reqwest::Client::new(); let response = client .post(&token_url) @@ -275,7 +317,20 @@ impl MicrosoftDirectorySync { .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; let mut combined_response = GroupsResponse::default(); - let mut url = GROUPS_URL.to_owned(); + let mut url = { + #[cfg(test)] + { + if let Some(base) = &self.graph_base_url_override { + format!("{base}/groups") + } else { + GROUPS_URL.to_owned() + } + } + #[cfg(not(test))] + { + GROUPS_URL.to_owned() + } + }; if self.group_filter.is_empty() { debug!("No group filter defined, all groups will be synced."); @@ -353,9 +408,20 @@ impl MicrosoftDirectorySync { .ok_or(DirectorySyncError::AccessTokenExpired)?; // Get the user ID from their email address first - let user_search = USER_SEARCH_URL - .replace("{email}", user_email) - .replace("{query_fields}", USER_QUERY_FIELDS); + let user_search = { + #[cfg(test)] + if let Some(base) = &self.graph_base_url_override { + format!("{base}/users?$select=id&$filter=mail eq '{user_email}'") + } else { + USER_SEARCH_URL + .replace("{email}", user_email) + .replace("{query_fields}", USER_QUERY_FIELDS) + } + #[cfg(not(test))] + USER_SEARCH_URL + .replace("{email}", user_email) + .replace("{query_fields}", USER_QUERY_FIELDS) + }; let response = make_get_request(&user_search, access_token, None).await?; let response: IdResponse = parse_response(response, "Failed to query user from Microsoft API.").await?; @@ -370,9 +436,22 @@ impl MicrosoftDirectorySync { debug!( "User with email {user_email} not found in Microsoft API, trying fallback search of additional email addresses", ); - let user_search = USER_SEARCH_URL_FALLBACK - .replace("{email}", user_email) - .replace("{query_fields}", USER_QUERY_FIELDS); + let user_search = { + #[cfg(test)] + if let Some(base) = &self.graph_base_url_override { + format!( + "{base}/users?$select=id&$filter=(otherMails/any(p:p eq '{user_email}'))" + ) + } else { + USER_SEARCH_URL_FALLBACK + .replace("{email}", user_email) + .replace("{query_fields}", USER_QUERY_FIELDS) + } + #[cfg(not(test))] + USER_SEARCH_URL_FALLBACK + .replace("{email}", user_email) + .replace("{query_fields}", USER_QUERY_FIELDS) + }; let response = make_get_request(&user_search, access_token, None).await?; let response: IdResponse = parse_response(response, "Failed to query user from Microsoft API.").await?; @@ -387,7 +466,20 @@ impl MicrosoftDirectorySync { } }; - let mut url = USER_GROUPS.replace("{user_id}", &user_id); + let mut url = { + #[cfg(test)] + { + if let Some(base) = &self.graph_base_url_override { + format!("{base}/users/{user_id}/memberOf") + } else { + USER_GROUPS.replace("{user_id}", &user_id) + } + } + #[cfg(not(test))] + { + USER_GROUPS.replace("{user_id}", &user_id) + } + }; let mut combined_response = GroupsResponse::default(); let mut query = Some([("$top", MAX_RESULTS)].as_slice()); @@ -450,7 +542,20 @@ impl MicrosoftDirectorySync { .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; let mut combined_response = GroupMembersResponse::default(); - let mut url = GROUP_MEMBERS.replace("{group_id}", &group.id); + let mut url = { + #[cfg(test)] + { + if let Some(base) = &self.graph_base_url_override { + format!("{}/groups/{}/members", base, group.id) + } else { + GROUP_MEMBERS.replace("{group_id}", &group.id) + } + } + #[cfg(not(test))] + { + GROUP_MEMBERS.replace("{group_id}", &group.id) + } + }; let mut query = Some([("$top", MAX_RESULTS), ("$select", USER_QUERY_FIELDS)].as_slice()); for _ in 0..MAX_REQUESTS { @@ -488,7 +593,20 @@ impl MicrosoftDirectorySync { .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; let mut combined_response = UsersResponse::default(); - let mut url = ALL_USERS_URL.to_owned(); + let mut url = { + #[cfg(test)] + { + if let Some(base) = &self.graph_base_url_override { + format!("{base}/users") + } else { + ALL_USERS_URL.to_owned() + } + } + #[cfg(not(test))] + { + ALL_USERS_URL.to_owned() + } + }; let mut query = Some([("$top", MAX_RESULTS), ("$select", USER_QUERY_FIELDS)].as_slice()); for _ in 0..MAX_REQUESTS { @@ -572,160 +690,55 @@ impl DirectorySync for MicrosoftDirectorySync { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_tenant() { - let provider = MicrosoftDirectorySync::new( - "client_id".to_owned(), - "client_secret".to_owned(), - "https://login.microsoftonline.com/tenant-id-123/v2.0".to_owned(), - Vec::new(), - ); - let tenant = provider.extract_tenant().unwrap(); - assert_eq!(tenant, "tenant-id-123"); - } - - #[tokio::test] - async fn test_token() { - let mut dirsync = MicrosoftDirectorySync::new( - "id".to_owned(), - "secret".to_owned(), - "https://login.microsoftonline.com/tenant-id-123/v2.0".to_owned(), - Vec::new(), - ); - - // no token - assert!(dirsync.is_token_expired()); - - // expired token - dirsync.access_token = Some("test_token".into()); - dirsync.token_expiry = Some(Utc::now() - TimeDelta::seconds(10000)); - assert!(dirsync.is_token_expired()); - - // valid token - dirsync.access_token = Some("test_token".into()); - dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(10000)); - assert!(!dirsync.is_token_expired()); - } - - #[tokio::test] - async fn test_groups_parse() { - let groups_response = GroupsResponse { - next_page: None, - value: vec![ - GroupDetails { - display_name: Some("Group 1".to_owned()), - id: "1".to_owned(), - }, - GroupDetails { - display_name: Some("Group 2".to_owned()), - id: "2".to_owned(), - }, - ], - }; - - let groups: Vec = groups_response.into(); - - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].name, "Group 1"); - assert_eq!(groups[0].id, "1"); - assert_eq!(groups[1].name, "Group 2"); - assert_eq!(groups[1].id, "2"); - } - - #[tokio::test] - async fn test_members_parse() { - let members_response = GroupMembersResponse { - next_page: None, - value: vec![ - User { - display_name: "User 1".to_owned(), - mail: Some("email@email.com".to_owned()), - account_enabled: true, - other_mails: Vec::new(), - id: "user1-id".into(), - given_name: Some("User".into()), - surname: Some("One".into()), - mobile_phone: Some("555555555".into()), - business_phones: Vec::new(), - }, - User { - display_name: "User 2".to_owned(), - mail: None, - account_enabled: true, - other_mails: vec!["email2@email.com".to_owned()], - id: "user2-id".into(), - given_name: Some("User".into()), - surname: Some("Two".into()), - mobile_phone: None, - business_phones: Vec::new(), - }, - User { - display_name: "User 3".to_owned(), - mail: None, - account_enabled: true, - other_mails: Vec::new(), - id: "user3-id".into(), - given_name: Some("User".into()), - surname: Some("Three".into()), - mobile_phone: None, - business_phones: Vec::new(), - }, - ], - }; - - let members: Vec = members_response.into(); - assert_eq!(members.len(), 2); - assert_eq!(members[0], "email@email.com".to_owned()); - assert_eq!(members[1], "email2@email.com".to_owned()); - } +pub(crate) fn response_from_fixture(name: &str) -> wiremock::ResponseTemplate { + let body = match name { + "token_response.json" => include_str!("fixtures/microsoft/token_response.json"), + "users_page1.json" => include_str!("fixtures/microsoft/users_page1.json"), + "users_page2.json" => include_str!("fixtures/microsoft/users_page2.json"), + "users_empty.json" => include_str!("fixtures/microsoft/users_empty.json"), + "groups_response.json" => include_str!("fixtures/microsoft/groups_response.json"), + "members_response.json" => include_str!("fixtures/microsoft/members_response.json"), + "user_search_response.json" => include_str!("fixtures/microsoft/user_search_response.json"), + "user_groups_response.json" => include_str!("fixtures/microsoft/user_groups_response.json"), + other => panic!("unknown fixture: {other}"), + }; + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string(body) +} - #[tokio::test] - async fn test_users_parse() { - let users_response = UsersResponse { - next_page: None, - value: vec![ - User { - display_name: "User 1".to_owned(), - mail: Some("email@email.com".to_owned()), - account_enabled: true, - other_mails: Vec::new(), - id: "user1-id".into(), - given_name: Some("User".into()), - surname: None, - mobile_phone: None, - business_phones: Vec::new(), - }, - User { - display_name: "User 2".to_owned(), - mail: None, - account_enabled: true, - other_mails: vec!["email2@email.com".to_owned()], - id: "user2-id".into(), - given_name: None, - surname: None, - mobile_phone: Some("555555555".into()), - business_phones: Vec::new(), - }, - User { - display_name: "User 3".to_owned(), - mail: None, - account_enabled: true, - other_mails: Vec::new(), - id: "user3-id".into(), - given_name: Some("User".into()), - surname: Some("Three".into()), - mobile_phone: Some("555555555".into()), - business_phones: Vec::new(), - }, - ], - }; +// Ports are ephemeral per MockServer, so pagination links can't be baked into the fixture. +#[cfg(test)] +pub(crate) fn response_from_fixture_with_mock_uri( + name: &str, + mock_server_uri: &str, +) -> wiremock::ResponseTemplate { + let mut body = match name { + "users_page1.json" => include_str!("fixtures/microsoft/users_page1.json").to_string(), + other => panic!("fixture {other} doesn't support mock URI replacement"), + }; + body = body.replace("MOCK_SERVER_URI", mock_server_uri); + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string(body) +} - let users: Vec = users_response.into(); - assert_eq!(users.len(), 2); - assert_eq!(users[0].email, "email@email.com".to_owned()); - assert_eq!(users[1].email, "email2@email.com".to_owned()); - } +#[cfg(test)] +pub(crate) fn dirsync_with_mock_server( + mock_server: &wiremock::MockServer, +) -> MicrosoftDirectorySync { + let mut dirsync = MicrosoftDirectorySync::new( + "client_id".into(), + "client_secret".into(), + "https://login.microsoftonline.com/tenant-123/v2.0".into(), + Vec::new(), + ) + .with_urls(&format!("{}/token", mock_server.uri()), &mock_server.uri()); + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(3600)); + dirsync } + +#[cfg(test)] +#[path = "provider_tests/microsoft.rs"] +mod tests; diff --git a/crates/defguard_core/src/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs index 57ec34960d..1df8e37b5c 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -19,7 +19,10 @@ use tokio::sync::{broadcast::Sender, mpsc::UnboundedSender}; use super::{ REQUEST_TIMEOUT, - db::models::openid_provider::{DirectorySyncTarget, OpenIdProvider}, + db::models::{ + openid_provider::{DirectorySyncTarget, OpenIdProvider}, + user_directory_identity::UserDirectoryIdentity, + }, ldap::utils::ldap_update_users_state, }; #[cfg(not(test))] @@ -120,6 +123,8 @@ pub mod okta; pub mod testprovider; #[cfg(test)] pub mod tests; +#[cfg(test)] +pub mod tests_cross_provider; #[derive(Debug, Serialize, Deserialize)] pub struct DirectoryGroup { @@ -487,6 +492,16 @@ pub async fn sync_user_groups_if_configured( debug!("Directory sync is disabled, skipping syncing user groups"); return Ok(()); } + if !matches!( + provider.directory_sync_target, + DirectorySyncTarget::All | DirectorySyncTarget::Groups + ) { + debug!( + "Directory sync target is set to {}, skipping syncing user groups", + provider.directory_sync_target + ); + return Ok(()); + } match DirectorySyncClient::build(pool).await { Ok(mut dir_sync) => { @@ -729,7 +744,7 @@ async fn sync_all_users_state( ldap_tx: &UnboundedSender, dirsync_tx: &UnboundedSender, all_users: &[DirectoryUser], - prefetch_allowed_emails: Option>, + allowed_emails: Option>, ) -> Result<(), DirectorySyncError> { info!("Syncing all users' state with the directory, this may take a while..."); let mut transaction = pool.begin().await?; @@ -743,7 +758,7 @@ async fn sync_all_users_state( let prefetch_users = settings.prefetch_users; let is_allowed_user = |user: &DirectoryUser| -> bool { - prefetch_allowed_emails + allowed_emails .as_ref() .is_none_or(|allowed| allowed.contains(&user.email)) }; @@ -807,6 +822,49 @@ async fn sync_all_users_state( .filter(|user| is_allowed_user(user)) .collect(); + // Backfill the directory identity for users who already exist in Defguard but don't have + // an identity mapping stored yet, e.g. they were created before prefetch was enabled, + // imported some other way, or have only ever logged in via SSO. + let directory_ids_by_email: HashMap<&str, &str> = all_users + .iter() + .filter_map(|u| u.id.as_deref().map(|id| (u.email.as_str(), id))) + .collect(); + for existing_user in &existing_users { + if let Some(&directory_id) = directory_ids_by_email.get(existing_user.email.as_str()) { + let provider_id = settings.id; + let existing_identity = UserDirectoryIdentity::find_by_user_and_provider( + &mut *transaction, + existing_user.id, + provider_id, + ) + .await?; + if existing_identity.is_none() { + let claimed_by = UserDirectoryIdentity::find_user_by_provider_external_id( + &mut *transaction, + provider_id, + directory_id, + ) + .await?; + if claimed_by.is_some_and(|user_id| user_id != existing_user.id) { + warn!( + "Directory id {directory_id} matches Defguard user {} by email, but \ + is already mapped to a different Defguard user. Skipping identity \ + backfill.", + existing_user.username + ); + continue; + } + UserDirectoryIdentity::upsert( + &mut *transaction, + existing_user.id, + provider_id, + directory_id, + ) + .await?; + } + } + } + let core_settings = Settings::get_current_settings(); // create missing users @@ -819,6 +877,59 @@ async fn sync_all_users_state( ); } Some(details) => { + // The directory ID uniquely identifies a user regardless of their email + // address. If it matches an existing Defguard user, the user's email was + // changed in the directory rather than the user being new. Update the + // existing user instead of trying to create a duplicate. + if let Some(directory_id) = &directory_user.id + && let Some(user_id) = + UserDirectoryIdentity::find_user_by_provider_external_id( + &mut *transaction, + settings.id, + directory_id, + ) + .await? + && let Some(mut existing_user) = + User::find_by_ids(&mut *transaction, &[user_id]) + .await? + .pop() + { + // Another Defguard user may already occupy the new email address (e.g. a + // manually created account). Skip this user. + if let Some(conflicting_user) = + User::find_by_email(&mut *transaction, &directory_user.email).await? + && conflicting_user.id != existing_user.id + { + error!( + "Cannot change email of user {} from {} to {} because that email \ + is already used by another Defguard user ({}). Skipping.", + existing_user.username, + existing_user.email, + directory_user.email, + conflicting_user.username + ); + continue; + } + + info!( + "User {} changed email in the directory from {} to {}, updating in \ + Defguard", + existing_user.username, existing_user.email, directory_user.email + ); + let before = existing_user.clone(); + existing_user.email = directory_user.email.clone(); + existing_user.first_name = details.first_name.clone(); + existing_user.last_name = details.last_name.clone(); + existing_user.phone = details.phone_number.clone(); + existing_user.save(&mut *transaction).await?; + dirsync_events.push(DirectorySyncEventType::UserModified { + before, + after: existing_user.clone(), + }); + modified_users.push(existing_user); + continue; + } + debug!( "User {directory_user:?} exists in directory but not in Defguard. Creating \ new Defguard user.", @@ -847,7 +958,7 @@ async fn sync_all_users_state( ))); } - let mut user = User::new( + let user = User::new( username, None, details.last_name.clone(), @@ -855,7 +966,6 @@ async fn sync_all_users_state( directory_user.email.clone(), details.phone_number.clone(), ); - user.openid_sub.clone_from(&directory_user.id); if let Some(limit) = user_limit.filter(|limit| user_count >= *limit) { error!( "Skipping directory sync import of user {} (email: {}) because \ @@ -869,6 +979,15 @@ async fn sync_all_users_state( continue; } let new_user = user.save(&mut *transaction).await?; + if let Some(directory_id) = &directory_user.id { + UserDirectoryIdentity::upsert( + &mut *transaction, + new_user.id, + settings.id, + directory_id, + ) + .await?; + } user_count += 1; dirsync_events.push(DirectorySyncEventType::UserCreated { user: new_user.clone(), @@ -1175,7 +1294,6 @@ pub async fn do_directory_sync( let provider = provider.ok_or(DirectorySyncError::NotConfigured)?; let sync_target = provider.directory_sync_target; - let prefetch_users = provider.prefetch_users; let provider_name = provider.name.clone(); let user_groups_filter = provider .directory_sync_user_groups @@ -1199,18 +1317,18 @@ pub async fn do_directory_sync( ) { let users = dir_sync.get_all_users().await?; - // If prefetch is enabled and a user group filter is configured, build a set - // of emails of users who are members of those groups. Only those users will - // be imported by the prefetch. When the filter is empty we pass None and - // import everyone. - let prefetch_allowed_emails = if prefetch_users && !user_groups_filter.is_empty() { + // If a user group filter is configured, build a set of emails of users who are + // members of those groups. Only those users are considered for syncing (state + // updates and, when supported by the provider, prefetch/import of new users). + // When the filter is empty we pass None and consider everyone. + let allowed_emails = if !user_groups_filter.is_empty() { let groups = dir_sync.get_groups().await?; // get_groups() may itself be limited by the membership sync group filter (directory_sync_group_match), // so groups configured here must also be included there if that filter is in use. for group_name in &user_groups_filter { if !groups.iter().any(|group| &group.name == group_name) { warn!( - "Group '{group_name}' configured for user prefetch was not found among the directory groups, its members won't be imported. + "Group '{group_name}' configured for user sync was not found among the directory groups, its members won't be synced. Make sure the group name is correct and that it's also included in the membership sync group filter, if one is defined." ); } @@ -1223,7 +1341,7 @@ pub async fn do_directory_sync( match dir_sync.get_group_members(group, Some(&users)).await { Ok(members) => { debug!( - "Adding {} members of group '{}' to the prefetch", + "Adding {} members of group '{}' to the set of users allowed to sync", members.len(), group.name ); @@ -1231,7 +1349,7 @@ pub async fn do_directory_sync( } Err(err) => { error!( - "Failed to get members of group '{}' for the prefetch filter: {err}", + "Failed to get members of group '{}' for the user sync filter: {err}", group.name ); } @@ -1248,7 +1366,7 @@ pub async fn do_directory_sync( ldap_tx, dirsync_tx, &users, - prefetch_allowed_emails, + allowed_emails, ) .await?; all_users = Some(users); diff --git a/crates/defguard_core/src/enterprise/directory_sync/okta.rs b/crates/defguard_core/src/enterprise/directory_sync/okta.rs index 74acb56a4f..4f6880da15 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/okta.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/okta.rs @@ -86,6 +86,7 @@ struct UserProfile { #[derive(Debug, Deserialize)] struct User { + id: String, status: String, profile: UserProfile, } @@ -95,7 +96,7 @@ impl From for DirectoryUser { Self { email: val.profile.email, active: ACTIVE_STATUS.contains(&val.status.as_str()), - id: None, + id: Some(val.id), // TODO: currently not supported for Okta user_details: None, } @@ -466,89 +467,32 @@ impl DirectorySync for OktaDirectorySync { } #[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_token() { - let mut dirsync = - OktaDirectorySync::new("private_key", "client_id", "https://trial-0000000.okta.com"); - - // no token - assert!(dirsync.is_token_expired()); - - // expired token - dirsync.access_token = Some("test_token".into()); - dirsync.token_expiry = Some(Utc::now() - TimeDelta::seconds(10000)); - assert!(dirsync.is_token_expired()); - - // valid token - dirsync.access_token = Some("test_token".into()); - dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(10000)); - assert!(!dirsync.is_token_expired()); - } - - #[tokio::test] - async fn test_header() { - let link_header = - "; rel=\"next\"" - .to_owned(); - let next_link = extract_next_link(Some(&link_header)).unwrap(); - assert_eq!( - next_link, - Some("https://trial-0000000.okta.com/api/v1/users?after=4&limit=200".to_owned()) - ); - - let next_link = extract_next_link(None).unwrap(); - assert_eq!(next_link, None); - - let link_header = "invalid".to_owned(); - let next_link = extract_next_link(Some(&link_header)); - assert!(next_link.is_err()); - - let link_header = "; rel=\"next\", ; rel=\"prev\"".to_owned(); - let next_link = extract_next_link(Some(&link_header)).unwrap(); - assert_eq!( - next_link, - Some("https://trial-0000000.okta.com/api/v1/users?after=4&limit=200".to_owned()) - ); - } +const TEST_JWK_PRIVATE_KEY: &str = include_str!("fixtures/okta/test_jwk_private_key.json"); - #[tokio::test] - async fn test_group_parse() { - let group = Group { - id: "test_id".to_owned(), - profile: GroupProfile { - name: "test_name".to_owned(), - }, - }; - let dir_group: DirectoryGroup = group.into(); - assert_eq!(dir_group.id, "test_id"); - assert_eq!(dir_group.name, "test_name"); - } +#[cfg(test)] +pub(crate) fn response_from_fixture(name: &str) -> wiremock::ResponseTemplate { + let body = match name { + "token_response.json" => include_str!("fixtures/okta/token_response.json"), + "users_page1.json" => include_str!("fixtures/okta/users_page1.json"), + "users_page2.json" => include_str!("fixtures/okta/users_page2.json"), + "groups_response.json" => include_str!("fixtures/okta/groups_response.json"), + "group_members_response.json" => include_str!("fixtures/okta/group_members_response.json"), + other => panic!("unknown fixture: {other}"), + }; + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string(body) +} - #[tokio::test] - async fn test_user_parse() { - let user = User { - status: "ACTIVE".to_owned(), - profile: UserProfile { - email: "test_email".to_owned(), - }, - }; - - let dir_user: DirectoryUser = user.into(); - assert_eq!(dir_user.email, "test_email"); - assert!(dir_user.active); - - let user = User { - status: "INACTIVE".to_owned(), - profile: UserProfile { - email: "test_email".to_owned(), - }, - }; - - let dir_user: DirectoryUser = user.into(); - assert_eq!(dir_user.email, "test_email"); - assert!(!dir_user.active); - } +#[cfg(test)] +pub(crate) fn dirsync_with_mock_server(mock_server: &wiremock::MockServer) -> OktaDirectorySync { + let mut dirsync = + OktaDirectorySync::new(TEST_JWK_PRIVATE_KEY, "test_client_id", &mock_server.uri()); + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(3600)); + dirsync } + +#[cfg(test)] +#[path = "provider_tests/okta.rs"] +mod tests; diff --git a/crates/defguard_core/src/enterprise/directory_sync/provider_tests/google.rs b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/google.rs new file mode 100644 index 0000000000..cd77d602a7 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/google.rs @@ -0,0 +1,191 @@ +use wiremock::{ + Mock, MockServer, + matchers::{method, path, query_param, query_param_is_missing}, +}; + +use super::*; + +#[tokio::test] +async fn test_refresh_access_token() { + let mock_server = MockServer::start().await; + // Real response shape from https://oauth2.googleapis.com/token (jwt-bearer grant). + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(response_from_fixture("token_response.json")) + .mount(&mock_server) + .await; + + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email") + .with_urls(&format!("{}/token", mock_server.uri()), "", ""); + dirsync.service_account_config.private_key = TEST_RSA_PRIVATE_KEY.into(); + + dirsync.refresh_access_token().await.unwrap(); + + assert_eq!( + dirsync.access_token.as_deref(), + Some("ya29.c.b0Aaekm1KfR7fake_opaque_token_value") + ); + assert!(!dirsync.is_token_expired()); +} + +#[tokio::test] +async fn test_get_all_users_paginates() { + let mock_server = MockServer::start().await; + // Real response shape from admin#directory#users (Directory API users.list). + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param_is_missing("pageToken")) + .respond_with(response_from_fixture("users_page1.json")) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param("pageToken", "EAIaBhACGgtGkAAfirstpage")) + .respond_with(response_from_fixture("users_page2.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let users = dirsync.get_all_users().await.unwrap(); + + assert_eq!(users.len(), 2); + assert!(users.iter().any(|u| u.email == "jane.doe@example.com" + && u.active + && u.id.as_deref() == Some("108234567890123456789"))); + assert!(users.iter().any(|u| u.email == "john.smith@example.com" + && !u.active + && u.id.as_deref() == Some("108234567890123456790"))); +} + +#[tokio::test] +async fn test_get_groups() { + let mock_server = MockServer::start().await; + // Real response shape from admin#directory#groups (Directory API groups.list). + Mock::given(method("GET")) + .and(path("/groups")) + .respond_with(response_from_fixture("groups_response.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let groups = dirsync.get_groups().await.unwrap(); + + assert_eq!(groups.len(), 2); + assert!(groups.iter().any(|g| g.name == "Engineering")); + assert!(groups.iter().any(|g| g.name == "Sales")); +} + +#[tokio::test] +async fn test_get_group_members() { + let mock_server = MockServer::start().await; + // Real response shape from admin#directory#members (Directory API members.list). + Mock::given(method("GET")) + .and(path("/groups/01302m9251m2vt3/members")) + .respond_with(response_from_fixture("members_response.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let group = DirectoryGroup { + id: "01302m9251m2vt3".into(), + name: "Engineering".into(), + }; + let members = dirsync.get_group_members(&group, None).await.unwrap(); + + assert_eq!(members, ["jane.doe@example.com".to_string()]); +} + +#[tokio::test] +async fn test_test_connection() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(response_from_fixture("users_empty.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + dirsync.test_connection().await.unwrap(); +} + +#[tokio::test] +async fn test_token() { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); + + // no token + assert!(dirsync.is_token_expired()); + + // expired token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() - TimeDelta::seconds(10000)); + assert!(dirsync.is_token_expired()); + + // valid token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(10000)); + assert!(!dirsync.is_token_expired()); +} + +#[tokio::test] +async fn test_group_members_parse() { + let response = GroupMembersResponse { + members: Some(vec![ + GroupMember { + email: "email@email.com".into(), + status: Some("active".into()), + }, + GroupMember { + email: "email2@email.com".into(), + status: Some("active".into()), + }, + GroupMember { + email: "email3@email.com".into(), + status: Some("suspended".into()), + }, + GroupMember { + email: "email4@email.com".into(), + status: None, + }, + ]), + page_token: None, + }; + + let members: Vec = response.into(); + assert_eq!(members.len(), 3); + assert!(members.contains(&"email@email.com".into())); + assert!(members.contains(&"email2@email.com".into())); + assert!(members.contains(&"email3@email.com".into())); +} + +#[tokio::test] +async fn test_all_users_parse() { + let response = UsersResponse { + users: vec![ + User { + id: "1".into(), + primary_email: "email@email.com".into(), + suspended: false, + }, + User { + id: "2".into(), + primary_email: "email2@email.com".into(), + suspended: true, + }, + User { + id: "3".into(), + primary_email: "email3@email.com".into(), + suspended: false, + }, + ], + page_token: None, + }; + + let users: Vec = response.into(); + assert_eq!(users.len(), 3); + let disabled_user = users + .iter() + .find(|u| u.email == "email2@email.com") + .unwrap(); + assert!(!disabled_user.active); + assert_eq!(disabled_user.id, Some("2".to_owned())); +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/provider_tests/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/jumpcloud.rs new file mode 100644 index 0000000000..7772f01d55 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/jumpcloud.rs @@ -0,0 +1,335 @@ +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path, query_param, query_param_is_missing}, +}; + +use super::*; + +#[test] +fn test_user_to_directory_user_conversions() { + // Test active user (activated=true, account_locked=false, state=ACTIVATED) + let active_user = User { + email: "active@example.com".to_owned(), + activated: true, + account_locked: false, + id: "user123".to_owned(), + state: UserState::Activated, + }; + let active_directory_user: DirectoryUser = active_user.into(); + assert_eq!(active_directory_user.email, "active@example.com"); + assert!(active_directory_user.active); + assert_eq!(active_directory_user.id, Some("user123".to_owned())); + + // Test inactive user (activated=false) + let inactive_user = User { + email: "inactive@example.com".to_owned(), + activated: false, + account_locked: false, + id: "user456".to_owned(), + state: UserState::Activated, + }; + let inactive_directory_user: DirectoryUser = inactive_user.into(); + assert_eq!(inactive_directory_user.email, "inactive@example.com"); + assert!(!inactive_directory_user.active); + assert_eq!(inactive_directory_user.id, Some("user456".to_owned())); + + // Test locked user (account_locked=true) + let locked_user = User { + email: "locked@example.com".to_owned(), + activated: true, + account_locked: true, + id: "user789".to_owned(), + state: UserState::Activated, + }; + let locked_directory_user: DirectoryUser = locked_user.into(); + assert_eq!(locked_directory_user.email, "locked@example.com"); + assert!(!locked_directory_user.active); + assert_eq!(locked_directory_user.id, Some("user789".to_owned())); + + // Test suspended user (state=SUSPENDED) + let suspended_user = User { + email: "suspended@example.com".to_owned(), + activated: true, + account_locked: false, + id: "user999".to_owned(), + state: UserState::Suspended, + }; + let suspended_directory_user: DirectoryUser = suspended_user.into(); + assert_eq!(suspended_directory_user.email, "suspended@example.com"); + assert!(!suspended_directory_user.active); + assert_eq!(suspended_directory_user.id, Some("user999".to_owned())); + + // Test staged user (state=STAGED) + let staged_user = User { + email: "staged@example.com".to_owned(), + activated: true, + account_locked: false, + id: "user888".to_owned(), + state: UserState::Staged, + }; + let staged_directory_user: DirectoryUser = staged_user.into(); + assert_eq!(staged_directory_user.email, "staged@example.com"); + assert!(!staged_directory_user.active); + assert_eq!(staged_directory_user.id, Some("user888".to_owned())); + + // Test both inactive and locked user + let both_user = User { + email: "both@example.com".to_owned(), + activated: false, + account_locked: true, + id: "user000".to_owned(), + state: UserState::Activated, + }; + let both_directory_user: DirectoryUser = both_user.into(); + assert_eq!(both_directory_user.email, "both@example.com"); + assert!(!both_directory_user.active); + assert_eq!(both_directory_user.id, Some("user000".to_owned())); +} + +#[test] +fn test_user_group_to_directory_group_conversions() { + // Test group with LDAP groups (uses first LDAP group name) + let group_with_ldap = UserGroup { + id: "group123".to_owned(), + compiled_attributes: CompiledAttributes { + ldap_groups: vec![ + LdapGroup { + name: "LDAP Group Name".to_owned(), + }, + LdapGroup { + name: "Second LDAP Group".to_owned(), + }, + ], + }, + }; + let directory_group_with_ldap: DirectoryGroup = group_with_ldap.into(); + assert_eq!(directory_group_with_ldap.id, "group123"); + assert_eq!(directory_group_with_ldap.name, "LDAP Group Name"); + + // Test group with empty LDAP groups (falls back to group ID) + let group_empty_ldap = UserGroup { + id: "group789".to_owned(), + compiled_attributes: CompiledAttributes { + ldap_groups: Vec::new(), + }, + }; + let directory_group_empty_ldap: DirectoryGroup = group_empty_ldap.into(); + assert_eq!(directory_group_empty_ldap.id, "group789"); + assert_eq!(directory_group_empty_ldap.name, "group789"); +} + +#[test] +fn test_response_collection_conversions() { + // Test empty UsersResponse conversion + let empty_users_response = UsersResponse { + results: Vec::new(), + total_count: 0, + }; + let empty_directory_users: Vec = empty_users_response.into(); + assert!(empty_directory_users.is_empty()); + + // Test single user UsersResponse conversion + let single_users_response = UsersResponse { + results: vec![User { + email: "single@example.com".to_owned(), + activated: true, + account_locked: false, + id: "single123".to_owned(), + state: UserState::Activated, + }], + total_count: 1, + }; + let single_directory_users: Vec = single_users_response.into(); + assert_eq!(single_directory_users.len(), 1); + assert_eq!(single_directory_users[0].email, "single@example.com"); + assert!(single_directory_users[0].active); + assert_eq!(single_directory_users[0].id, Some("single123".to_owned())); + + // Test multiple users with mixed states + let multiple_users_response = UsersResponse { + results: vec![ + User { + email: "user1@example.com".to_owned(), + activated: true, + account_locked: false, + id: "user1".to_owned(), + state: UserState::Activated, + }, + User { + email: "user2@example.com".to_owned(), + activated: false, + account_locked: false, + id: "user2".to_owned(), + state: UserState::Activated, + }, + User { + email: "user3@example.com".to_owned(), + activated: true, + account_locked: true, + id: "user3".to_owned(), + state: UserState::Activated, + }, + ], + total_count: 3, + }; + let multiple_directory_users: Vec = multiple_users_response.into(); + assert_eq!(multiple_directory_users.len(), 3); + assert_eq!(multiple_directory_users[0].email, "user1@example.com"); + assert!(multiple_directory_users[0].active); + assert_eq!(multiple_directory_users[1].email, "user2@example.com"); + assert!(!multiple_directory_users[1].active); + assert_eq!(multiple_directory_users[2].email, "user3@example.com"); + assert!(!multiple_directory_users[2].active); + + // Test GroupsResponse conversion + let groups_response = GroupsResponse { + results: vec![ + DirectoryGroup { + id: "group1".to_owned(), + name: "Group 1".to_owned(), + }, + DirectoryGroup { + id: "group2".to_owned(), + name: "Group 2".to_owned(), + }, + ], + }; + let directory_groups: Vec = groups_response.into(); + assert_eq!(directory_groups.len(), 2); + assert_eq!(directory_groups[0].id, "group1"); + assert_eq!(directory_groups[0].name, "Group 1"); + assert_eq!(directory_groups[1].id, "group2"); + assert_eq!(directory_groups[1].name, "Group 2"); +} + +#[tokio::test] +async fn test_get_all_users_paginates() { + let mock_server = MockServer::start().await; + let empty_response = ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string(r#"{"results":[],"totalCount":0}"#); + + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .and(query_param_is_missing("skip")) + .respond_with(response_from_fixture("users_page1.json")) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .and(query_param("skip", "100")) + .respond_with(response_from_fixture("users_page2.json")) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .respond_with(empty_response) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let users = dirsync.get_all_users().await.unwrap(); + + assert_eq!(users.len(), 2); + assert!(users.iter().any(|u| u.email == "jane.doe@example.com" + && u.active + && u.id.as_deref() == Some("user123"))); + assert!(users.iter().any(|u| u.email == "john.smith@example.com" + && !u.active + && u.id.as_deref() == Some("user456"))); +} + +#[tokio::test] +async fn test_get_groups() { + let mock_server = MockServer::start().await; + let empty_response = ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string("[]"); + + Mock::given(method("GET")) + .and(path("/api/v2/usergroups")) + .and(query_param_is_missing("skip")) + .respond_with(response_from_fixture("groups_response.json")) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups")) + .respond_with(empty_response) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let groups = dirsync.get_groups().await.unwrap(); + + assert_eq!(groups.len(), 2); + assert!(groups.iter().any(|g| g.name == "Engineering")); + assert!(groups.iter().any(|g| g.name == "Sales")); +} + +#[tokio::test] +async fn test_get_group_members() { + let mock_server = MockServer::start().await; + let empty_response = ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string("[]"); + + Mock::given(method("GET")) + .and(path("/api/v2/usergroups/group1/members")) + .and(query_param_is_missing("skip")) + .respond_with(response_from_fixture("group_members_response.json")) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups/group1/members")) + .respond_with(empty_response) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let group = DirectoryGroup { + id: "group1".into(), + name: "Engineering".into(), + }; + + let all_users = vec![ + DirectoryUser { + email: "jane.doe@example.com".into(), + active: true, + id: Some("user123".into()), + user_details: None, + }, + DirectoryUser { + email: "john.smith@example.com".into(), + active: true, + id: Some("user456".into()), + user_details: None, + }, + ]; + + let members = dirsync + .get_group_members(&group, Some(&all_users)) + .await + .unwrap(); + + assert_eq!(members.len(), 2); + assert!(members.contains(&"jane.doe@example.com".to_string())); + assert!(members.contains(&"john.smith@example.com".to_string())); +} + +#[tokio::test] +async fn test_test_connection() { + use wiremock::{ + Mock, MockServer, + matchers::{method, path}, + }; + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .respond_with(response_from_fixture("users_empty.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + dirsync.test_connection().await.unwrap(); +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/provider_tests/microsoft.rs b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/microsoft.rs new file mode 100644 index 0000000000..72cf102b49 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/microsoft.rs @@ -0,0 +1,264 @@ +use wiremock::{ + Mock, MockServer, + matchers::{method, path, query_param, query_param_is_missing}, +}; + +use super::*; + +#[tokio::test] +async fn test_refresh_access_token() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(response_from_fixture("token_response.json")) + .mount(&mock_server) + .await; + + let mut dirsync = MicrosoftDirectorySync::new( + "client_id".into(), + "client_secret".into(), + "https://login.microsoftonline.com/tenant-123/v2.0".into(), + Vec::new(), + ) + .with_urls(&format!("{}/token", mock_server.uri()), ""); + + dirsync.refresh_access_token().await.unwrap(); + + assert_eq!(dirsync.access_token.as_deref(), Some("EwAoA8l6BAAR...")); + assert!(!dirsync.is_token_expired()); +} + +#[tokio::test] +async fn test_get_all_users_paginates() { + let mock_server = MockServer::start().await; + let mock_uri = mock_server.uri(); + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param_is_missing("$skiptoken")) + .respond_with(response_from_fixture_with_mock_uri( + "users_page1.json", + &mock_uri, + )) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param("$skiptoken", "firstpage")) + .respond_with(response_from_fixture("users_page2.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let users = dirsync.get_all_users().await.unwrap(); + + assert_eq!(users.len(), 2); + assert!(users.iter().any(|u| u.email == "jane.doe@example.com" + && u.active + && u.id.as_deref() == Some("87654321-1234-5678-1234-567812345678"))); + assert!(users.iter().any(|u| u.email == "john.smith@example.com" + && !u.active + && u.id.as_deref() == Some("12345678-1234-5678-1234-567812345679"))); +} + +#[tokio::test] +async fn test_get_groups() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/groups")) + .respond_with(response_from_fixture("groups_response.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let groups = dirsync.get_groups().await.unwrap(); + + assert_eq!(groups.len(), 2); + assert!(groups.iter().any(|g| g.name == "Engineering")); + assert!(groups.iter().any(|g| g.name == "Sales")); +} + +#[tokio::test] +async fn test_get_group_members() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/groups/11111111-1111-1111-1111-111111111111/members")) + .respond_with(response_from_fixture("members_response.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let group = DirectoryGroup { + id: "11111111-1111-1111-1111-111111111111".into(), + name: "Engineering".into(), + }; + let members = dirsync.get_group_members(&group, None).await.unwrap(); + + assert_eq!(members, ["jane.doe@example.com".to_string()]); +} + +#[tokio::test] +async fn test_test_connection() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(response_from_fixture("users_empty.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + dirsync.test_connection().await.unwrap(); +} + +#[test] +fn test_extract_tenant() { + let provider = MicrosoftDirectorySync::new( + "client_id".to_owned(), + "client_secret".to_owned(), + "https://login.microsoftonline.com/tenant-id-123/v2.0".to_owned(), + Vec::new(), + ); + let tenant = provider.extract_tenant().unwrap(); + assert_eq!(tenant, "tenant-id-123"); +} + +#[tokio::test] +async fn test_token() { + let mut dirsync = MicrosoftDirectorySync::new( + "id".to_owned(), + "secret".to_owned(), + "https://login.microsoftonline.com/tenant-id-123/v2.0".to_owned(), + Vec::new(), + ); + + // no token + assert!(dirsync.is_token_expired()); + + // expired token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() - TimeDelta::seconds(10000)); + assert!(dirsync.is_token_expired()); + + // valid token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(10000)); + assert!(!dirsync.is_token_expired()); +} + +#[tokio::test] +async fn test_groups_parse() { + let groups_response = GroupsResponse { + next_page: None, + value: vec![ + GroupDetails { + display_name: Some("Group 1".to_owned()), + id: "1".to_owned(), + }, + GroupDetails { + display_name: Some("Group 2".to_owned()), + id: "2".to_owned(), + }, + ], + }; + + let groups: Vec = groups_response.into(); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].name, "Group 1"); + assert_eq!(groups[0].id, "1"); + assert_eq!(groups[1].name, "Group 2"); + assert_eq!(groups[1].id, "2"); +} + +#[tokio::test] +async fn test_members_parse() { + let members_response = GroupMembersResponse { + next_page: None, + value: vec![ + User { + display_name: "User 1".to_owned(), + mail: Some("email@email.com".to_owned()), + account_enabled: true, + other_mails: Vec::new(), + id: "user1-id".into(), + given_name: Some("User".into()), + surname: Some("One".into()), + mobile_phone: Some("555555555".into()), + business_phones: Vec::new(), + }, + User { + display_name: "User 2".to_owned(), + mail: None, + account_enabled: true, + other_mails: vec!["email2@email.com".to_owned()], + id: "user2-id".into(), + given_name: Some("User".into()), + surname: Some("Two".into()), + mobile_phone: None, + business_phones: Vec::new(), + }, + User { + display_name: "User 3".to_owned(), + mail: None, + account_enabled: true, + other_mails: Vec::new(), + id: "user3-id".into(), + given_name: Some("User".into()), + surname: Some("Three".into()), + mobile_phone: None, + business_phones: Vec::new(), + }, + ], + }; + + let members: Vec = members_response.into(); + assert_eq!(members.len(), 2); + assert_eq!(members[0], "email@email.com".to_owned()); + assert_eq!(members[1], "email2@email.com".to_owned()); +} + +#[tokio::test] +async fn test_users_parse() { + let users_response = UsersResponse { + next_page: None, + value: vec![ + User { + display_name: "User 1".to_owned(), + mail: Some("email@email.com".to_owned()), + account_enabled: true, + other_mails: Vec::new(), + id: "user1-id".into(), + given_name: Some("User".into()), + surname: None, + mobile_phone: None, + business_phones: Vec::new(), + }, + User { + display_name: "User 2".to_owned(), + mail: None, + account_enabled: true, + other_mails: vec!["email2@email.com".to_owned()], + id: "user2-id".into(), + given_name: None, + surname: None, + mobile_phone: Some("555555555".into()), + business_phones: Vec::new(), + }, + User { + display_name: "User 3".to_owned(), + mail: None, + account_enabled: true, + other_mails: Vec::new(), + id: "user3-id".into(), + given_name: Some("User".into()), + surname: Some("Three".into()), + mobile_phone: Some("555555555".into()), + business_phones: Vec::new(), + }, + ], + }; + + let users: Vec = users_response.into(); + assert_eq!(users.len(), 2); + assert_eq!(users[0].email, "email@email.com".to_owned()); + assert_eq!(users[1].email, "email2@email.com".to_owned()); +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/provider_tests/okta.rs b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/okta.rs new file mode 100644 index 0000000000..6448d0b7b7 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/provider_tests/okta.rs @@ -0,0 +1,198 @@ +use wiremock::{ + Mock, MockServer, + matchers::{method, path, query_param, query_param_is_missing}, +}; + +use super::*; + +#[tokio::test] +async fn test_refresh_access_token() { + let mock_server = MockServer::start().await; + // Real response shape from Okta OAuth 2.0 token endpoint. + Mock::given(method("POST")) + .and(path("/oauth2/v1/token")) + .respond_with(response_from_fixture("token_response.json")) + .mount(&mock_server) + .await; + + let mut dirsync = + OktaDirectorySync::new(TEST_JWK_PRIVATE_KEY, "test_client_id", &mock_server.uri()); + + dirsync.refresh_access_token().await.unwrap(); + + assert_eq!( + dirsync.access_token.as_deref(), + Some( + "eyJhbGciOiJSUzI1NiIsImtpZCI6IlRlc3RAMjAyNCJ9.eyJpc3MiOiJodHRwczovL3RyaWFsLW9rdGEuY29tIiwiYXV0IjoiaW50ZXJuYWwiLCJhdWQiOiJodHRwczovL3RyaWFsLW9rdGEuY29tL2FwaS92MS91c2VycyIsInN1YiI6ImM0MDFuMjYwbnNvZkpiMDBk" + ) + ); + assert!(!dirsync.is_token_expired()); +} + +#[tokio::test] +async fn test_get_all_users_paginates() { + let mock_server = MockServer::start().await; + let server_uri = mock_server.uri(); + // Real response shape from Okta users.list; Link header uses RFC 5988 format + // with `rel="next"` pointing to the next page URL. + let link_header = format!("<{server_uri}/api/v1/users?limit=200&after=page1>; rel=\"next\""); + Mock::given(method("GET")) + .and(path("/api/v1/users")) + .and(query_param_is_missing("after")) + .respond_with(response_from_fixture("users_page1.json").append_header("link", link_header)) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/users")) + .and(query_param("after", "page1")) + .respond_with(response_from_fixture("users_page2.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let users = dirsync.get_all_users().await.unwrap(); + + assert_eq!(users.len(), 2); + assert!(users.iter().any(|u| u.email == "jane.doe@example.com" + && u.active + && u.id.as_deref() == Some("00u4s64d9MYvzKmLW0g7"))); + assert!(users.iter().any(|u| u.email == "john.smith@example.com" + && !u.active + && u.id.as_deref() == Some("00u5s74e0NZvzKmMX1h8"))); +} + +#[tokio::test] +async fn test_get_groups() { + let mock_server = MockServer::start().await; + // Real response shape from Okta groups.list. + Mock::given(method("GET")) + .and(path("/api/v1/groups")) + .respond_with(response_from_fixture("groups_response.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let groups = dirsync.get_groups().await.unwrap(); + + assert_eq!(groups.len(), 2); + assert!(groups.iter().any(|g| g.name == "Engineering")); + assert!(groups.iter().any(|g| g.name == "Sales")); +} + +#[tokio::test] +async fn test_get_group_members() { + let mock_server = MockServer::start().await; + // Real response shape from Okta groups/{groupId}/users. + Mock::given(method("GET")) + .and(path("/api/v1/groups/00gjitxyt9yJW2FKR0g7/users")) + .respond_with(response_from_fixture("group_members_response.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + let group = DirectoryGroup { + id: "00gjitxyt9yJW2FKR0g7".into(), + name: "Engineering".into(), + }; + let members = dirsync.get_group_members(&group, None).await.unwrap(); + + assert_eq!(members, ["jane.doe@example.com".to_string()]); +} + +#[tokio::test] +async fn test_test_connection() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/users")) + .respond_with(response_from_fixture("users_page1.json")) + .mount(&mock_server) + .await; + + let dirsync = dirsync_with_mock_server(&mock_server); + dirsync.test_connection().await.unwrap(); +} + +#[tokio::test] +async fn test_token() { + let mut dirsync = + OktaDirectorySync::new("private_key", "client_id", "https://trial-0000000.okta.com"); + + // no token + assert!(dirsync.is_token_expired()); + + // expired token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() - TimeDelta::seconds(10000)); + assert!(dirsync.is_token_expired()); + + // valid token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(Utc::now() + TimeDelta::seconds(10000)); + assert!(!dirsync.is_token_expired()); +} + +#[tokio::test] +async fn test_header() { + let link_header = + "; rel=\"next\"".to_owned(); + let next_link = extract_next_link(Some(&link_header)).unwrap(); + assert_eq!( + next_link, + Some("https://trial-0000000.okta.com/api/v1/users?after=4&limit=200".to_owned()) + ); + + let next_link = extract_next_link(None).unwrap(); + assert_eq!(next_link, None); + + let link_header = "invalid".to_owned(); + let next_link = extract_next_link(Some(&link_header)); + assert!(next_link.is_err()); + + let link_header = "; rel=\"next\", ; rel=\"prev\"".to_owned(); + let next_link = extract_next_link(Some(&link_header)).unwrap(); + assert_eq!( + next_link, + Some("https://trial-0000000.okta.com/api/v1/users?after=4&limit=200".to_owned()) + ); +} + +#[tokio::test] +async fn test_group_parse() { + let group = Group { + id: "test_id".to_owned(), + profile: GroupProfile { + name: "test_name".to_owned(), + }, + }; + let dir_group: DirectoryGroup = group.into(); + assert_eq!(dir_group.id, "test_id"); + assert_eq!(dir_group.name, "test_name"); +} + +#[tokio::test] +async fn test_user_parse() { + let user = User { + id: "test_id".to_owned(), + status: "ACTIVE".to_owned(), + profile: UserProfile { + email: "test_email".to_owned(), + }, + }; + + let dir_user: DirectoryUser = user.into(); + assert_eq!(dir_user.email, "test_email"); + assert_eq!(dir_user.id, Some("test_id".to_owned())); + assert!(dir_user.active); + + let user = User { + id: "test_id".to_owned(), + status: "INACTIVE".to_owned(), + profile: UserProfile { + email: "test_email".to_owned(), + }, + }; + + let dir_user: DirectoryUser = user.into(); + assert_eq!(dir_user.email, "test_email"); + assert!(!dir_user.active); +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index c882659ac2..4f7683b050 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -21,7 +21,10 @@ mod test { use crate::{ device_access::join_device_to_all_networks, enterprise::{ - db::models::openid_provider::{DirectorySyncTarget, OpenIdProviderKind}, + db::models::{ + openid_provider::{DirectorySyncTarget, OpenIdProvider, OpenIdProviderKind}, + user_directory_identity::UserDirectoryIdentity, + }, license::{License, LicenseTier, SupportType, set_cached_license}, limits::{get_counts, update_counts}, }, @@ -647,6 +650,38 @@ mod test { assert_eq!(user_groups[0].id, group.id); } + // Logging in through OIDC used to sync the user's groups regardless of the configured sync + // target, overwriting locally managed group assignments when the target was set to users only. + #[sqlx::test] + async fn test_sync_user_groups_target_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Users, + false, + ) + .await; + let user = make_test_user_and_device("testuser", &pool).await; + let local_group = Group::new("localgroup").save(&pool).await.unwrap(); + user.add_to_group(&pool, &local_group).await.unwrap(); + let (ldap_tx, _ldap_rx) = mpsc::unbounded_channel::(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + + sync_user_groups_if_configured(&user, &pool, &gateway_tx, &ldap_tx, &dirsync_tx) + .await + .unwrap(); + + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert_eq!(user_groups[0].id, local_group.id); + } + #[sqlx::test] async fn test_sync_target_users(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1031,6 +1066,292 @@ mod test { assert!(gateway_rx.try_recv().is_err()); } + // Regression test for a bug where changing a user's email address in the directory (e.g. + // Entra ID) caused directory sync to try to create a new Defguard user for them. + #[sqlx::test] + async fn test_users_prefetch_email_changed_in_directory( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, _gateway_rx) = broadcast::channel::(16); + + // enable prefetching users + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + true, + ) + .await; + + let directory_user = DirectoryUser { + email: "alice@email.com".into(), + active: true, + id: Some("entra-alice-id".into()), + user_details: Some(DirectoryUserDetails { + last_name: "Doe".into(), + first_name: "Alice".into(), + phone_number: None, + }), + }; + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &dirsync_tx, + &[directory_user], + None, + ) + .await + .unwrap(); + + // user was imported with a username derived from their original email + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 1); + assert_eq!(defguard_users[0].username, "alice"); + assert_eq!(defguard_users[0].email, "alice@email.com"); + let original_user_id = defguard_users[0].id; + + // the same directory user (matched by directory id) changes their email in the + // directory, e.g. via a name change in Entra ID + let directory_user_new_email = DirectoryUser { + email: "alice@newteam.com".into(), + active: true, + id: Some("entra-alice-id".into()), + user_details: Some(DirectoryUserDetails { + last_name: "Doe".into(), + first_name: "Alice".into(), + phone_number: None, + }), + }; + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &dirsync_tx, + &[directory_user_new_email], + None, + ) + .await + .unwrap(); + + // the existing user was updated in place instead of a duplicate being created + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 1); + assert_eq!(defguard_users[0].username, "alice"); + assert_eq!(defguard_users[0].email, "alice@newteam.com"); + assert_eq!(defguard_users[0].id, original_user_id); + } + + // Regression test for the case where a user was never created through directory sync's + // prefetch (e.g. they were invited manually, or existed before prefetch was enabled) and so + // has no directory identity mapping stored. Directory sync should backfill it the first time + // it matches such a user by email, so that a later email change in the directory can still be + // matched by directory ID. + #[sqlx::test] + async fn test_users_prefetch_backfills_directory_identity_for_preexisting_user( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, _gateway_rx) = broadcast::channel::(16); + + // enable prefetching users + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + true, + ) + .await; + + // user already exists in Defguard, created some other way (e.g. manual invite) + let _user = User::new("alice", None, "Doe", "Alice", "alice@email.com", None) + .save(&pool) + .await + .unwrap(); + + let directory_user = DirectoryUser { + email: "alice@email.com".into(), + active: true, + id: Some("entra-alice-id".into()), + user_details: Some(DirectoryUserDetails { + last_name: "Doe".into(), + first_name: "Alice".into(), + phone_number: None, + }), + }; + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &dirsync_tx, + &[directory_user], + None, + ) + .await + .unwrap(); + + // no duplicate was created, and the directory ID was backfilled into the mapping table + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 1); + let provider = OpenIdProvider::get_current(&pool).await.unwrap().unwrap(); + let identity = UserDirectoryIdentity::find_by_user_and_provider( + &pool, + defguard_users[0].id, + provider.id, + ) + .await + .unwrap(); + assert_eq!( + identity.map(|i| i.external_id), + Some("entra-alice-id".to_string()) + ); + + // the user changes their email in the directory + let directory_user_new_email = DirectoryUser { + email: "alice@newteam.com".into(), + active: true, + id: Some("entra-alice-id".into()), + user_details: Some(DirectoryUserDetails { + last_name: "Doe".into(), + first_name: "Alice".into(), + phone_number: None, + }), + }; + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &dirsync_tx, + &[directory_user_new_email], + None, + ) + .await + .unwrap(); + + // the existing user was updated in place instead of a duplicate being created + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 1); + assert_eq!(defguard_users[0].username, "alice"); + assert_eq!(defguard_users[0].email, "alice@newteam.com"); + assert_eq!(defguard_users[0].id, _user.id); + } + + // Regression test: if a directory user's new email already belongs to a different Defguard + // user, updating in place would hit the email UNIQUE constraint. Directory sync should skip + // that user instead of aborting the whole sync. + #[sqlx::test] + async fn test_users_prefetch_email_changed_to_existing_user_email( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, _gateway_rx) = broadcast::channel::(16); + + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + true, + ) + .await; + + let directory_user = DirectoryUser { + email: "alice@email.com".into(), + active: true, + id: Some("entra-alice-id".into()), + user_details: Some(DirectoryUserDetails { + last_name: "Doe".into(), + first_name: "Alice".into(), + phone_number: None, + }), + }; + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &dirsync_tx, + &[directory_user], + None, + ) + .await + .unwrap(); + + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 1); + let alice_id = defguard_users[0].id; + + // a different Defguard user already occupies the email alice is about to change to, + // e.g. someone who was invited manually + let _bob = User::new("bob", None, "Smith", "Bob", "bob@newteam.com", None) + .save(&pool) + .await + .unwrap(); + + // alice changes her email (matched by directory id) to bob's email + let directory_user_new_email = DirectoryUser { + email: "bob@newteam.com".into(), + active: true, + id: Some("entra-alice-id".into()), + user_details: Some(DirectoryUserDetails { + last_name: "Doe".into(), + first_name: "Alice".into(), + phone_number: None, + }), + }; + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let (dirsync_tx, _dirsync_rx) = dirsync_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &dirsync_tx, + &[directory_user_new_email], + None, + ) + .await + .unwrap(); + + // sync succeeded as a whole; alice was skipped and left unchanged instead of the + // email update failing on the unique constraint + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 2); + let alice = defguard_users.iter().find(|u| u.id == alice_id).unwrap(); + assert_eq!(alice.email, "alice@email.com"); + let bob = defguard_users.iter().find(|u| u.username == "bob").unwrap(); + assert_eq!(bob.email, "bob@newteam.com"); + } + #[sqlx::test] async fn test_user_in_directory_groups(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1111,4 +1432,48 @@ mod test { // No events assert!(gateway_rx.try_recv().is_err()); } + + // directory_sync_user_groups must be honored for every provider and + // regardless of the prefetch setting. + #[sqlx::test] + async fn test_users_group_filter_applies_without_prefetch( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, _gateway_rx) = broadcast::channel::(16); + + // prefetch disabled, restrict sync to a group that has no members in the directory + let mut provider = make_test_provider( + &pool, + DirectorySyncUserBehavior::Disable, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::Users, + false, + ) + .await; + provider.directory_sync_user_groups = Some(vec!["nonexistent-group".to_owned()]); + provider.save(&pool).await.unwrap(); + + // users already present in Defguard, matching directory users which are normally active + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuser2", &pool).await; + + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); + assert!(testuser.is_active); + assert!(testuser2.is_active); + + do_test_directory_sync(&pool, &gateway_tx).await; + + // both users were excluded from the group filter, so they are treated as no longer + // present in the directory and get disabled, even though prefetch was never enabled + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); + assert!(!testuser.is_active); + assert!(!testuser2.is_active); + } } diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests_cross_provider.rs b/crates/defguard_core/src/enterprise/directory_sync/tests_cross_provider.rs new file mode 100644 index 0000000000..8e53784e02 --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/tests_cross_provider.rs @@ -0,0 +1,349 @@ +//! Drives every directory sync provider (Google, Okta, JumpCloud, Microsoft) through the +//! shared `DirectorySync` trait interface, each backed by its own wiremock `MockServer` and +//! real-response fixtures. Verifies that the common dispatch (`DirectorySyncClient`) works +//! consistently across providers. + +use std::collections::HashSet; + +use wiremock::{ + Mock, MockServer, + matchers::{method, path, query_param, query_param_is_missing}, +}; + +use super::{ + DirectoryGroup, DirectorySync, DirectorySyncClient, google, jumpcloud, microsoft, okta, +}; + +/// Drives a provider client through the full shared `DirectorySync` interface and asserts on +/// the "Engineering" group and its "jane.doe@example.com" member, which every provider's +/// fixtures agree on. +async fn assert_provider_dirsync( + name: &str, + mut client: DirectorySyncClient, + group_id: &str, + all_users_helper: Option<&[super::DirectoryUser]>, +) { + client + .prepare() + .await + .unwrap_or_else(|err| panic!("{name}: prepare() failed: {err}")); + + client + .test_connection() + .await + .unwrap_or_else(|err| panic!("{name}: test_connection() failed: {err}")); + + let groups = client + .get_groups() + .await + .unwrap_or_else(|err| panic!("{name}: get_groups() failed: {err}")); + assert!( + groups.iter().any(|g| g.name == "Engineering"), + "{name}: expected an 'Engineering' group, got {groups:?}" + ); + + let group = DirectoryGroup { + id: group_id.to_owned(), + name: "Engineering".to_owned(), + }; + let members = client + .get_group_members(&group, all_users_helper) + .await + .unwrap_or_else(|err| panic!("{name}: get_group_members() failed: {err}")); + assert!( + members.contains(&"jane.doe@example.com".to_string()), + "{name}: expected jane.doe@example.com among group members, got {members:?}" + ); +} + +#[tokio::test] +async fn test_all_providers() { + // Google + let google_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/groups")) + .respond_with(google::response_from_fixture("groups_response.json")) + .mount(&google_server) + .await; + Mock::given(method("GET")) + .and(path("/groups/01302m9251m2vt3/members")) + .respond_with(google::response_from_fixture("members_response.json")) + .mount(&google_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(google::response_from_fixture("users_empty.json")) + .mount(&google_server) + .await; + let google_client = + DirectorySyncClient::Google(google::dirsync_with_mock_server(&google_server)); + assert_provider_dirsync("Google", google_client, "01302m9251m2vt3", None).await; + + // Okta + let okta_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/groups")) + .respond_with(okta::response_from_fixture("groups_response.json")) + .mount(&okta_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/groups/00gjitxyt9yJW2FKR0g7/users")) + .respond_with(okta::response_from_fixture("group_members_response.json")) + .mount(&okta_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/users")) + .respond_with(okta::response_from_fixture("users_page1.json")) + .mount(&okta_server) + .await; + let okta_client = DirectorySyncClient::Okta(okta::dirsync_with_mock_server(&okta_server)); + assert_provider_dirsync("Okta", okta_client, "00gjitxyt9yJW2FKR0g7", None).await; + + // Microsoft + let microsoft_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/groups")) + .respond_with(microsoft::response_from_fixture("groups_response.json")) + .mount(µsoft_server) + .await; + Mock::given(method("GET")) + .and(path("/groups/11111111-1111-1111-1111-111111111111/members")) + .respond_with(microsoft::response_from_fixture("members_response.json")) + .mount(µsoft_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with(microsoft::response_from_fixture("users_empty.json")) + .mount(µsoft_server) + .await; + let microsoft_client = + DirectorySyncClient::Microsoft(microsoft::dirsync_with_mock_server(µsoft_server)); + assert_provider_dirsync( + "Microsoft", + microsoft_client, + "11111111-1111-1111-1111-111111111111", + None, + ) + .await; + + // JumpCloud (needs the all_users helper to map member ids to emails) + let jumpcloud_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups")) + .respond_with(jumpcloud::response_from_fixture("groups_response.json")) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups/group1/members")) + .respond_with(jumpcloud::response_from_fixture( + "group_members_response.json", + )) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .respond_with(jumpcloud::response_from_fixture("users_empty.json")) + .mount(&jumpcloud_server) + .await; + let all_users = vec![ + super::DirectoryUser { + id: Some("user123".into()), + email: "jane.doe@example.com".into(), + active: true, + user_details: None, + }, + super::DirectoryUser { + id: Some("user456".into()), + email: "john.smith@example.com".into(), + active: true, + user_details: None, + }, + ]; + let jumpcloud_client = + DirectorySyncClient::JumpCloud(jumpcloud::dirsync_with_mock_server(&jumpcloud_server)); + assert_provider_dirsync("JumpCloud", jumpcloud_client, "group1", Some(&all_users)).await; +} + +/// Replicates the `directory_sync_user_groups` filter from [`super::do_directory_sync`]: +/// resolve the configured group names to their members and return the set of emails +/// allowed to sync. +async fn allowed_emails_for_group_filter( + client: &DirectorySyncClient, + user_groups_filter: &[String], +) -> HashSet { + let all_users = client.get_all_users().await.unwrap(); + let groups = client.get_groups().await.unwrap(); + let mut emails = HashSet::new(); + for group in groups + .iter() + .filter(|group| user_groups_filter.contains(&group.name)) + { + let members = client + .get_group_members(group, Some(&all_users)) + .await + .unwrap(); + emails.extend(members); + } + emails +} + +/// Every provider's fixtures agree on two directory users: "jane.doe@example.com", a member +/// of the "Engineering" group, and "john.smith@example.com", who isn't. Verifies that limiting +/// sync to "Engineering" allows jane.doe and excludes john.smith for all four providers. +#[tokio::test] +async fn test_group_limiting_all_providers() { + let engineering = ["Engineering".to_owned()]; + + // Google + let google_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/groups")) + .respond_with(google::response_from_fixture("groups_response.json")) + .mount(&google_server) + .await; + Mock::given(method("GET")) + .and(path("/groups/01302m9251m2vt3/members")) + .respond_with(google::response_from_fixture("members_response.json")) + .mount(&google_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param_is_missing("pageToken")) + .respond_with(google::response_from_fixture("users_page1.json")) + .mount(&google_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param("pageToken", "EAIaBhACGgtGkAAfirstpage")) + .respond_with(google::response_from_fixture("users_page2.json")) + .mount(&google_server) + .await; + let google_client = + DirectorySyncClient::Google(google::dirsync_with_mock_server(&google_server)); + let allowed = allowed_emails_for_group_filter(&google_client, &engineering).await; + assert_eq!( + allowed, + HashSet::from(["jane.doe@example.com".to_owned()]), + "Google: expected only jane.doe to be allowed by the Engineering group filter" + ); + + // Okta + let okta_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/groups")) + .respond_with(okta::response_from_fixture("groups_response.json")) + .mount(&okta_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/groups/00gjitxyt9yJW2FKR0g7/users")) + .respond_with(okta::response_from_fixture("group_members_response.json")) + .mount(&okta_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/users")) + .and(query_param_is_missing("after")) + .respond_with(okta::response_from_fixture("users_page1.json")) + .mount(&okta_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/users")) + .and(query_param("after", "page1")) + .respond_with(okta::response_from_fixture("users_page2.json")) + .mount(&okta_server) + .await; + let okta_client = DirectorySyncClient::Okta(okta::dirsync_with_mock_server(&okta_server)); + let allowed = allowed_emails_for_group_filter(&okta_client, &engineering).await; + assert_eq!( + allowed, + HashSet::from(["jane.doe@example.com".to_owned()]), + "Okta: expected only jane.doe to be allowed by the Engineering group filter" + ); + + // Microsoft + let microsoft_server = MockServer::start().await; + let microsoft_uri = microsoft_server.uri(); + Mock::given(method("GET")) + .and(path("/groups")) + .respond_with(microsoft::response_from_fixture("groups_response.json")) + .mount(µsoft_server) + .await; + Mock::given(method("GET")) + .and(path("/groups/11111111-1111-1111-1111-111111111111/members")) + .respond_with(microsoft::response_from_fixture("members_response.json")) + .mount(µsoft_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param_is_missing("$skiptoken")) + .respond_with(microsoft::response_from_fixture_with_mock_uri( + "users_page1.json", + µsoft_uri, + )) + .mount(µsoft_server) + .await; + Mock::given(method("GET")) + .and(path("/users")) + .and(query_param("$skiptoken", "firstpage")) + .respond_with(microsoft::response_from_fixture("users_page2.json")) + .mount(µsoft_server) + .await; + let microsoft_client = + DirectorySyncClient::Microsoft(microsoft::dirsync_with_mock_server(µsoft_server)); + let allowed = allowed_emails_for_group_filter(µsoft_client, &engineering).await; + assert_eq!( + allowed, + HashSet::from(["jane.doe@example.com".to_owned()]), + "Microsoft: expected only jane.doe to be allowed by the Engineering group filter" + ); + + // JumpCloud + let jumpcloud_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups")) + .respond_with(jumpcloud::response_from_fixture("groups_response.json")) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups/group1/members")) + .and(query_param_is_missing("skip")) + .respond_with(jumpcloud::response_from_fixture( + "group_members_response.json", + )) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/v2/usergroups/group1/members")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string("[]"), + ) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .and(query_param_is_missing("skip")) + .respond_with(jumpcloud::response_from_fixture("users_page1.json")) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .and(query_param("skip", "100")) + .respond_with(jumpcloud::response_from_fixture("users_page2.json")) + .mount(&jumpcloud_server) + .await; + Mock::given(method("GET")) + .and(path("/api/systemusers")) + .respond_with(jumpcloud::response_from_fixture("users_empty.json")) + .mount(&jumpcloud_server) + .await; + let jumpcloud_client = + DirectorySyncClient::JumpCloud(jumpcloud::dirsync_with_mock_server(&jumpcloud_server)); + let allowed = allowed_emails_for_group_filter(&jumpcloud_client, &engineering).await; + assert_eq!( + allowed, + HashSet::from(["jane.doe@example.com".to_owned()]), + "JumpCloud: expected only jane.doe to be allowed by the Engineering group filter \ + (john.smith is a group member but inactive, so get_group_members excludes him)" + ); +} diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index 27343f12d3..330ec2c431 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -19,14 +19,13 @@ use crate::{ enterprise::db::models::acl::{AclRule, AclRuleInfo, Protocol, RuleState}, error::WebError, handlers::{ - ApiResponse, ApiResult, + ApiErrorResponse, ApiResponse, ApiResult, pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, }, }; -/// API representation of [`AclRule`] used in API responses. -/// All relations represented as arrays of IDs. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +/// An ACL rule. All relations represented as arrays of IDs. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, ToSchema)] pub struct ApiAclRule { pub id: Id, pub parent_id: Option, @@ -102,7 +101,7 @@ impl From> for ApiAclRule { } } -/// API representation of [`AclRule`] used in API requests for modification operations +/// An ACL rule, as accepted when creating or updating one. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, ToSchema)] pub struct EditAclRule { pub name: String, @@ -252,14 +251,25 @@ pub struct AclStateCount { pub pending: i64, } -/// List all ACL rules. +/// List ACL rules #[utoipa::path( get, path = "/api/v1/acl/rule", tag = "ACL", + params( + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ), responses( - (status = OK, description = "ACL rules"), + (status = 200, description = "Paginated list of ACL rules.", body = PaginatedApiResponse), + (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 = 500, description = "Unable to list ACL rules.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub(crate) async fn list_acl_rules( _admin: AdminRole, @@ -298,14 +308,21 @@ pub(crate) async fn list_acl_rules( )) } -/// Count ACL rules by state. +/// Count ACL rules by state #[utoipa::path( get, path = "/api/v1/acl/rule/count", tag = "ACL", responses( - (status = OK, description = "ACL rule state counts", body = AclStateCount), + (status = 200, description = "Number of ACL rules in each state.", body = AclStateCount), + (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 = 500, description = "Unable to count ACL rules.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub(crate) async fn count_acl_rules( _admin: AdminRole, @@ -325,16 +342,24 @@ pub(crate) async fn count_acl_rules( Ok(ApiResponse::json(counts, StatusCode::OK)) } -/// Get ACL rule. +/// Get an ACL rule #[utoipa::path( get, path = "/api/v1/acl/rule/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL rule",) + ("id" = i64, Path, description = "ID of the ACL rule.",) ), responses( - (status = OK, description = "ACL rule"), + (status = 200, description = "ACL rule details.", body = ApiAclRule), + (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 = "ACL rule not found."), + (status = 500, description = "Unable to get ACL rule.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn get_acl_rule( @@ -363,14 +388,23 @@ pub(crate) async fn get_acl_rule( Ok(ApiResponse::new(rule, status)) } -/// Create ACL rule. +/// Create an ACL rule #[utoipa::path( post, path = "/api/v1/acl/rule", tag = "ACL", - request_body = EditAclRule, + request_body(content = EditAclRule, description = "The rule starts working after `PUT /api/v1/acl/rule/apply`.", example = json!({"name": "allow-web", "all_locations": false, "locations": [1], "enabled": true, "allow_all_users": false, "deny_all_users": false, "allow_all_groups": false, "deny_all_groups": false, "allow_all_network_devices": false, "deny_all_network_devices": false, "allowed_users": [1], "denied_users": [], "allowed_groups": [], "denied_groups": [], "allowed_network_devices": [], "denied_network_devices": [], "use_manual_destination_settings": true, "addresses": "10.0.0.0/24", "ports": "80, 443", "protocols": [6], "any_address": false, "any_port": false, "any_protocol": false, "aliases": [], "destinations": [], "expires": null})), responses( - (status = OK, description = "ACL rule"), + (status = 201, description = "ACL rule created.", body = ApiAclRule), + (status = 400, description = "Cannot use a modified alias in an ACL rule.", body = ApiErrorResponse, example = json!({"msg": "Cannot use modified alias in ACL rule [1]"})), + (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 = 422, description = "Invalid addresses, ports or protocols.", body = ApiErrorResponse, example = json!({"msg": "Unprocessable entity"})), + (status = 500, description = "Unable to create ACL rule.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn create_acl_rule( @@ -398,17 +432,27 @@ pub(crate) async fn create_acl_rule( Ok(ApiResponse::json(rule, StatusCode::CREATED)) } -/// Update ACL rule. +/// Update an ACL rule #[utoipa::path( put, path = "/api/v1/acl/rule/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL rule",) + ("id" = i64, Path, description = "ID of the ACL rule.",) ), request_body = EditAclRule, responses( - (status = OK, description = "ACL rule"), + (status = 200, description = "ACL rule updated.", body = ApiAclRule), + (status = 400, description = "Cannot modify a deleted ACL rule.", body = ApiErrorResponse, example = json!({"msg": "Cannot modify deleted ACL rule 1"})), + (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 = "ACL rule not found.", body = ApiErrorResponse, example = json!({"msg": "Rule 1 not found"})), + (status = 422, description = "Invalid addresses, ports or protocols.", body = ApiErrorResponse, example = json!({"msg": "Unprocessable entity"})), + (status = 500, description = "Unable to update ACL rule.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn update_acl_rule( @@ -434,16 +478,24 @@ pub(crate) async fn update_acl_rule( Ok(ApiResponse::json(rule, StatusCode::OK)) } -/// Delete ACL rule. +/// Delete an ACL rule #[utoipa::path( delete, path = "/api/v1/acl/rule/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL rule",) + ("id" = i64, Path, description = "ID of the ACL rule.",) ), responses( - (status = OK, description = "ACL rule"), + (status = 200, description = "ACL rule deleted."), + (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 = "ACL rule not found.", body = ApiErrorResponse, example = json!({"msg": "Rule 1 not found"})), + (status = 500, description = "Unable to delete ACL rule.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn delete_acl_rule( @@ -464,13 +516,23 @@ pub(crate) async fn delete_acl_rule( Ok(ApiResponse::default()) } -/// Apply ACL alias. +/// Apply ACL rules #[utoipa::path( put, path = "/api/v1/acl/rule/apply", + tag = "ACL", request_body = ApplyAclRulesData, responses( - (status = OK, description = "ACL alias"), + (status = 200, description = "Pending rule changes applied."), + (status = 400, description = "ACL rule is already applied.", body = ApiErrorResponse, example = json!({"msg": "Rule 1 already applied"})), + (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 = "ACL rule not found.", body = ApiErrorResponse, example = json!({"msg": "Rule 1 not found"})), + (status = 500, description = "Unable to apply ACL rules.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn apply_acl_rules( diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index 62d784cde8..15d278cf2d 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -17,10 +17,10 @@ use crate::{ Protocol, acl_delete_related_objects, parse_destination_addresses, }, error::WebError, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; -/// API representation of [`AclAlias`] used in API requests for modification operations. +/// An ACL alias, as accepted when creating or updating one. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, ToSchema)] pub struct EditAclAlias { pub name: String, @@ -66,7 +66,7 @@ impl EditAclAlias { } } -/// API representation of [`AclAlias`] for "Alias Component" (not "Destination"). +/// An ACL alias component. /// All relations represented as arrays of IDs. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)] pub struct ApiAclAlias { @@ -199,14 +199,21 @@ impl From for ApiAclAlias { } } -/// List all ACL aliases. +/// List ACL aliases #[utoipa::path( get, path = "/api/v1/acl/alias", tag = "ACL", responses( - (status = OK, description = "ACL alias", body = [ApiAclAlias]), + (status = 200, description = "All ACL aliases.", body = [ApiAclAlias]), + (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 = 500, description = "Unable to list ACL aliases.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub(crate) async fn list_acl_aliases( _admin: AdminRole, @@ -228,14 +235,21 @@ pub(crate) async fn list_acl_aliases( Ok(ApiResponse::json(api_aliases, StatusCode::OK)) } -/// Count ACL aliases by state. +/// Count ACL aliases by state #[utoipa::path( get, path = "/api/v1/acl/alias/count", tag = "ACL", responses( - (status = OK, description = "ACL alias state counts", body = AclStateCount), + (status = 200, description = "Number of ACL aliases in each state.", body = AclStateCount), + (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 = 500, description = "Unable to count ACL aliases.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub(crate) async fn count_acl_aliases( _admin: AdminRole, @@ -255,16 +269,24 @@ pub(crate) async fn count_acl_aliases( Ok(ApiResponse::json(counts, StatusCode::OK)) } -/// Get ACL alias. +/// Get an ACL alias #[utoipa::path( get, path = "/api/v1/acl/alias/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL alias",) + ("id" = i64, Path, description = "ID of the ACL alias.",) ), responses( - (status = OK, description = "ACL alias", body = ApiAclAlias), + (status = 200, description = "ACL alias details.", body = ApiAclAlias), + (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 = "ACL alias not found."), + (status = 500, description = "Unable to get ACL alias.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn get_acl_alias( @@ -293,14 +315,23 @@ pub(crate) async fn get_acl_alias( Ok(ApiResponse::new(alias, status)) } -/// Create ACL alias. +/// Create an ACL alias #[utoipa::path( post, path = "/api/v1/acl/alias", tag = "ACL", - request_body = EditAclAlias, + request_body(content = EditAclAlias, description = "`protocols` are IP protocol numbers, for example 6 for TCP and 17 for UDP.", example = json!({"name": "web-ports", "addresses": "10.0.0.0/24", "ports": "80, 443", "protocols": [6]})), responses( - (status = CREATED, description = "ACL alias", body = ApiAclAlias), + (status = 201, description = "ACL alias created.", body = ApiAclAlias), + (status = 400, description = "Alias addresses, ports or protocols are missing.", body = ApiErrorResponse, example = json!({"msg": "Must provide alias addresses, ports, or protocols"})), + (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 = 422, description = "Invalid addresses, ports or protocols.", body = ApiErrorResponse, example = json!({"msg": "Unprocessable entity"})), + (status = 500, description = "Unable to create ACL alias.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn create_acl_alias( @@ -325,17 +356,27 @@ pub(crate) async fn create_acl_alias( Ok(ApiResponse::json(alias, StatusCode::CREATED)) } -/// Update ACL alias. +/// Update an ACL alias #[utoipa::path( put, path = "/api/v1/acl/alias/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL alias",) + ("id" = i64, Path, description = "ID of the ACL alias.",) ), request_body = EditAclAlias, responses( - (status = OK, description = "ACL alias", body = ApiAclAlias), + (status = 200, description = "ACL alias updated.", body = ApiAclAlias), + (status = 400, description = "Alias addresses, ports or protocols are missing.", body = ApiErrorResponse, example = json!({"msg": "Must provide alias addresses, ports, or protocols"})), + (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 = "ACL alias not found.", body = ApiErrorResponse, example = json!({"msg": "Alias 1 not found"})), + (status = 422, description = "Invalid addresses, ports or protocols.", body = ApiErrorResponse, example = json!({"msg": "Unprocessable entity"})), + (status = 500, description = "Unable to update ACL alias.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn update_acl_alias( @@ -358,15 +399,25 @@ pub(crate) async fn update_acl_alias( Ok(ApiResponse::json(alias, StatusCode::OK)) } -/// Delete ACL alias. +/// Delete an ACL alias #[utoipa::path( delete, path = "/api/v1/acl/alias/{id}", + tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL alias",) + ("id" = i64, Path, description = "ID of the ACL alias.",) ), responses( - (status = OK, description = "ACL alias"), + (status = 200, description = "ACL alias deleted."), + (status = 400, description = "Alias is used by existing ACL rules.", body = ApiErrorResponse, example = json!({"msg": "Alias 1 is used by some existing ACL rules"})), + (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 = "ACL alias not found.", body = ApiErrorResponse, example = json!({"msg": "Alias 1 not found"})), + (status = 500, description = "Unable to delete ACL alias.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn delete_acl_alias( @@ -387,13 +438,23 @@ pub(crate) async fn delete_acl_alias( Ok(ApiResponse::default()) } -/// Apply ACL aliases. +/// Apply ACL aliases #[utoipa::path( put, path = "/api/v1/acl/alias/apply", + tag = "ACL", request_body = ApplyAclAliasesData, responses( - (status = OK, description = "ACL alias"), + (status = 200, description = "Pending alias changes applied."), + (status = 400, description = "ACL alias is already applied.", body = ApiErrorResponse, example = json!({"msg": "Alias 1 already applied"})), + (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 = "ACL alias not found.", body = ApiErrorResponse, example = json!({"msg": "Alias 1 not found"})), + (status = 500, description = "Unable to apply ACL aliases.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn apply_acl_aliases( diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index 403d0eec5b..4289a32eea 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -17,10 +17,10 @@ use crate::{ Protocol, acl_delete_related_objects, parse_destination_addresses, }, error::WebError, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; -/// API representation of [`AclAlias`] used in API requests for modification operations +/// An ACL destination, as accepted when creating or updating one. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, ToSchema)] pub struct EditAclDestination { pub name: String, @@ -77,7 +77,7 @@ impl EditAclDestination { } } -/// API representation of [`AclAlias`] for "Destination" (not "Alias Component"). +/// An ACL destination. /// All relations represented as arrays of IDs. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)] pub struct ApiAclDestination { @@ -216,13 +216,20 @@ impl From for ApiAclDestination { } } -/// List ACL destinations. +/// List ACL destinations #[utoipa::path( get, path = "/api/v1/acl/destination", tag = "ACL", responses( - (status = OK, description = "ACL destination", body = [ApiAclDestination]), + (status = 200, description = "All ACL destinations.", body = [ApiAclDestination]), + (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 = 500, description = "Unable to list ACL destinations.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn list_acl_destinations( @@ -245,13 +252,20 @@ pub(crate) async fn list_acl_destinations( Ok(ApiResponse::json(api_aliases, StatusCode::OK)) } -/// Count ACL destinations by state. +/// Count ACL destinations by state #[utoipa::path( get, path = "/api/v1/acl/destination/count", tag = "ACL", responses( - (status = OK, description = "ACL destination state counts", body = AclStateCount), + (status = 200, description = "Number of ACL destinations in each state.", body = AclStateCount), + (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 = 500, description = "Unable to count ACL destinations.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn count_acl_destinations( @@ -272,16 +286,24 @@ pub(crate) async fn count_acl_destinations( Ok(ApiResponse::json(counts, StatusCode::OK)) } -/// Get ACL destination. +/// Get an ACL destination #[utoipa::path( get, path = "/api/v1/acl/destination/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL destination") + ("id" = i64, Path, description = "ID of the ACL destination.") ), responses( - (status = OK, description = "ACL destination", body = ApiAclDestination), + (status = 200, description = "ACL destination details.", body = ApiAclDestination), + (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 = "ACL destination not found."), + (status = 500, description = "Unable to get ACL destination.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn get_acl_destination( @@ -316,14 +338,23 @@ pub(crate) async fn get_acl_destination( Ok(ApiResponse::new(alias, status)) } -/// Create ACL destination. +/// Create an ACL destination #[utoipa::path( post, path = "/api/v1/acl/destination", tag = "ACL", - request_body = EditAclDestination, + request_body(content = EditAclDestination, example = json!({"name": "internal-web", "addresses": "10.0.0.0/24", "ports": "80, 443", "protocols": [6], "any_address": false, "any_port": false, "any_protocol": false})), responses( - (status = CREATED, description = "ACL destination", body = ApiAclDestination), + (status = 201, description = "ACL destination created.", body = ApiAclDestination), + (status = 400, description = "Destination addresses, ports or protocols are missing.", body = ApiErrorResponse, example = json!({"msg": "Must provide alias addresses, ports, or protocols"})), + (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 = 422, description = "Invalid addresses, ports or protocols.", body = ApiErrorResponse, example = json!({"msg": "Unprocessable entity"})), + (status = 500, description = "Unable to create ACL destination.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn create_acl_destination( @@ -351,16 +382,27 @@ pub(crate) async fn create_acl_destination( Ok(ApiResponse::json(alias, StatusCode::CREATED)) } -/// Update ACL destination. +/// Update an ACL destination #[utoipa::path( put, path = "/api/v1/acl/destination/{id}", tag = "ACL", + request_body = EditAclDestination, params( - ("id" = Id, Path, description = "ID of ACL destination",) + ("id" = i64, Path, description = "ID of the ACL destination.",) ), responses( - (status = OK, description = "ACL destination", body = ApiAclDestination), + (status = 200, description = "ACL destination updated.", body = ApiAclDestination), + (status = 400, description = "Destination addresses, ports or protocols are missing.", body = ApiErrorResponse, example = json!({"msg": "Must provide alias addresses, ports, or protocols"})), + (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 = "ACL destination not found.", body = ApiErrorResponse, example = json!({"msg": "Destination 1 not found"})), + (status = 422, description = "Invalid addresses, ports or protocols.", body = ApiErrorResponse, example = json!({"msg": "Unprocessable entity"})), + (status = 500, description = "Unable to update ACL destination.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn update_acl_destination( @@ -387,16 +429,25 @@ pub(crate) async fn update_acl_destination( Ok(ApiResponse::json(alias, StatusCode::OK)) } -/// Delete ACL destination. +/// Delete an ACL destination #[utoipa::path( delete, path = "/api/v1/acl/destination/{id}", tag = "ACL", params( - ("id" = Id, Path, description = "ID of ACL destination",) + ("id" = i64, Path, description = "ID of the ACL destination.",) ), responses( - (status = OK, description = "ACL destination"), + (status = 200, description = "ACL destination deleted."), + (status = 400, description = "Destination is used by existing ACL rules.", body = ApiErrorResponse, example = json!({"msg": "Destination 1 is used by some existing ACL rules"})), + (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 = "ACL destination not found.", body = ApiErrorResponse, example = json!({"msg": "Destination 1 not found"})), + (status = 500, description = "Unable to delete ACL destination.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn delete_acl_destination( @@ -423,13 +474,23 @@ pub(crate) async fn delete_acl_destination( Ok(ApiResponse::default()) } -/// Apply ACL destinations. +/// Apply ACL destinations #[utoipa::path( put, path = "/api/v1/acl/destination/apply", + tag = "ACL", request_body = ApplyAclDestinationsData, responses( - (status = OK, description = "ACL destination"), + (status = 200, description = "Pending destination changes applied."), + (status = 400, description = "ACL destination is already applied.", body = ApiErrorResponse, example = json!({"msg": "Destination 1 already applied"})), + (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 = "ACL destination not found.", body = ApiErrorResponse, example = json!({"msg": "Destination 1 not found"})), + (status = 500, description = "Unable to apply ACL destinations.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn apply_acl_destinations( diff --git a/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs b/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs index 3c4cb3f509..06b889a1dd 100644 --- a/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs +++ b/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs @@ -4,6 +4,7 @@ use axum::{ }; use defguard_common::db::{Id, NoId}; use reqwest::StatusCode; +use utoipa::ToSchema; use super::LicenseInfo; use crate::{ @@ -13,9 +14,27 @@ use crate::{ ActivityLogStream, ActivityLogStreamConfig, ActivityLogStreamType, }, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; +/// List activity log streams +#[utoipa::path( + get, + path = "/api/v1/activity_log_stream/", + tag = "activity log", + responses( + (status = 200, description = "All activity log streams.", body = [Object], example = json!([ + {"id": 1, "name": "vector", "stream_type": "vector_http", "config": {"url": "https://vector.example.com"}} + ])), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list activity log streams.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_activity_log_stream( _admin: AdminRole, State(appstate): State, @@ -34,13 +53,31 @@ pub async fn get_activity_log_stream( Ok(ApiResponse::json(streams, StatusCode::OK)) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct ActivityLogStreamModificationRequest { pub name: String, pub stream_type: ActivityLogStreamType, pub stream_config: serde_json::Value, } +/// Create an activity log stream +#[utoipa::path( + post, + path = "/api/v1/activity_log_stream/", + tag = "activity log", + request_body = ActivityLogStreamModificationRequest, + responses( + (status = 201, description = "Activity log stream created."), + (status = 400, description = "Invalid stream configuration.", body = ApiErrorResponse, example = json!({"msg": "Invalid stream config"})), + (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 = 500, description = "Unable to create activity log stream.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn create_activity_log_stream( _license: LicenseInfo, _admin: AdminRole, @@ -69,6 +106,28 @@ pub async fn create_activity_log_stream( Ok(ApiResponse::with_status(StatusCode::CREATED)) } +/// Update an activity log stream +#[utoipa::path( + put, + path = "/api/v1/activity_log_stream/{id}", + tag = "activity log", + request_body = ActivityLogStreamModificationRequest, + params( + ("id" = i64, Path, description = "ID of the activity log stream."), + ), + responses( + (status = 200, description = "Activity log stream updated."), + (status = 400, description = "Invalid stream configuration.", body = ApiErrorResponse, example = json!({"msg": "Invalid stream config"})), + (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 = "Activity log stream not found.", body = ApiErrorResponse, example = json!({"msg": "stream not found"})), + (status = 500, description = "Unable to update activity log stream.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn modify_activity_log_stream( _license: LicenseInfo, _admin: AdminRole, @@ -107,6 +166,26 @@ pub async fn modify_activity_log_stream( ))) } +/// Delete an activity log stream +#[utoipa::path( + delete, + path = "/api/v1/activity_log_stream/{id}", + tag = "activity log", + params( + ("id" = i64, Path, description = "ID of the activity log stream."), + ), + responses( + (status = 200, description = "Activity log stream deleted."), + (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 = "Activity log stream not found.", body = ApiErrorResponse, example = json!({"msg": "stream not found"})), + (status = 500, description = "Unable to delete activity log stream.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn delete_activity_log_stream( _license: LicenseInfo, _admin: AdminRole, diff --git a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs index 5ef9e469c6..1988c0e630 100644 --- a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs @@ -6,6 +6,7 @@ use axum::{ use chrono::Utc; use defguard_common::{config::server_config, db::models::user::User, random::gen_alphanumeric}; use serde_json::json; +use utoipa::ToSchema; use super::LicenseInfo; use crate::{ @@ -14,16 +15,40 @@ use crate::{ enterprise::db::models::api_tokens::{ApiToken, ApiTokenInfo}, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ApiResponse, ApiResult, user_for_admin_or_self, validate_name}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult, user_for_admin_or_self, validate_name}, }; const API_TOKEN_LENGTH: usize = 32; -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, ToSchema)] pub struct AddApiTokenData { pub name: String, } +/// Create an API token for a user +/// +/// The token value is returned only in this response and cannot be retrieved later. +#[utoipa::path( + post, + path = "/api/v1/user/{username}/api_token", + tag = "API token", + request_body(content = AddApiTokenData, example = json!({"name": "ci-pipeline"})), + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 201, description = "API token created. Its value is returned only here.", body = Object, example = json!({"token": "dg-4vJqXk9wR2mNpL7sT1yZbH3cD8fG5aQe"})), + (status = 400, description = "Invalid token name.", body = ApiErrorResponse, example = json!({"msg": "Invalid name"})), + (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 = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to create API token.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn add_api_token( _license: LicenseInfo, _admin: AdminRole, @@ -100,6 +125,28 @@ pub async fn add_api_token( } // GET on user, returns ApiTokenInfo vector in JSON +/// List API tokens of a user +/// +/// Token values are never returned, only their metadata. +#[utoipa::path( + get, + path = "/api/v1/user/{username}/api_token", + tag = "API token", + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "All API tokens of the user.", body = Vec), + (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 = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to list API tokens.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn fetch_api_tokens( _license: LicenseInfo, _admin: AdminRole, @@ -117,6 +164,28 @@ pub async fn fetch_api_tokens( Ok(ApiResponse::json(tokens_info, StatusCode::OK)) } +/// Delete an API token of a user +#[utoipa::path( + delete, + path = "/api/v1/user/{username}/api_token/{token_id}", + tag = "API token", + params( + ("username" = String, Path, description = "Name of the user."), + ("token_id" = i64, Path, description = "ID of the API token."), + ), + responses( + (status = 200, description = "API token deleted."), + (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 = 400, description = "Token not found.", body = ApiErrorResponse, example = json!({"msg": "Key not found"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to delete API token.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn delete_api_token( _license: LicenseInfo, _admin: AdminRole, @@ -153,11 +222,34 @@ pub async fn delete_api_token( Ok(ApiResponse::with_status(StatusCode::OK)) } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, ToSchema)] pub struct RenameRequest { pub name: String, } +/// Rename an API token of a user +#[utoipa::path( + post, + path = "/api/v1/user/{username}/api_token/{token_id}/rename", + tag = "API token", + request_body = RenameRequest, + params( + ("username" = String, Path, description = "Name of the user."), + ("token_id" = i64, Path, description = "ID of the API token."), + ), + responses( + (status = 200, description = "API token renamed."), + (status = 400, description = "Invalid name.", body = ApiErrorResponse, example = json!({"msg": "Invalid name"})), + (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 = "User or token not found.", body = ApiErrorResponse, example = json!({"msg": "token not found"})), + (status = 500, description = "Unable to rename API token.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn rename_api_token( _license: LicenseInfo, _admin: AdminRole, diff --git a/crates/defguard_core/src/enterprise/handlers/device_posture.rs b/crates/defguard_core/src/enterprise/handlers/device_posture.rs index fde1efb32e..3375a94e5f 100644 --- a/crates/defguard_core/src/enterprise/handlers/device_posture.rs +++ b/crates/defguard_core/src/enterprise/handlers/device_posture.rs @@ -30,7 +30,7 @@ use crate::{ events::{ApiEvent, ApiEventType, ApiRequestContext}, grpc::GatewayCommand, handlers::{ - ApiResponse, ApiResult, + ApiErrorResponse, ApiResponse, ApiResult, pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, }, location_management::allowed_peers::get_location_allowed_peers, @@ -643,17 +643,18 @@ fn validate_device_posture_os_rules(os_rules: &[ApiOsRule]) -> Result<(), WebErr Ok(()) } +/// Create a device posture check policy #[utoipa::path( post, - path = "/api/v1/posture", - tag = "DevicePosture", + path = "/api/v1/device-posture", + tag = "device posture", request_body = EditDevicePosture, responses( - (status = 201, description = "Posture check created successfully", body = ApiDevicePosture), - (status = 400, description = "Bad request - invalid field value"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), - (status = 500, description = "Internal server error") + (status = 201, description = "Device posture check policy created.", body = ApiDevicePosture), + (status = 400, description = "Invalid request data.", body = ApiErrorResponse, example = json!({"msg": "Unknown desktop client version '1.0'. Valid values: 2.1"})), + (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 = 500, description = "Unable to create device posture check policy.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -721,24 +722,21 @@ pub async fn create_device_posture( Ok(ApiResponse::json(response, StatusCode::CREATED)) } +/// List available posture check versions #[utoipa::path( get, path = "/api/v1/device-posture/versions", - tag = "DevicePosture", + tag = "device posture", responses( - (status = 200, description = "Valid device posture OS and client versions", body = DevicePostureVersionMetadata), - (status = 401, description = "Unauthorized"), + (status = 200, description = "Operating system and client versions that posture checks can require.", body = DevicePostureVersionMetadata), + (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": "requires privileged access"})), ), security( ("cookie" = []), ("api_token" = []) ) )] -/// Return the backend-owned catalog of selectable posture-check versions. -/// -/// # Errors -/// -/// Returns an error when the requester is unauthorized or lacks the required license. pub async fn get_device_posture_versions(_admin: AdminRole, session: SessionInfo) -> ApiResult { debug!( "User {} fetching device posture version metadata", @@ -751,18 +749,20 @@ pub async fn get_device_posture_versions(_admin: AdminRole, session: SessionInfo )) } +/// List device posture check policies #[utoipa::path( get, path = "/api/v1/device-posture", - tag = "DevicePosture", + tag = "device posture", params( - ("page" = Option, Query, description = "Page number (default: 1)"), - ("per_page" = Option, Query, description = "Items per page (default: 10)"), + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), ), responses( - (status = 200, description = "Paginated list of device posture check policies", body = [ApiDevicePosture]), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") + (status = 200, description = "Paginated list of device posture check policies.", body = PaginatedApiResponse), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list device posture check policies.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -826,18 +826,20 @@ pub async fn list_device_postures( )) } +/// Get a device posture check policy #[utoipa::path( get, path = "/api/v1/device-posture/{id}", - tag = "DevicePosture", + tag = "device posture", params( - ("id" = Id, Path, description = "Device posture check policy ID") + ("id" = i64, Path, description = "ID of the device posture check policy.") ), responses( - (status = 200, description = "Device posture check policy", body = ApiDevicePosture), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Device posture check policy details.", body = ApiDevicePosture), + (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": "requires privileged access"})), + (status = 404, description = "Device posture check policy not found.", body = ApiErrorResponse, example = json!({"msg": "Device posture check 1 not found"})), + (status = 500, description = "Unable to get device posture check policy.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -868,22 +870,22 @@ pub async fn get_device_posture( Ok(ApiResponse::json(response, StatusCode::OK)) } -/// Update an existing device posture check policy +/// Update a device posture check policy #[utoipa::path( put, path = "/api/v1/device-posture/{id}", - tag = "DevicePosture", + tag = "device posture", params( - ("id" = Id, Path, description = "Device posture check policy ID") + ("id" = i64, Path, description = "ID of the device posture check policy.") ), request_body = EditDevicePosture, responses( - (status = 200, description = "Device posture check policy updated successfully", body = ApiDevicePosture), - (status = 400, description = "Bad request - invalid field value"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), - (status = 404, description = "Not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Device posture check policy updated.", body = ApiDevicePosture), + (status = 400, description = "Invalid request data.", body = ApiErrorResponse, example = json!({"msg": "Unknown desktop client version '1.0'. Valid values: 2.1"})), + (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 = "Device posture check policy not found.", body = ApiErrorResponse, example = json!({"msg": "Device posture check 1 not found"})), + (status = 500, description = "Unable to update device posture check policy.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -966,16 +968,16 @@ pub async fn update_device_posture( #[utoipa::path( delete, path = "/api/v1/device-posture/{id}", - tag = "DevicePosture", + tag = "device posture", params( - ("id" = Id, Path, description = "Device posture check policy ID") + ("id" = i64, Path, description = "ID of the device posture check policy.") ), responses( - (status = 200, description = "Device posture check policy deleted successfully"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), - (status = 404, description = "Not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Device posture check policy deleted."), + (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 = "Device posture check policy not found.", body = ApiErrorResponse, example = json!({"msg": "Device posture check 1 not found"})), + (status = 500, description = "Unable to delete device posture check policy.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1029,16 +1031,16 @@ pub async fn delete_device_posture( #[utoipa::path( post, path = "/api/v1/device-posture/{id}/duplicate", - tag = "DevicePosture", + tag = "device posture", params( - ("id" = Id, Path, description = "Device posture check policy ID to duplicate") + ("id" = i64, Path, description = "ID of the device posture check policy.") ), responses( - (status = 201, description = "Duplicate created successfully", body = ApiDevicePosture), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), - (status = 404, description = "Not found"), - (status = 500, description = "Internal server error") + (status = 201, description = "Device posture check policy duplicated.", body = ApiDevicePosture), + (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 = "Device posture check policy not found.", body = ApiErrorResponse, example = json!({"msg": "Device posture check 1 not found"})), + (status = 500, description = "Unable to duplicate device posture check policy.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1124,21 +1126,24 @@ pub struct AssignLocationsData { pub locations: Vec, } -/// Assign posture checks to a VPN location (replaces existing assignment) +/// Assign device posture check policies to a location +/// +/// Replaces the current assignment. #[utoipa::path( put, path = "/api/v1/network/{id}/postures", - tag = "DevicePosture", + tag = "device posture", params( - ("id" = Id, Path, description = "VPN location ID") + ("id" = i64, Path, description = "ID of the location.") ), request_body = AssignPosturesData, responses( - (status = 200, description = "Postures assigned successfully", body = [Id]), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), - (status = 404, description = "Location not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Device posture check policies assigned to the location.", body = [Id]), + (status = 400, description = "Posture checks cannot be assigned to a service location.", body = ApiErrorResponse, example = json!({"msg": "Posture checks cannot be assigned to service locations"})), + (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 = "Location not found.", body = ApiErrorResponse, example = json!({"msg": "Location 1 not found"})), + (status = 500, description = "Unable to assign device posture check policies to the location.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1163,12 +1168,6 @@ pub async fn set_postures_for_location( .await? .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; - if location.is_service_location() && !data.postures.is_empty() { - return Err(WebError::BadRequest( - "Posture checks cannot be assigned to service locations".to_owned(), - )); - } - let mut tx = appstate.pool.begin().await?; let old_postures = DevicePostureLocation::find_by_location(&mut *tx, location_id).await?; let result = @@ -1193,21 +1192,24 @@ pub async fn set_postures_for_location( Ok(ApiResponse::json(result, StatusCode::OK)) } -/// Assign VPN locations to a posture check (replaces existing assignment) +/// Assign locations to a device posture check policy +/// +/// Replaces the current assignment. #[utoipa::path( put, path = "/api/v1/device-posture/{id}/locations", - tag = "DevicePosture", + tag = "device posture", params( - ("id" = Id, Path, description = "Device posture check policy ID") + ("id" = i64, Path, description = "ID of the device posture check policy.") ), request_body = AssignLocationsData, responses( - (status = 200, description = "Locations assigned successfully", body = [Id]), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), - (status = 404, description = "Posture check not found"), - (status = 500, description = "Internal server error") + (status = 200, description = "Locations assigned to the device posture check policy.", body = [Id]), + (status = 400, description = "Posture checks cannot be assigned to a service location.", body = ApiErrorResponse, example = json!({"msg": "Posture checks cannot be assigned to service locations"})), + (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 = "Device posture check policy not found.", body = ApiErrorResponse, example = json!({"msg": "Device posture check 1 not found"})), + (status = 500, description = "Unable to assign locations to the device posture check policy.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1234,16 +1236,6 @@ pub async fn set_locations_for_posture( WebError::ObjectNotFound(format!("Device posture check {posture_id} not found")) })?; - for location_id in &data.locations { - if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, *location_id).await? - && location.is_service_location() - { - return Err(WebError::BadRequest( - "Posture checks cannot be assigned to service locations".to_owned(), - )); - } - } - let mut tx = appstate.pool.begin().await?; let old_locations = DevicePostureLocation::find_by_posture(&mut *tx, posture_id).await?; let result = diff --git a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs index 0f374dbe96..bf57ec341c 100644 --- a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs @@ -1,16 +1,120 @@ +use std::collections::HashSet; + use axum::{Json, extract::State, http::StatusCode}; -use defguard_common::types::proxy::ProxyControlMessage; +use defguard_common::{db::Id, types::proxy::ProxyControlMessage}; +use sqlx::{PgConnection, PgPool, query_scalar}; use struct_patch::Patch; use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - enterprise::db::models::enterprise_settings::{EnterpriseSettings, EnterpriseSettingsPatch}, + enterprise::db::models::{ + enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, EnterpriseSettingsInfo, + EnterpriseSettingsPatch, GroupClientTrafficPolicies, + }, + group_client_traffic_policy::GroupClientTrafficPolicy, + }, + error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; +#[derive(Deserialize)] +/// Request payload for partially updating enterprise settings and group policies. +pub struct EnterpriseSettingsPatchRequest { + #[serde(flatten)] + pub settings: EnterpriseSettingsPatch, + pub group_client_traffic_policies: Option, +} + +fn policy_assignments(policies: &GroupClientTrafficPolicies) -> Vec<(Id, ClientTrafficPolicy)> { + policies + .none + .iter() + .map(|&id| (id, ClientTrafficPolicy::None)) + .chain( + policies + .disable_all_traffic + .iter() + .map(|&id| (id, ClientTrafficPolicy::DisableAllTraffic)), + ) + .chain( + policies + .force_all_traffic + .iter() + .map(|&id| (id, ClientTrafficPolicy::ForceAllTraffic)), + ) + .collect() +} + +async fn validate_policy_assignments( + transaction: &mut PgConnection, + policies: &GroupClientTrafficPolicies, +) -> Result<(), WebError> { + let assignments = policy_assignments(policies); + let mut group_ids = HashSet::with_capacity(assignments.len()); + for (group_id, _) in &assignments { + if !group_ids.insert(*group_id) { + return Err(WebError::BadRequest( + "A group cannot be assigned to multiple client traffic policies.".into(), + )); + } + } + + let group_ids = group_ids.into_iter().collect::>(); + if group_ids.is_empty() { + return Ok(()); + } + let existing_ids: HashSet = + query_scalar!("SELECT id FROM \"group\" WHERE id = ANY($1)", &group_ids) + .fetch_all(&mut *transaction) + .await? + .into_iter() + .collect(); + if existing_ids.len() != group_ids.len() { + return Err(WebError::BadRequest( + "One or more client traffic policy groups do not exist.".into(), + )); + } + Ok(()) +} + +async fn settings_info( + pool: &PgPool, + settings: EnterpriseSettings, +) -> Result { + let group_policies = + GroupClientTrafficPolicy::grouped(GroupClientTrafficPolicy::all(pool).await?); + Ok(EnterpriseSettingsInfo::new(settings, group_policies)) +} + +/// Get enterprise settings +/// +/// Available to every authenticated user. +#[utoipa::path( + get, + path = "/api/v1/settings_enterprise", + tag = "settings", + responses( + (status = 200, description = "Enterprise settings.", body = Object, example = json!({ + "admin_device_management": false, + "client_traffic_policy": "none", + "only_client_activation": false, + "disable_tunnels": false, + "display_download_step": true, + "display_password_reset": true, + "group_client_traffic_policies": {"none": [], "disable_all_traffic": [2], "force_all_traffic": []} + })), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to get enterprise settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_enterprise_settings( session: SessionInfo, State(appstate): State, @@ -24,21 +128,45 @@ pub async fn get_enterprise_settings( "User {} retrieved enterprise settings", session.user.username ); - Ok(ApiResponse::json(settings, StatusCode::OK)) + Ok(ApiResponse::json( + settings_info(&appstate.pool, settings).await?, + StatusCode::OK, + )) } +/// Update selected enterprise settings +#[utoipa::path( + patch, + path = "/api/v1/settings_enterprise", + tag = "settings", + request_body = Object, + responses( + (status = 200, description = "Enterprise settings updated."), + (status = 400, description = "Invalid settings.", body = ApiErrorResponse, example = json!({"msg": "Invalid settings"})), + (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 = 500, description = "Unable to update enterprise settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn patch_enterprise_settings( _license: LicenseInfo, _admin: AdminRole, State(appstate): State, session: SessionInfo, - Json(data): Json, + Json(data): Json, ) -> ApiResult { debug!( "Admin {} patching enterprise settings.", session.user.username, ); - let mut settings = EnterpriseSettings::get(&appstate.pool).await?; + let mut transaction = appstate.pool.begin().await?; + let mut settings = EnterpriseSettings::get(&mut *transaction).await?; + let old_group_policies = + GroupClientTrafficPolicy::grouped(GroupClientTrafficPolicy::all(&mut *transaction).await?); // snapshot for audit event let old_settings = settings.clone(); @@ -46,9 +174,23 @@ pub async fn patch_enterprise_settings( let old_display_password_reset = old_settings.display_password_reset; let old_display_download_step = old_settings.display_download_step; - settings.apply(data); - settings.save(&appstate.pool).await?; - info!("Admin {} patched settings.", session.user.username); + settings.apply(data.settings); + let group_policies = if let Some(group_policies) = data.group_client_traffic_policies { + validate_policy_assignments(&mut transaction, &group_policies).await?; + GroupClientTrafficPolicy::replace_all(&mut transaction, &group_policies).await?; + group_policies + } else { + old_group_policies.clone() + }; + settings.save(&mut *transaction).await?; + transaction.commit().await?; + + let before = EnterpriseSettingsInfo::new(old_settings, old_group_policies); + let after = EnterpriseSettingsInfo::new(settings.clone(), group_policies); + info!( + "Admin {} patched enterprise settings.", + session.user.username + ); appstate.emit_event(ApiEvent { context: ApiRequestContext::new( @@ -57,10 +199,7 @@ pub async fn patch_enterprise_settings( None::, "web".into(), ), - event: Box::new(ApiEventType::EnterpriseSettingsUpdated { - before: old_settings, - after: settings.clone(), - }), + event: Box::new(ApiEventType::EnterpriseSettingsUpdated { before, after }), })?; // Broadcast updated public settings to proxies only if they changed. diff --git a/crates/defguard_core/src/enterprise/handlers/mod.rs b/crates/defguard_core/src/enterprise/handlers/mod.rs index c2a2923567..244df320c1 100644 --- a/crates/defguard_core/src/enterprise/handlers/mod.rs +++ b/crates/defguard_core/src/enterprise/handlers/mod.rs @@ -26,7 +26,7 @@ use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, error::WebError, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; pub struct LicenseInfo { @@ -100,7 +100,40 @@ where } } -/// Gets full information about enterprise status. +/// Get information about the enterprise license and enabled features +#[utoipa::path( + get, + path = "/api/v1/enterprise_info", + tag = "license", + responses( + (status = 200, description = "License information and effective enterprise features.", body = Object, example = json!({ + "license_info": { + "valid_until": "2027-01-01T00:00:00Z", + "subscription": true, + "expired": false, + "limits_exceeded": false, + "tier": "Enterprise", + "support_type": "DirectEnterprise", + "limits": { + "users": {"current": 12, "limit": 100}, + "locations": {"current": 2, "limit": 10}, + "user_devices": null, + "network_devices": null, + "devices": {"current": 30, "limit": 500} + }, + "features": ["DevicePosture"], + "customer_id": "cus_00000000" + } + })), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get license information.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn check_enterprise_info(_admin: AdminRole, _session: SessionInfo) -> ApiResult { let license = get_cached_license(); let license_info = license.as_ref().map(|license| { diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 76969300b8..4eadbc6326 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -14,7 +14,7 @@ use defguard_common::{ config::server_config, db::{ Id, - models::{Settings, settings::OpenIdUsernameHandling, user::User}, + models::{MFAInfo, Settings, settings::OpenIdUsernameHandling, user::User}, }, }; use openidconnect::{ @@ -39,7 +39,7 @@ use super::LicenseInfo; use crate::{ appstate::AppState, enterprise::{ - db::models::openid_provider::{OpenIdProvider, OpenIdProviderKind}, + db::models::openid_provider::OpenIdProvider, directory_sync::{sync_user_groups_if_configured, user_in_directory_groups}, ldap::utils::ldap_update_user_state, license::get_cached_license, @@ -48,7 +48,8 @@ use crate::{ error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ - ApiResponse, AuthResponse, ClientIpAddr, SESSION_COOKIE_NAME, SIGN_IN_COOKIE_NAME, + ApiErrorResponse, ApiResponse, AuthResponse, ClientIpAddr, SESSION_COOKIE_NAME, + SIGN_IN_COOKIE_NAME, auth::create_session, cookie_domain, mail::send_user_import_blocked_email, @@ -360,7 +361,6 @@ pub async fn user_from_claims( .directory_sync_user_groups .as_ref() .filter(|groups| !groups.is_empty()) - && provider.kind == OpenIdProviderKind::Microsoft { let in_groups = user_in_directory_groups(pool, email, user_groups_filter) .await @@ -558,6 +558,20 @@ pub async fn user_from_claims( Ok(user) } +/// Start login through the external OpenID provider +/// +/// Returns the provider authorization URL the user should be redirected to. +#[utoipa::path( + get, + path = "/api/v1/openid/auth_info", + tag = "OpenID", + responses( + (status = 200, description = "Authorization URL of the external provider.", body = Object, example = json!({"url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=..."})), + (status = 403, description = "Requires an active enterprise license.", body = ApiErrorResponse, example = json!({"msg": "requires privileged access"})), + (status = 404, description = "No external OpenID provider is configured.", body = ApiErrorResponse, example = json!({"msg": "OpenID provider not set"})), + (status = 500, description = "Unable to build authorization URL.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn get_auth_info( _license: LicenseInfo, private_cookies: PrivateCookieJar, @@ -635,6 +649,31 @@ pub struct AuthenticationResponse { state: CsrfToken, } +/// Finish login through the external OpenID provider +/// +/// Exchanges the authorization code for tokens and creates a defguard session. +#[utoipa::path( + post, + path = "/api/v1/openid/callback", + tag = "OpenID", + request_body = Object, + responses( + (status = 200, description = "User authenticated.", body = AuthResponse, + headers( + ("Set-Cookie" = String, description = "`defguard_session` cookie."), + ), + ), + (status = 201, description = "A second authentication factor is required. Verify one of the listed methods with the matching `/api/v1/auth/{method}` endpoint.", body = MFAInfo, + headers( + ("Set-Cookie" = String, description = "`defguard_session` cookie of a not fully authenticated session."), + ), + ), + (status = 400, description = "Invalid callback payload.", body = ApiErrorResponse, example = json!({"msg": "Invalid state"})), + (status = 401, description = "CSRF token mismatch or missing nonce cookie.", body = ApiErrorResponse, example = json!({"msg": "State mismatch"})), + (status = 403, description = "Requires an active enterprise license.", body = ApiErrorResponse, example = json!({"msg": "requires privileged access"})), + (status = 500, description = "Unable to finish external login.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn auth_callback( _license: LicenseInfo, cookies: CookieJar, @@ -768,9 +807,45 @@ pub async fn auth_callback( #[cfg(test)] mod test { + use std::{ + net::Ipv4Addr, + sync::{Arc, Mutex, RwLock, atomic::AtomicBool}, + }; + + use axum::http::HeaderMap; + use axum_extra::extract::cookie::Key; + use chrono::Utc; + use defguard_common::{ + config::{DefGuardConfig, SERVER_CONFIG}, + db::{ + models::{group::Group, settings::initialize_current_settings}, + setup_pool, + }, + }; + use openidconnect::{ + AccessToken, Audience, AuthUrl, EmptyAdditionalClaims, EmptyAdditionalProviderMetadata, + EmptyExtraTokenFields, EndUserEmail, JsonWebKeySetUrl, PrivateSigningKey, ResponseTypes, + StandardClaims, SubjectIdentifier, TokenUrl, + core::{ + CoreIdToken, CoreIdTokenClaims, CoreIdTokenFields, CoreJsonWebKeySet, + CoreJwsSigningAlgorithm, CoreResponseType, CoreSubjectIdentifierType, + CoreTokenResponse, CoreTokenType, + }, + }; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use tokio::sync::{broadcast, mpsc}; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + use super::*; use crate::{ + auth::failed_login::FailedLoginMap, enterprise::{ + db::models::openid_provider::{ + DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProviderKind, + }, license::{License, LicenseTier, SupportType, set_cached_license}, limits::{Counts, set_counts}, }, @@ -957,4 +1032,221 @@ mod test { assert_eq!(reached_user_license_limit(), None); } + + const TEST_CLIENT_ID: &str = "client_id"; + const TEST_NONCE: &str = "test_nonce"; + const TEST_CSRF: &str = "test_csrf"; + const TEST_SUB: &str = "test_sub"; + + async fn make_mock_provider_server(email: &str) -> MockServer { + let server = MockServer::start().await; + let issuer = IssuerUrl::new(server.uri()).unwrap(); + let signing_key = Settings::get_current_settings() + .openid_key_required() + .unwrap(); + + let metadata = CoreProviderMetadata::new( + issuer.clone(), + AuthUrl::new(format!("{}/authorize", server.uri())).unwrap(), + JsonWebKeySetUrl::new(format!("{}/jwks", server.uri())).unwrap(), + vec![ResponseTypes::new(vec![CoreResponseType::Code])], + vec![CoreSubjectIdentifierType::Public], + vec![CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256], + EmptyAdditionalProviderMetadata {}, + ) + .set_token_endpoint(Some( + TokenUrl::new(format!("{}/token", server.uri())).unwrap(), + )); + + let issue_time = Utc::now(); + let id_token = CoreIdToken::new( + CoreIdTokenClaims::new( + issuer, + vec![Audience::new(TEST_CLIENT_ID.to_owned())], + issue_time + chrono::Duration::hours(1), + issue_time, + StandardClaims::new(SubjectIdentifier::new(TEST_SUB.to_owned())) + .set_email(Some(EndUserEmail::new(email.to_owned()))), + EmptyAdditionalClaims {}, + ) + .set_nonce(Some(Nonce::new(TEST_NONCE.to_owned()))), + &signing_key, + CoreJwsSigningAlgorithm::RsaSsaPkcs1V15Sha256, + None, + None, + ) + .unwrap(); + let token_response = CoreTokenResponse::new( + AccessToken::new("access_token".to_owned()), + CoreTokenType::Bearer, + CoreIdTokenFields::new(Some(id_token), EmptyExtraTokenFields {}), + ); + + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with( + ResponseTemplate::new(200).set_body_json(CoreJsonWebKeySet::new(vec![ + signing_key.as_verification_key(), + ])), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(token_response)) + .mount(&server) + .await; + + server + } + + fn make_app_state(pool: PgPool, key: Key) -> AppState { + let (webhook_tx, webhook_rx) = mpsc::unbounded_channel(); + let (gateway_tx, _gateway_rx) = broadcast::channel(16); + let (web_reload_tx, _web_reload_rx) = broadcast::channel(8); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let (ldap_tx, _ldap_rx) = mpsc::unbounded_channel(); + let (dirsync_tx, _dirsync_rx) = mpsc::unbounded_channel(); + let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(10); + + AppState::new( + pool, + webhook_tx, + webhook_rx, + gateway_tx, + web_reload_tx, + key, + Arc::new(Mutex::new(FailedLoginMap::new())), + event_tx, + ldap_tx, + dirsync_tx, + Arc::new(RwLock::default()), + proxy_control_tx, + Arc::new(AtomicBool::new(false)), + ) + } + + /// Log a user belonging to a single locally managed group in through an external OpenID + /// provider configured with the given directory sync target. The mock directory reports the + /// user as a member of "group1" only, so a sync that runs replaces the local group. + async fn login_with_sync_target( + pool: &PgPool, + target: DirectorySyncTarget, + ) -> (User, Group) { + let _ = SERVER_CONFIG.set(DefGuardConfig::new_test_config()); + Settings::initialize_runtime_defaults(pool).await.unwrap(); + initialize_current_settings(pool).await.unwrap(); + + let user = User::new( + "testuser", + None, + "LastName", + "FirstName", + "testuser@email.com", + None, + ) + .save(pool) + .await + .unwrap(); + let local_group = Group::new("localgroup").save(pool).await.unwrap(); + user.add_to_group(pool, &local_group).await.unwrap(); + + let provider_server = make_mock_provider_server(&user.email).await; + // The provider name selects the directory sync client, "Test" is the mock one. + OpenIdProvider::new( + "Test".to_owned(), + provider_server.uri(), + OpenIdProviderKind::Custom, + TEST_CLIENT_ID.to_owned(), + "client_secret".to_owned(), + None, + None, + None, + None, + true, + 60, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + target, + None, + None, + Vec::new(), + None, + false, + false, + None, + ) + .save(pool) + .await + .unwrap(); + + let key = Key::generate(); + let private_cookies = PrivateCookieJar::from_headers(&HeaderMap::new(), key.clone()) + .add(Cookie::new(NONCE_COOKIE_NAME, TEST_NONCE)) + .add(Cookie::new(CSRF_COOKIE_NAME, TEST_CSRF)); + + let _response = auth_callback( + LicenseInfo { valid: true }, + CookieJar::from_headers(&HeaderMap::new()), + private_cookies, + TypedHeader(UserAgent::from_static("test")), + ClientIpAddr(IpAddr::V4(Ipv4Addr::LOCALHOST)), + State(make_app_state(pool.clone(), key)), + Json(AuthenticationResponse { + code: AuthorizationCode::new("code".to_owned()), + state: CsrfToken::new(TEST_CSRF.to_owned()), + }), + ) + .await + .unwrap(); + + (user, local_group) + } + + // Logging in through an external OpenID provider used to sync the user's groups regardless of + // the configured directory sync target, wiping locally managed group assignments when the + // target was set to users only. + #[sqlx::test] + async fn test_login_keeps_groups_when_sync_target_is_users( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let (user, local_group) = login_with_sync_target(&pool, DirectorySyncTarget::Users).await; + + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert_eq!(user_groups[0].id, local_group.id); + } + + #[sqlx::test] + async fn test_login_syncs_groups_when_sync_target_is_all( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let (user, _) = login_with_sync_target(&pool, DirectorySyncTarget::All).await; + + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert_eq!(user_groups[0].name, "group1"); + } + + #[sqlx::test] + async fn test_login_syncs_groups_when_sync_target_is_groups( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let (user, _) = login_with_sync_target(&pool, DirectorySyncTarget::Groups).await; + + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert_eq!(user_groups[0].name, "group1"); + } } diff --git a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs index bd0ec78af3..608bddf00b 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs @@ -24,7 +24,7 @@ use crate::{ directory_sync::test_directory_sync_connection, }, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; #[derive(Deserialize, Serialize, ToSchema)] @@ -55,20 +55,23 @@ pub struct AddProviderData { pub username_handling: OpenIdUsernameHandling, } -/// Add OpenID provider. -/// -/// # Returns -/// - HTTP Status "created" on success. +/// Create an OpenID provider #[utoipa::path( post, path = "/api/v1/openid/provider", tag = "OpenID", - params( - ("data" = AddProviderData, Path, description = "OpenID provider data",) - ), + request_body = AddProviderData, responses( - (status = CREATED, description = "Add OpenID provider"), + (status = 201, description = "OpenID provider created."), + (status = 400, description = "Invalid provider configuration.", body = ApiErrorResponse, example = json!({"msg": "Failed to parse Google service account key"})), + (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 = 500, description = "Unable to create OpenID provider.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub(crate) async fn add_openid_provider( _license: LicenseInfo, @@ -258,19 +261,24 @@ pub(crate) async fn add_openid_provider( Ok(ApiResponse::with_status(StatusCode::CREATED)) } -/// Get OpenID provider by name. -/// -/// # Returns -/// - HTTP Status "OK" on success. +/// Get an OpenID provider #[utoipa::path( get, path = "/api/v1/openid/provider/{name}", tag = "OpenID", responses( - (status = OK, description = "Get OpenID provider"), + (status = 200, description = "OpenID provider details.", body = Object, example = json!({"provider": {"name": "google", "base_url": "https://accounts.google.com", "client_id": "client-id"}, "settings": {"create_account": false, "username_handling": "remove_forbidden"}})), + (status = 204, description = "No OpenID provider with this name."), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get OpenID provider.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), params( - ("name" = String, Path, description = "The name of a provider",) + ("name" = String, Path, description = "Name of the OpenID provider.",) + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn get_openid_provider( @@ -298,19 +306,24 @@ pub(crate) async fn get_openid_provider( } } -/// Delete OpenID provider. -/// -/// # Returns -/// - HTTP Status "OK" on success. +/// Delete an OpenID provider #[utoipa::path( delete, path = "/api/v1/openid/provider/{name}", tag = "OpenID", responses( - (status = OK, description = "Delete OpenID provider"), + (status = 200, description = "OpenID provider deleted."), + (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."), + (status = 500, description = "Unable to delete OpenID provider.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), params( - ("name" = String, Path, description = "The name of a provider",) + ("name" = String, Path, description = "Name of the OpenID provider.",) + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn delete_openid_provider( @@ -362,19 +375,26 @@ pub(crate) async fn delete_openid_provider( } } -/// Modify OpenID provider. -/// -/// # Returns -/// - HTTP Status "OK" on success. +/// Update an OpenID provider #[utoipa::path( put, path = "/api/v1/openid/provider/{name}", tag = "OpenID", + request_body = AddProviderData, responses( - (status = OK, description = "Modify OpenID provider"), + (status = 200, description = "OpenID provider updated."), + (status = 400, description = "Invalid provider configuration.", body = ApiErrorResponse, example = json!({"msg": "Failed to parse Google service account key"})), + (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."), + (status = 500, description = "Unable to update OpenID provider.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), params( - ("name" = String, Path, description = "The name of a provider",) + ("name" = String, Path, description = "Name of the OpenID provider.",) + ), + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn modify_openid_provider( @@ -543,17 +563,21 @@ pub(crate) async fn modify_openid_provider( } } -/// List all OpenID providers. -/// -/// # Returns -/// - Array of all OpenID providers and HTTP status "OK" on success. +/// List OpenID providers #[utoipa::path( get, path = "/api/v1/openid/provider", tag = "OpenID", responses( - (status = OK, description = "List of OpenID providers"), + (status = 200, description = "All OpenID providers.", body = [Object], example = json!([{"id": 1, "name": "google", "base_url": "https://accounts.google.com", "client_id": "client-id"}])), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list OpenID providers.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub(crate) async fn list_openid_providers( _admin: AdminRole, @@ -563,19 +587,21 @@ pub(crate) async fn list_openid_providers( Ok(ApiResponse::json(providers, StatusCode::OK)) } -/// Get current OpenID provider. -/// -/// # Returns -/// - HTTP Status "OK" on success. +/// Get the current OpenID provider #[utoipa::path( get, path = "/api/v1/openid/provider/current", tag = "OpenID", responses( - (status = OK, description = "Get current OpenID provider"), + (status = 200, description = "Current OpenID provider details.", body = Object, example = json!({"provider": {"name": "google", "base_url": "https://accounts.google.com", "client_id": "client-id"}, "settings": {"create_account": false, "username_handling": "remove_forbidden"}})), + (status = 204, description = "No OpenID provider is configured."), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get OpenID provider.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), - params( - ("name" = String, Path, description = "The name of a provider",) + security( + ("cookie" = []), + ("api_token" = []) ) )] pub(crate) async fn get_current_openid_provider( @@ -602,6 +628,22 @@ pub(crate) async fn get_current_openid_provider( } } +/// Test the directory sync connection of the current OpenID provider +#[utoipa::path( + get, + path = "/api/v1/test_directory_sync", + tag = "OpenID", + responses( + (status = 200, description = "Result of the connection test.", body = Object, example = json!({"message": "Connection successful", "success": true})), + (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 = 500, description = "Unable to test directory sync connection.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn test_dirsync_connection( _license: LicenseInfo, _admin: AdminRole, diff --git a/crates/defguard_core/src/enterprise/ldap/tests.rs b/crates/defguard_core/src/enterprise/ldap/tests.rs index 57d99e331d..33083dcd22 100644 --- a/crates/defguard_core/src/enterprise/ldap/tests.rs +++ b/crates/defguard_core/src/enterprise/ldap/tests.rs @@ -9,6 +9,7 @@ use defguard_common::{ setup_pool, }, secret::SecretStringWrapper, + testing::smtp::configure_working_smtp, }; use ldap3::SearchEntry; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -3825,7 +3826,8 @@ async fn test_ldap_sync_allowed_all_conditions_false(_: PgPoolOptions, options: } /// When both `ldap_remote_enrollment_enabled` and `ldap_remote_enrollment_send_invite` are -/// disabled (the default), syncing new LDAP users must NOT create any enrollment tokens. +/// disabled (the default), syncing new LDAP users must NOT create any enrollment tokens and +/// must NOT send any invite email - even though a working SMTP server is available. #[sqlx::test] async fn test_sync_does_not_send_invite_when_flags_disabled( _: PgPoolOptions, @@ -3836,6 +3838,9 @@ async fn test_sync_does_not_send_invite_when_flags_disabled( let (ldap_tx, mut ldap_rx) = ldap_test_channel(); let _ = initialize_current_settings(&pool).await; + // Working SMTP is available, so the disabled flags are the only reason no invite is sent. + let smtp = configure_working_smtp(&pool).await; + // Create an admin so find_admins() would have something to return - we want to prove // the early-return on the flag guard, not the no-admin guard. make_test_admin(&pool, "sync_admin_nodisabled").await; @@ -3874,6 +3879,15 @@ async fn test_sync_does_not_send_invite_when_flags_disabled( tokens.is_empty(), "Expected no enrollment token when invite flags are disabled, got {tokens:?}" ); + + // No invite email should have been sent. The invite mail is spawned only after the + // token is created, so with no token no mail is ever dispatched to the mock server. + assert_eq!( + smtp.message_count(), + 0, + "Expected no invite email when invite flags are disabled, got {:?}", + smtp.messages() + ); } /// When only `ldap_remote_enrollment_enabled` is on but `ldap_remote_enrollment_send_invite` @@ -3894,6 +3908,10 @@ async fn test_sync_invite_skipped_when_send_invite_flag_disabled( settings.ldap_remote_enrollment_send_invite = false; update_current_settings(&pool, settings).await.unwrap(); + // Point SMTP at a working mock so the disabled send-invite flag is the only reason + // no invite is sent. + let smtp = configure_working_smtp(&pool).await; + make_test_admin(&pool, "sync_admin_sendoff").await; let mut ldap_conn = LDAPConnection::create().await.unwrap(); @@ -3921,14 +3939,82 @@ async fn test_sync_invite_skipped_when_send_invite_flag_disabled( tokens.is_empty(), "Expected no enrollment token when send_invite flag is disabled, got {tokens:?}" ); + + assert_eq!( + smtp.message_count(), + 0, + "Expected no invite email when send_invite flag is disabled, got {:?}", + smtp.messages() + ); } -/// When both `ldap_remote_enrollment_enabled` and `ldap_remote_enrollment_send_invite` are on, -/// syncing a new LDAP user must create an enrollment token and set `enrollment_pending = true`. +/// Regression test for . /// -/// SMTP is configured in settings but no real SMTP server is reachable, so `new_account_mail` -/// will fail - but the token and flag are persisted before the mail attempt, so the DB side -/// effects are still observable. +/// When `ldap_remote_enrollment_send_invite` is left on but its parent +/// `ldap_remote_enrollment_enabled` toggle is off, syncing new LDAP users must NOT create +/// enrollment tokens and must NOT send an invite email. Everything else needed to send an +/// invite (a working SMTP server, LDAP, proxy URL and an admin) is configured, so the disabled +/// parent toggle is the only reason no invite is sent. +#[sqlx::test] +async fn test_sync_invite_skipped_when_remote_enrollment_disabled( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (wg_tx, _wg_rx) = wg_test_channel(); + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + let _ = initialize_current_settings(&pool).await; + + let mut settings = Settings::get_current_settings(); + configure_smtp_and_ldap(&mut settings); + settings.ldap_remote_enrollment_enabled = false; + settings.ldap_remote_enrollment_send_invite = true; + settings.public_proxy_url = PROXY_URL.into(); + update_current_settings(&pool, settings).await.unwrap(); + + // Point SMTP at a working mock so the disabled parent toggle is the only reason + // no invite is sent. + let smtp = configure_working_smtp(&pool).await; + + make_test_admin(&pool, "sync_admin_enrollmentoff").await; + + let mut ldap_conn = LDAPConnection::create().await.unwrap(); + let config = ldap_conn.config.clone(); + + let mut ldap_user = make_test_user("sync_invite_enrollmentoff_user", None, None); + ldap_user.ldap_rdn = Some("sync_invite_enrollmentoff_user".into()); + ldap_user.ldap_user_path = Some("ou=users,dc=example,dc=com".into()); + ldap_conn + .test_client_mut() + .add_test_user(&ldap_user, &config); + + ldap_conn + .sync(&pool, false, &wg_tx, &ldap_tx) + .await + .unwrap(); + + let saved = User::find_by_username(&pool, "sync_invite_enrollmentoff_user") + .await + .unwrap(); + assert!(saved.is_some(), "User should have been synced to Defguard"); + + let tokens = Token::fetch_all(&pool).await.unwrap(); + assert!( + tokens.is_empty(), + "Expected no enrollment token when remote enrollment is disabled, got {tokens:?}" + ); + + assert_eq!( + smtp.message_count(), + 0, + "Expected no invite email when remote enrollment is disabled, got {:?}", + smtp.messages() + ); +} + +/// When both `ldap_remote_enrollment_enabled` and `ldap_remote_enrollment_send_invite` are on, +/// syncing a new LDAP user must create an enrollment token, set `enrollment_pending = true`, +/// and actually deliver an invite email to the user carrying the tokenized proxy link. #[sqlx::test] async fn test_sync_sends_invite_when_flags_enabled(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -3944,6 +4030,9 @@ async fn test_sync_sends_invite_when_flags_enabled(_: PgPoolOptions, options: Pg settings.public_proxy_url = PROXY_URL.into(); update_current_settings(&pool, settings).await.unwrap(); + // Point SMTP at a working mock so the invite email is actually delivered and captured. + let smtp = configure_working_smtp(&pool).await; + make_test_admin(&pool, "sync_admin_invite").await; let mut ldap_conn = LDAPConnection::create().await.unwrap(); @@ -3982,7 +4071,25 @@ async fn test_sync_sends_invite_when_flags_enabled(_: PgPoolOptions, options: Pg "Token should belong to the synced user" ); + // The invite email (sent fire-and-forget) is delivered to the synced user and carries a + // tokenized enrollment link pointing at the configured proxy. + let mail = smtp + .wait_for(|m| m.sent_to("sync_invite_user@example.com")) + .await; + assert!( + mail.body_contains(&tokens[0].id), + "invite email should contain the enrollment token" + ); + assert!( + mail.body_contains("proxy.example.com"), + "invite link should point at the configured proxy URL" + ); + // Second sync: user already exists in Defguard - must NOT create a second token. + // An invite email is only ever dispatched together with a new enrollment token, so an + // unchanged token count is the deterministic guard that no second invite was sent. (A + // direct message_count() check here would be racy: mail is spawned fire-and-forget, so a + // wrongly-sent second mail might not have reached the mock by the time we read the count.) ldap_conn .sync(&pool, false, &wg_tx, &ldap_tx) .await @@ -4051,7 +4158,8 @@ async fn test_sync_invite_skipped_when_no_admin_exists( } /// When both invite flags are on and a user logs in through LDAP for the first time (not yet -/// in Defguard), an enrollment token must be created and `enrollment_pending` set to `true`. +/// in Defguard), an enrollment token must be created, `enrollment_pending` set to `true`, and +/// an invite email actually delivered to the user carrying the tokenized proxy link. #[sqlx::test] async fn test_ldap_login_sends_invite_when_flags_enabled( _: PgPoolOptions, @@ -4067,6 +4175,9 @@ async fn test_ldap_login_sends_invite_when_flags_enabled( settings.public_proxy_url = PROXY_URL.into(); update_current_settings(&pool, settings).await.unwrap(); + // Point SMTP at a working mock so the invite email is actually delivered and captured. + let smtp = configure_working_smtp(&pool).await; + make_test_admin(&pool, "login_admin_invite").await; let mut ldap_conn = super::LDAPConnection::create().await.unwrap(); @@ -4105,7 +4216,24 @@ async fn test_ldap_login_sends_invite_when_flags_enabled( "Token should belong to the logged-in user" ); + // The invite email (sent fire-and-forget) is delivered to the user and carries a + // tokenized enrollment link pointing at the configured proxy. + let mail = smtp + .wait_for(|m| m.sent_to("login_invite_user@example.com")) + .await; + assert!( + mail.body_contains(&tokens[0].id), + "invite email should contain the enrollment token" + ); + assert!( + mail.body_contains("proxy.example.com"), + "invite link should point at the configured proxy URL" + ); + // Second login: user now exists in Defguard - must NOT create a second token. + // As above, the unchanged token count is the deterministic guard that no second invite + // was sent (mail is dispatched only alongside a new token); an instantaneous + // message_count() check would be racy against fire-and-forget delivery. let result = login_through_ldap_with_connection(&pool, &mut ldap_conn, "login_invite_user", PASSWORD) .await; @@ -4123,7 +4251,8 @@ async fn test_ldap_login_sends_invite_when_flags_enabled( } /// When both invite flags are on but the LDAP user already exists in Defguard (returning user), -/// no additional enrollment token must be created. +/// no additional enrollment token must be created and no invite email must be sent - even +/// though a working SMTP server and both flags are configured. #[sqlx::test] async fn test_ldap_login_does_not_send_invite_for_existing_user( _: PgPoolOptions, @@ -4139,6 +4268,10 @@ async fn test_ldap_login_does_not_send_invite_for_existing_user( settings.public_proxy_url = PROXY_URL.into(); update_current_settings(&pool, settings).await.unwrap(); + // Point SMTP at a working mock so the returning-user guard is the only reason no invite + // is sent, not a missing SMTP configuration. + let smtp = configure_working_smtp(&pool).await; + make_test_admin(&pool, "login_admin_existing").await; let mut ldap_conn = super::LDAPConnection::create().await.unwrap(); @@ -4173,6 +4306,13 @@ async fn test_ldap_login_does_not_send_invite_for_existing_user( tokens.is_empty(), "Expected no enrollment token for a returning LDAP user, got {tokens:?}" ); + + assert_eq!( + smtp.message_count(), + 0, + "Expected no invite email for a returning LDAP user, got {:?}", + smtp.messages() + ); } /// With AD account status sync enabled, an incremental sync (LDAP authority) must mirror the diff --git a/crates/defguard_core/src/enterprise/posture/evaluation.rs b/crates/defguard_core/src/enterprise/posture/evaluation.rs index 2af9f73092..c3de7469cb 100644 --- a/crates/defguard_core/src/enterprise/posture/evaluation.rs +++ b/crates/defguard_core/src/enterprise/posture/evaluation.rs @@ -1,7 +1,7 @@ use defguard_common::{config::server_config, db::Id}; use defguard_proto::enterprise::posture::{ - BoolCheck, DevicePostureCheckRequest, DevicePostureData, Int32Check, StringCheck, - UnavailableReason, bool_check::Result as BoolResult, int32_check::Result as Int32Result, + BoolCheck, DevicePostureData, Int32Check, StringCheck, UnavailableReason, + bool_check::Result as BoolResult, int32_check::Result as Int32Result, string_check::Result as StringResult, }; use sqlx::PgPool; @@ -230,46 +230,33 @@ fn client_version_requirement<'a>( /// Returns [`PostureResult::Fail`] with accumulated [`FailureReason`]s otherwise. pub(crate) async fn validate_posture( pool: &PgPool, - request: &DevicePostureCheckRequest, + location_id: Id, + pubkey: &str, + posture_data: Option<&DevicePostureData>, ) -> Result { - debug!( - "Performing posture check for device {}: {:?}", - request.pubkey, request.device_posture_data - ); + debug!("Performing posture check for device {pubkey}: {posture_data:?}"); // If location has no assigned postures - pass immediately (no license required). - let posture_ids = DevicePostureLocation::find_by_location(pool, request.location_id).await?; + let posture_ids = DevicePostureLocation::find_by_location(pool, location_id).await?; if posture_ids.is_empty() { - debug!( - "No posture policies assigned to location {} — passing device {}", - request.location_id, request.pubkey - ); + debug!("No posture policies assigned to location {location_id} — passing device {pubkey}"); return Ok(PostureResult::Pass); } // In demo mode allow managing posture policies but never enforce them. if server_config().is_demo_mode { - debug!( - "Demo mode enabled - skipping posture enforcement for device {}", - request.pubkey - ); + debug!("Demo mode enabled - skipping posture enforcement for device {pubkey}"); return Ok(PostureResult::Pass); } // Policies exist - enforce the enterprise license. if !has_enterprise_access(Some(LicenseFeature::DevicePosture)) { - warn!( - "No active enterprise license - posture check aborted for device {}", - request.pubkey - ); + warn!("No active enterprise license - posture check aborted for device {pubkey}"); return Err(PostureCheckError::NoActiveEnterpriseLicense); } - let Some(data) = request.device_posture_data.as_ref() else { - info!( - "Missing posture data - posture check failed for device {}", - request.pubkey - ); + let Some(data) = posture_data else { + info!("Missing posture data - posture check failed for device {pubkey}"); return Ok(PostureResult::Fail(vec![FailureReason::MissingPostureData])); }; @@ -331,7 +318,7 @@ pub(crate) async fn validate_posture( } if all_failures.is_empty() { - info!("Posture check passed for device {}", request.pubkey); + info!("Posture check passed for device {pubkey}"); Ok(PostureResult::Pass) } else { Ok(PostureResult::Fail(all_failures)) diff --git a/crates/defguard_core/src/enterprise/posture/tests.rs b/crates/defguard_core/src/enterprise/posture/tests.rs index 19c6b3ebb3..d7bb5023d3 100644 --- a/crates/defguard_core/src/enterprise/posture/tests.rs +++ b/crates/defguard_core/src/enterprise/posture/tests.rs @@ -8,8 +8,8 @@ use defguard_common::db::{ setup_pool, }; use defguard_proto::enterprise::posture::{ - BoolCheck, DevicePostureCheckRequest, DevicePostureData, Int32Check, StringCheck, - UnavailableReason, bool_check, int32_check, string_check, + BoolCheck, DevicePostureData, Int32Check, StringCheck, UnavailableReason, bool_check, + int32_check, string_check, }; use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; @@ -29,6 +29,8 @@ use crate::{ // Test helpers // --------------------------------------------------------------------------- +const TEST_DEVICE_PUBKEY: &str = "testpubkey"; + fn set_enterprise_license() { let limits = LicenseLimits { users: 100, @@ -117,14 +119,6 @@ fn windows_posture_data() -> DevicePostureData { } } -fn make_request(location_id: Id, data: Option) -> DevicePostureCheckRequest { - DevicePostureCheckRequest { - location_id, - pubkey: "testpubkey".to_owned(), - device_posture_data: data, - } -} - /// Creates a Linux posture policy with no OS version requirement (Linux has no version list). async fn save_linux_policy( pool: &PgPool, @@ -409,7 +403,9 @@ async fn pass_no_posture_assigned(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", true))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", true)), ) .await .unwrap(); @@ -428,7 +424,9 @@ async fn pass_all_checks_met(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(windows_posture_data())), + location_id, + TEST_DEVICE_PUBKEY, + Some(&windows_posture_data()), ) .await .unwrap(); @@ -452,7 +450,7 @@ async fn pass_boundary_os_version_exact(_: PgPoolOptions, options: PgConnectOpti ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -474,7 +472,7 @@ async fn pass_macos_version(_: PgPoolOptions, options: PgConnectOptions) { ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -489,7 +487,7 @@ async fn fail_missing_posture_data(_: PgPoolOptions, options: PgConnectOptions) save_linux_policy(&pool, location_id, None, None, true).await; - let result = validate_posture(&pool, &make_request(location_id, None)) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, None) .await .unwrap(); @@ -550,7 +548,7 @@ async fn fail_os_version_too_old_regression(_: PgPoolOptions, options: PgConnect ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -576,7 +574,7 @@ async fn pass_known_client_version_meets_minimum(_: PgPoolOptions, options: PgCo let mut data = linux_posture_data("6.1.0", true); data.defguard_client_version = "2.1.2".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -600,7 +598,7 @@ async fn pass_mobile_client_version_uses_mobile_minimum( let mut data = android_posture_data("2026-01-01"); data.defguard_client_version = "1.7.0".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -624,7 +622,7 @@ async fn fail_desktop_client_version_uses_desktop_minimum( let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "1.7.0".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -645,7 +643,9 @@ async fn pass_antivirus_present(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(windows_posture_data())), + location_id, + TEST_DEVICE_PUBKEY, + Some(&windows_posture_data()), ) .await .unwrap(); @@ -663,7 +663,9 @@ async fn pass_ad_domain_joined(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(windows_posture_data())), + location_id, + TEST_DEVICE_PUBKEY, + Some(&windows_posture_data()), ) .await .unwrap(); @@ -682,7 +684,7 @@ async fn pass_security_update_within_max_age(_: PgPoolOptions, options: PgConnec let mut data = windows_posture_data(); data.windows_security_update_age_days = Some(int32_check_value(15)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -733,7 +735,7 @@ async fn pass_kernel_version_meets_minimum(_: PgPoolOptions, options: PgConnectO let mut data = linux_posture_data("22.04", true); data.linux_kernel_version = Some(string_check_value("6.8.0")); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -788,7 +790,7 @@ async fn pass_device_integrity_ok(_: PgPoolOptions, options: PgConnectOptions) { ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -839,7 +841,9 @@ async fn fail_os_not_in_policy(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", true))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", true)), ) .await .unwrap(); @@ -861,7 +865,9 @@ async fn fail_disk_encryption_required(_: PgPoolOptions, options: PgConnectOptio let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", false))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", false)), ) .await .unwrap(); @@ -889,7 +895,7 @@ async fn fail_os_version_too_old(_: PgPoolOptions, options: PgConnectOptions) { ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -917,7 +923,7 @@ async fn pass_os_version_same_major_lower_minor(_: PgPoolOptions, options: PgCon ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -938,7 +944,7 @@ async fn fail_client_version_too_old(_: PgPoolOptions, options: PgConnectOptions let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "2.1.2".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -959,7 +965,7 @@ async fn pass_accept_prerelease(_: PgPoolOptions, options: PgConnectOptions) { let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "2.1.0-alpha".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -980,7 +986,7 @@ async fn fail_prerelease_not_allowed(_: PgPoolOptions, options: PgConnectOptions let mut data = linux_posture_data("22.04", true); data.defguard_client_version = "1.6.0-beta1".to_owned(); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1002,7 +1008,7 @@ async fn fail_check_unavailable_detection_failed(_: PgPoolOptions, options: PgCo let mut data = linux_posture_data("22.04", true); data.disk_encryption = Some(bool_check_unavailable(UnavailableReason::DetectionFailed)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1029,7 +1035,7 @@ async fn fail_check_unavailable_insufficient_permissions( UnavailableReason::InsufficientPermissions, )); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1051,7 +1057,7 @@ async fn pass_check_not_applicable(_: PgPoolOptions, options: PgConnectOptions) let mut data = linux_posture_data("22.04", true); data.disk_encryption = Some(bool_check_unavailable(UnavailableReason::NotApplicable)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1132,7 +1138,9 @@ async fn fail_multi_policy_and_logic(_: PgPoolOptions, options: PgConnectOptions let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", false))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", false)), ) .await .unwrap(); @@ -1154,7 +1162,9 @@ async fn fail_enterprise_inactive(_: PgPoolOptions, options: PgConnectOptions) { let result = validate_posture( &pool, - &make_request(location_id, Some(linux_posture_data("22.04", true))), + location_id, + TEST_DEVICE_PUBKEY, + Some(&linux_posture_data("22.04", true)), ) .await; @@ -1175,7 +1185,7 @@ async fn fail_antivirus_required(_: PgPoolOptions, options: PgConnectOptions) { let mut data = windows_posture_data(); data.antivirus_present = Some(bool_check_value(false)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1197,7 +1207,7 @@ async fn fail_ad_domain_required(_: PgPoolOptions, options: PgConnectOptions) { let mut data = windows_posture_data(); data.windows_ad_domain_joined = Some(bool_check_value(false)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1219,7 +1229,7 @@ async fn fail_security_update_too_old(_: PgPoolOptions, options: PgConnectOption let mut data = windows_posture_data(); data.windows_security_update_age_days = Some(int32_check_value(90)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1274,7 +1284,7 @@ async fn fail_kernel_version_too_old(_: PgPoolOptions, options: PgConnectOptions let mut data = linux_posture_data("22.04", true); data.linux_kernel_version = Some(string_check_value("5.15.0")); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1332,7 +1342,7 @@ async fn fail_device_integrity_required(_: PgPoolOptions, options: PgConnectOpti ..Default::default() }; - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1354,7 +1364,7 @@ async fn fail_check_unavailable_unspecified(_: PgPoolOptions, options: PgConnect let mut data = linux_posture_data("22.04", true); data.disk_encryption = Some(bool_check_unavailable(UnavailableReason::Unspecified)); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1379,7 +1389,7 @@ async fn pass_android_security_patch_within_max_age(_: PgPoolOptions, options: P .to_string(); let data = android_posture_data(&patch_date); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1400,7 +1410,7 @@ async fn fail_android_security_patch_too_old(_: PgPoolOptions, options: PgConnec .to_string(); let data = android_posture_data(&patch_date); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); @@ -1421,7 +1431,7 @@ async fn fail_android_security_patch_unparseable(_: PgPoolOptions, options: PgCo let data = android_posture_data("not-a-date"); - let result = validate_posture(&pool, &make_request(location_id, Some(data))) + let result = validate_posture(&pool, location_id, TEST_DEVICE_PUBKEY, Some(&data)) .await .unwrap(); diff --git a/crates/defguard_core/src/enterprise/snat/handlers.rs b/crates/defguard_core/src/enterprise/snat/handlers.rs index c7035145f7..b41f34b1e5 100644 --- a/crates/defguard_core/src/enterprise/snat/handlers.rs +++ b/crates/defguard_core/src/enterprise/snat/handlers.rs @@ -22,28 +22,23 @@ use crate::{ error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, grpc::GatewayCommand, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; -/// List all SNAT bindings for a WireGuard location -/// -/// # Returns -/// - `Vec>` object -/// -/// - `WebError` if error occurs +/// List SNAT bindings in a location #[utoipa::path( get, path = "/api/v1/network/{location_id}/snat", tag = "SNAT", params( - ("location_id" = Id, Path, description = "WireGuard location ID") + ("location_id" = i64, Path, description = "ID of the location.") ), responses( - (status = 200, description = "List of SNAT bindings", body = [UserSnatBinding]), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - Admin role required"), - (status = 404, description = "Not found - location does not exist"), - (status = 500, description = "Internal server error") + (status = 200, description = "All SNAT bindings in the location.", body = [UserSnatBinding]), + (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 = "Location not found.", body = ApiErrorResponse, example = json!({"msg": "Location 1 not found"})), + (status = 500, description = "Unable to list SNAT bindings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -73,37 +68,30 @@ pub async fn list_snat_bindings( #[derive(Debug, Deserialize, Serialize, ToSchema)] pub struct NewUserSnatBinding { - /// User ID to bind to the public IP + /// ID of the user bound to the public IP address. pub user_id: Id, - /// Public IP address for SNAT + /// Public IP address used for SNAT. #[schema(value_type = String)] pub public_ip: IpAddr, } -/// Create a new SNAT binding for a user in a WireGuard location -/// -/// Create snat binding basing on `NewUserSnatBinding` object. -/// -/// # Returns -/// - `UserSnatBinding` object -/// -/// - `WebError` if error occurs +/// Create a SNAT binding for a user in a location #[utoipa::path( post, path = "/api/v1/network/{location_id}/snat", tag = "SNAT", params( - ("location_id" = Id, Path, description = "WireGuard location ID") + ("location_id" = i64, Path, description = "ID of the location.") ), request_body = NewUserSnatBinding, responses( - (status = 201, description = "SNAT binding created successfully", body = UserSnatBinding), - (status = 400, description = "Bad request - Invalid input data"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - Admin role required"), - (status = 404, description = "Not found - location or user does not exist"), - (status = 409, description = "Conflict - Binding already exists"), - (status = 500, description = "Internal server error") + (status = 201, description = "SNAT binding created.", body = UserSnatBinding), + (status = 400, description = "Invalid request data.", body = ApiErrorResponse, example = json!({"msg": "Invalid request data"})), + (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 = "Location or user not found.", body = ApiErrorResponse, example = json!({"msg": "Location 1 not found"})), + (status = 409, description = "A SNAT binding for this user in this location already exists.", body = ApiErrorResponse, example = json!({"msg": "Binding already exists"})), + (status = 500, description = "Unable to create SNAT binding.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -170,35 +158,28 @@ pub async fn create_snat_binding( #[derive(Debug, Deserialize, Serialize, ToSchema)] pub struct EditUserSnatBinding { - /// New public IP address for SNAT + /// New public IP address used for SNAT. #[schema(value_type = String)] pub public_ip: IpAddr, } -/// Modify an existing SNAT binding for a user in a WireGuard location -/// -/// Modify an **existing** SNAT binding basing on `EditUserSnatBinding` object. -/// -/// # Returns -/// - `UserSnatBinding` object -/// -/// - `WebError` if error occurs +/// Update a SNAT binding #[utoipa::path( put, path = "/api/v1/network/{location_id}/snat/{user_id}", tag = "SNAT", params( - ("location_id" = Id, Path, description = "WireGuard location ID"), - ("user_id" = Id, Path, description = "User ID") + ("location_id" = i64, Path, description = "ID of the location."), + ("user_id" = i64, Path, description = "ID of the user.") ), request_body = EditUserSnatBinding, responses( - (status = 200, description = "SNAT binding updated successfully", body = UserSnatBinding), - (status = 400, description = "Bad request - Invalid input data"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - Admin role required"), - (status = 404, description = "Not found - SNAT binding does not exist"), - (status = 500, description = "Internal server error") + (status = 200, description = "SNAT binding updated.", body = UserSnatBinding), + (status = 400, description = "Invalid request data.", body = ApiErrorResponse, example = json!({"msg": "Invalid request data"})), + (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 = "Location, user or SNAT binding not found.", body = ApiErrorResponse, example = json!({"msg": "Binding not found"})), + (status = 500, description = "Unable to update SNAT binding.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -267,28 +248,21 @@ pub async fn modify_snat_binding( Ok(ApiResponse::json(snat_binding, StatusCode::OK)) } -/// Delete an existing SNAT binding for a user in a WireGuard location -/// -/// Delete an existing SNAT binding basing on `location_id` and `user_id`. -/// -/// # Returns -/// - empty JSON -/// -/// - `WebError` if error occurs +/// Delete a SNAT binding #[utoipa::path( delete, path = "/api/v1/network/{location_id}/snat/{user_id}", tag = "SNAT", params( - ("location_id" = Id, Path, description = "WireGuard location ID"), - ("user_id" = Id, Path, description = "User ID") + ("location_id" = i64, Path, description = "ID of the location."), + ("user_id" = i64, Path, description = "ID of the user.") ), responses( - (status = 200, description = "SNAT binding deleted successfully"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - Admin role required"), - (status = 404, description = "Not found - SNAT binding does not exist"), - (status = 500, description = "Internal server error") + (status = 200, description = "SNAT binding deleted."), + (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 = "Location, user or SNAT binding not found.", body = ApiErrorResponse, example = json!({"msg": "Binding not found"})), + (status = 500, description = "Unable to delete SNAT binding.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 5b73fcc7eb..cff453225c 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -10,7 +10,6 @@ use defguard_common::{ use defguard_static_ip::error::StaticIpError; use thiserror::Error; use tokio::sync::mpsc::error::SendError; -use utoipa::ToSchema; use crate::{ auth::failed_login::FailedLoginError, @@ -28,7 +27,7 @@ use crate::{ }; /// Represents kinds of error that occurred -#[derive(Debug, Error, ToSchema)] +#[derive(Debug, Error)] pub enum WebError { #[error("GRPC error: {0}")] Grpc(String), @@ -65,46 +64,34 @@ pub enum WebError { #[error("Public key already exists {0}")] PubkeyExists(String), #[error("HTTP error: {0}")] - #[schema(value_type=Object)] Http(StatusCode), #[error(transparent)] - #[schema(value_type=Object)] TooManyLoginAttempts(#[from] FailedLoginError), #[error("Bad request: {0}")] BadRequest(String), #[error(transparent)] - #[schema(value_type=Object)] TemplateError(TemplateError), #[error("License error: {0}")] - #[schema(value_type=Object)] LicenseError(#[from] LicenseError), #[error("Failed to get client IP address")] ClientIpError, #[error("ACL error: {0}")] - #[schema(value_type=Object)] AclError(#[from] AclError), #[error("Firewall config error: {0}")] - #[schema(value_type=Object)] FirewallError(#[from] FirewallError), #[error("API event channel error: {0}")] - #[schema(value_type=Object)] ApiEventChannelError(#[from] SendError), #[error("Activity log stream error: {0}")] - #[schema(value_type=Object)] ActivityLogStreamError(#[from] ActivityLogStreamError), #[error(transparent)] - #[schema(value_type=Object)] CertificateError(#[from] defguard_certs::CertificateError), #[error(transparent)] - #[schema(value_type=Object)] UrlParseError(#[from] UrlParseError), #[error(transparent)] - #[schema(value_type=Object)] StaticIpError(#[from] StaticIpError), #[error("Network full: {0}")] NetworkFull(String), #[error(transparent)] - #[schema(value_type=Object)] IpNetwork(#[from] ipnetwork::IpNetworkError), #[error("cert_pem is required for own_cert")] CertMissingCertPem, @@ -227,7 +214,6 @@ impl From for WebError { match err { SettingsValidationError::CannotEnableGatewayNotifications | SettingsValidationError::CannotEnableLdapRemoteEnrollment - | SettingsValidationError::CannotEnableLdapRemoteEnrollmentInvite | SettingsValidationError::CannotEnableLdap | SettingsValidationError::InvalidDefguardUrl(_) => Self::BadRequest(err.to_string()), } diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 4fb8a2855b..776bcb033e 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -17,7 +17,7 @@ use crate::{ activity_log_stream::ActivityLogStream, api_tokens::ApiToken, device_posture::{DevicePosture, DevicePostureSnapshot}, - enterprise_settings::EnterpriseSettings, + enterprise_settings::EnterpriseSettingsInfo, openid_provider::OpenIdProvider, snat::UserSnatBinding, }, @@ -251,8 +251,8 @@ pub enum ApiEventType { }, SettingsDefaultBrandingRestored, EnterpriseSettingsUpdated { - before: EnterpriseSettings, - after: EnterpriseSettings, + before: EnterpriseSettingsInfo, + after: EnterpriseSettingsInfo, }, GroupsBulkAssigned { users: Vec>, @@ -476,6 +476,24 @@ pub enum DesktopClientMfaEvent { }, } +#[derive(Clone, Debug, PartialEq)] +pub enum GatewayConnectionEvent { + Connected { + gateway_id: Id, + gateway_name: String, + }, + Disconnected { + gateway_id: Id, + gateway_name: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ProxyConnectionEvent { + Connected { proxy_id: Id, proxy_name: String }, + Disconnected { proxy_id: Id, proxy_name: String }, +} + #[derive(Debug, PartialEq, EnumCount)] #[allow(clippy::large_enum_variant)] pub enum LdapSyncEventType { @@ -501,6 +519,7 @@ pub enum LdapSyncEventType { pub enum DirectorySyncEventType { UserCreated { user: User }, UserDeleted { user: User }, + UserModified { before: User, after: User }, UserEnabled { user: User }, UserDisabled { user: User }, GroupCreated { group: Group }, diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 283897785f..cc5ce2dc8b 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -10,7 +10,7 @@ use defguard_common::{ config::server_config, db::{ Id, - models::{Settings, WireguardNetwork, wireguard::ServiceLocationMode}, + models::{Settings, User, WireguardNetwork, wireguard::ServiceLocationMode}, }, types::UrlParseError, }; @@ -24,7 +24,10 @@ use crate::{ enterprise::{ LicenseFeature, db::models::{ - enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, + enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, resolve_client_traffic_policy, + }, + group_client_traffic_policy::GroupClientTrafficPolicy, openid_provider::OpenIdProvider, }, has_enterprise_access, is_business_license_active, @@ -161,15 +164,36 @@ pub struct InstanceInfo { client_traffic_policy: ClientTrafficPolicy, enterprise_enabled: bool, openid_display_name: Option, + disable_tunnels: bool, +} + +#[derive(Debug, thiserror::Error)] +/// Errors that can occur while building client instance information. +pub enum InstanceInfoBuildError { + #[error("failed to load enterprise settings: {0}")] + Database(#[from] sqlx::Error), + #[error("failed to parse instance URL: {0}")] + UrlParse(#[from] UrlParseError), } impl InstanceInfo { - pub fn new>( - settings: Settings, - username: S, - enterprise_settings: &EnterpriseSettings, + /// Builds client instance information with the effective user traffic policy. + pub async fn build( + pool: &PgPool, + settings: &Settings, + user: &User, openid_provider: Option>, - ) -> Result { + ) -> Result { + let enterprise_settings = EnterpriseSettings::get(pool).await?; + let client_traffic_policy = if is_business_license_active() { + let group_policies = GroupClientTrafficPolicy::find_by_user_id(pool, user.id) + .await? + .into_iter() + .map(|policy| policy.client_traffic_policy); + resolve_client_traffic_policy(enterprise_settings.client_traffic_policy, group_policies) + } else { + enterprise_settings.client_traffic_policy + }; let openid_display_name = openid_provider .as_ref() .map(|provider| provider.display_name.clone()) @@ -178,13 +202,14 @@ impl InstanceInfo { let proxy_url = settings.proxy_public_url()?; Ok(Self { id: settings.uuid, - name: settings.instance_name, + name: settings.instance_name.clone(), url, proxy_url, - username: username.into(), - client_traffic_policy: enterprise_settings.client_traffic_policy, + username: user.username.clone(), + client_traffic_policy, enterprise_enabled: is_business_license_active(), openid_display_name, + disable_tunnels: enterprise_settings.disable_tunnels, }) } } @@ -204,6 +229,7 @@ impl From for defguard_proto::client_types::InstanceInfo { client_traffic_policy: Some(instance.client_traffic_policy as i32), enterprise_enabled: instance.enterprise_enabled, openid_display_name: instance.openid_display_name, + disable_tunnels: Some(instance.disable_tunnels), } } } diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 2e54cbb8f7..323e8dceb0 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -1,5 +1,6 @@ use std::{ collections::HashMap, + net::IpAddr, sync::{Arc, RwLock}, time::Duration, }; @@ -12,6 +13,7 @@ use defguard_common::{ models::{ BiometricAuth, BiometricChallenge, Device, User, WireguardNetwork, device::{DeviceNetworkInfo, WireguardNetworkDevice}, + polling_token::PollingToken, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, wireguard::LocationMfaMode, }, @@ -211,7 +213,7 @@ impl ClientMfaServer { })?; // validate user is allowed to connect to a given location - Self::validate_location_access(&self.pool, &location, &user_info).await?; + Self::validate_location_access(&self.pool, &location, &device, &user_info).await?; // Evaluate postures if necessary. let has_postures = location.has_postures(&self.pool).await.map_err(|err| { @@ -222,12 +224,14 @@ impl ClientMfaServer { Status::internal("unexpected error") })?; if has_postures { - let posture_request = DevicePostureCheckRequest { - location_id: location.id, - pubkey: request.pubkey.clone(), - device_posture_data: request.posture_data.clone(), - }; - let posture_result = match validate_posture(&self.pool, &posture_request).await { + let posture_result = match validate_posture( + &self.pool, + location.id, + &request.pubkey, + request.posture_data.as_ref(), + ) + .await + { Ok(result) => result, Err(PostureCheckError::NoActiveEnterpriseLicense) => { debug!("No active license - skipping posture check for location {location}"); @@ -259,6 +263,8 @@ impl ClientMfaServer { }) { error!("Failed to emit DevicePostureCheckFailed event: {err}"); } + self.revoke_rejected_posture_sessions(&location, &user, &device, ip) + .await?; return Ok(ClientMfaStartOutcome::Rejected { failed_checks }); } PostureResult::Pass => { @@ -372,15 +378,22 @@ impl ClientMfaServer { error!("Database error: {err}"); Status::internal("database error") })?; - mfa_code_mail(&user.email, &mut transaction, &user.first_name, &code, None) - .await - .map_err(|err| { - error!( - "Failed to send email MFA code for user {}: {err}", - user.username - ); - Status::internal("unexpected error") - })?; + mfa_code_mail( + &user.email, + &mut transaction, + &user.first_name, + &code, + None, + true, + ) + .await + .map_err(|err| { + error!( + "Failed to send email MFA code for user {}: {err}", + user.username + ); + Status::internal("unexpected error") + })?; } MfaMethod::Oidc => { if !is_business_license_active() { @@ -460,10 +473,11 @@ impl ClientMfaServer { })) } - /// Checks if given user is allowed to access a location + /// Checks whether the user and device are allowed to access a location. async fn validate_location_access( pool: &PgPool, location: &WireguardNetwork, + device: &Device, user_info: &UserInfo, ) -> Result<(), Status> { // acquire connection @@ -492,10 +506,26 @@ impl ClientMfaServer { {allowed_groups:?}", user_info.username, user_info.groups ); - Err(Status::unauthenticated("unauthorized")) - } else { - Ok(()) + return Err(Status::unauthenticated("unauthorized")); + } + + let assignment = WireguardNetworkDevice::find(&mut *conn, device.id, location.id) + .await + .map_err(|err| { + error!( + "Failed to validate assignment for device {device} in location {location}: \ + {err}" + ); + Status::internal("unexpected error") + })?; + if assignment.is_none() { + error!("Device {device} is not assigned to location {location}"); + return Err(Status::permission_denied( + "device is not assigned to location", + )); } + + Ok(()) } #[instrument(skip_all)] @@ -861,15 +891,43 @@ impl ClientMfaServer { /// with a generated preshared key. Returns a typed outcome so the caller can /// map it to the appropriate `CoreResponse` payload without needing to know about /// session internals. + /// + /// A location with no postures assigned is approved with an *empty* preshared key and no + /// session, since its peers are handed to the gateway without one. pub async fn handle_posture_check( &mut self, request: DevicePostureCheckRequest, + info: Option, ) -> Result { debug!( "Handling posture check for device pubkey={} location_id={}", request.pubkey, request.location_id ); + // Authenticate the caller before touching anything else. + // Validated first so that an unauthenticated caller cannot use the error codes below to + // probe which locations exist or which public keys are enrolled. + let Some(token) = request.token.as_deref().filter(|token| !token.is_empty()) else { + error!( + "Posture check: missing polling token for pubkey {}", + request.pubkey + ); + return Err(Status::unauthenticated("missing token")); + }; + let polling_token = PollingToken::find(&self.pool, token) + .await + .map_err(|err| { + error!("Posture check: failed to look up polling token: {err}"); + Status::internal("unexpected error") + })? + .ok_or_else(|| { + error!( + "Posture check: unknown polling token for claimed pubkey {}", + request.pubkey + ); + Status::unauthenticated("invalid token") + })?; + // Look up location, device, and user. let Ok(Some(location)) = WireguardNetwork::find_by_id(&self.pool, request.location_id).await @@ -893,15 +951,14 @@ impl ClientMfaServer { return Err(Status::invalid_argument("device not found")); }; - if !location.has_postures(&self.pool).await.map_err(|err| { - error!("Posture check: failed to fetch postures for location {location}: {err}"); - Status::internal("unexpected error") - })? { + // Make sure caller owns the device. + if polling_token.device_id != device.id { error!( - "Posture check: location {location} has no postures defined but device {} requested posture check", - device.wireguard_pubkey + "Posture check: polling token belongs to device {} but request claims pubkey {} \ + (device {})", + polling_token.device_id, request.pubkey, device.id ); - return Err(Status::invalid_argument("location does not use postures")); + return Err(Status::unauthenticated("token does not match device")); } let Ok(Some(user)) = User::find_by_id(&self.pool, device.user_id).await else { @@ -927,10 +984,34 @@ impl ClientMfaServer { ); Status::internal("unexpected error") })?; - Self::validate_location_access(&self.pool, &location, &user_info).await?; + Self::validate_location_access(&self.pool, &location, &device, &user_info).await?; - // Evaluate posture. - let posture_result = match validate_posture(&self.pool, &request).await { + // If location has no postures assigned, approve the posture check returning empty string as PSK. + // This way the client can recover on it's own if the admin unassigns PCs from a location and the client + // didn't get the config yet. Matters especially for service locations where the client UI may not be + // running and therefore config is not being polled. + if !location.has_postures(&self.pool).await.map_err(|err| { + error!("Posture check: failed to fetch postures for location {location}: {err}"); + Status::internal("unexpected error") + })? { + info!( + "Posture check: location {location} has no postures assigned, approving device {} \ + with an empty preshared key without creating a session", + device.wireguard_pubkey + ); + return Ok(PostureCheckOutcome::Approved { + preshared_key: String::new(), + }); + } + + let posture_result = match validate_posture( + &self.pool, + location.id, + &device.wireguard_pubkey, + request.device_posture_data.as_ref(), + ) + .await + { Ok(result) => result, Err(PostureCheckError::NoActiveEnterpriseLicense) => { debug!("No active license - skipping posture check for location {location}"); @@ -942,12 +1023,46 @@ impl ClientMfaServer { } }; + let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; + let context = + BidiRequestContext::new(user.id, user.username.clone(), ip, device.name.clone()); + // Posture check failed - return payload with reasons if let PostureResult::Fail(reasons) = posture_result { - let failed_checks = reasons.iter().map(ToString::to_string).collect(); + let failed_checks = reasons.iter().map(ToString::to_string).collect::>(); + if let Err(err) = self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::PostureCheckFailed { + device: device.clone(), + location: location.clone(), + device_posture_data: request.device_posture_data.clone(), + failed_checks: failed_checks.clone(), + }, + )), + }) { + error!("Failed to emit DevicePostureCheckFailed event: {err}"); + } + + self.revoke_rejected_posture_sessions(&location, &user, &device, ip) + .await?; + return Ok(PostureCheckOutcome::Rejected { failed_checks }); } + if let Err(err) = self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::PostureCheckPassed { + device: device.clone(), + location: location.clone(), + device_posture_data: request.device_posture_data.clone(), + }, + )), + }) { + error!("Failed to emit DevicePostureCheckPassed event: {err}"); + } + // Posture check succeeded - create a vpn session let key = WireguardNetwork::genkey(); @@ -1000,6 +1115,102 @@ impl ClientMfaServer { }) } + /// Revokes sessions after a definitive posture rejection and publishes resulting events. + async fn revoke_rejected_posture_sessions( + &self, + location: &WireguardNetwork, + user: &User, + device: &Device, + ip: IpAddr, + ) -> Result<(), Status> { + let mut transaction = self.pool.begin().await.map_err(|err| { + error!("Failed to begin transaction for posture session rejection: {err}"); + Status::internal("unexpected error") + })?; + let disconnect_events = self + .revoke_active_posture_sessions(&mut transaction, location, user, device, ip) + .await?; + transaction.commit().await.map_err(|err| { + error!("Failed to commit rejected posture session cleanup: {err}"); + Status::internal("unexpected error") + })?; + + let event = GatewayCommand::VpnSessionDeauthorized(location.id, device.clone()); + if let Err(err) = self.gateway_tx.send(event) { + error!("Error sending WireGuard event: {err}"); + } + for event in disconnect_events { + if let Err(err) = self.emit_event(event) { + error!("Failed to emit VPN session disconnect event: {err}"); + } + } + + Ok(()) + } + + /// Marks active posture sessions disconnected and returns their audit events. + async fn revoke_active_posture_sessions( + &self, + conn: &mut PgConnection, + location: &WireguardNetwork, + user: &User, + device: &Device, + ip: IpAddr, + ) -> Result, Status> { + let active_sessions = VpnClientSession::get_all_active_device_sessions_in_location( + &mut *conn, + location.id, + device.id, + ) + .await + .map_err(|err| { + error!( + "Failed to fetch active VPN sessions for device {device} in location {location}: {err}" + ); + Status::internal("unexpected error") + })?; + if !active_sessions.is_empty() { + info!( + "Posture check rejected device {device} in location {location}. Disconnecting {} active sessions", + active_sessions.len() + ); + } + + let mut events = Vec::new(); + for mut session in active_sessions { + let is_connected = session.state == VpnClientSessionState::Connected; + let is_mfa_session = session.mfa_method.is_some(); + let disconnect_timestamp = Utc::now().naive_utc(); + session.disconnected_at = Some(disconnect_timestamp); + session.state = VpnClientSessionState::Disconnected; + session.save(&mut *conn).await.map_err(|err| { + error!("Failed to revoke rejected posture session {session:?}: {err}"); + Status::internal("unexpected error") + })?; + + if is_connected { + events.push(BidiStreamEvent { + context: BidiRequestContext { + timestamp: disconnect_timestamp, + user_id: user.id, + username: user.username.clone(), + ip: Some(ip), + device_name: format!("{device}"), + }, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Disconnected { + location: location.clone(), + device: device.clone(), + is_mfa_session, + }, + )), + }); + } + } + + Ok(events) + } + /// Helper used to close all existing active sessions while creating a new MFA session /// and send relevant gateway updates async fn create_new_session( @@ -1158,14 +1369,19 @@ mod tests { models::{ Device, DeviceType, User, WireguardNetwork, device::WireguardNetworkDevice, + polling_token::PollingToken, settings::initialize_current_settings, vpn_client_session::{VpnClientMfaMethod, VpnClientSession, VpnClientSessionState}, wireguard::{LocationMfaMode, ServiceLocationMode}, }, setup_pool, }; - use defguard_proto::enterprise::posture::{ - BoolCheck, DevicePostureCheckRequest, DevicePostureData, bool_check, + use defguard_proto::{ + client_types::{ClientMfaStartRequest, MfaMethod}, + enterprise::posture::{ + BoolCheck, DevicePostureCheckRequest, DevicePostureData, bool_check, + }, + proxy::DeviceInfo, }; use ipnetwork::IpNetwork; use sqlx::{ @@ -1190,6 +1406,16 @@ mod tests { const REPLACEMENT_MFA_PRESHARED_KEY: &str = "replacement-mfa-psk"; const NEW_MFA_PRESHARED_KEY: &str = "new-psk"; + const DEVICE_INFO_IP: &str = "10.0.0.7"; + + /// The `DeviceInfo` the proxy attaches to every bidi request; audit events are built from it. + fn device_info() -> Option { + Some(DeviceInfo { + ip_address: DEVICE_INFO_IP.to_owned(), + user_agent: Some("defguard-client/1.6.0".to_owned()), + ..Default::default() + }) + } #[sqlx::test] async fn test_posture_check_success_emits_vpn_session_authorized_event( @@ -1206,14 +1432,19 @@ mod tests { let user = create_user(&pool).await; let device = create_device(&pool, user.id).await; attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; let (mut server, _event_rx, mut gateway_rx) = make_server(pool.clone()); let outcome = server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(token.clone()), + }, + device_info(), + ) .await .expect("posture check should pass"); let preshared_key = match outcome { @@ -1282,14 +1513,19 @@ mod tests { .save(&pool) .await .expect("failed to create previous posture session"); + let token = create_polling_token(&pool, device.id).await; let (mut server, mut event_rx, mut gateway_rx) = make_server(pool.clone()); server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey.clone(), - device_posture_data: Some(passing_linux_posture_data()), - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(token.clone()), + }, + device_info(), + ) .await .expect("replacement posture check should pass"); @@ -1315,6 +1551,18 @@ mod tests { other => panic!("unexpected gateway event: {other:?}"), } + // the passing posture evaluation is audited first + let event = event_rx + .try_recv() + .expect("expected posture check passed audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::PostureCheckPassed { .. } => {} + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + // replacing a connected posture-only session emits the unified session // superseded audit event, flagged as a non-MFA session let event = event_rx @@ -1343,6 +1591,174 @@ mod tests { assert_eq!(old_session.state, VpnClientSessionState::Disconnected); } + /// A caller with no token must be refused. Without this, knowing a device's public key is + /// enough to mint a preshared key for it. + #[sqlx::test] + async fn test_posture_check_requires_a_token(_: PgPoolOptions, options: PgConnectOptions) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).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; + let (mut server, _, mut gateway_rx) = make_server(pool.clone()); + + for token in [None, Some(String::new())] { + let err = server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token, + }, + device_info(), + ) + .await; + let err = match err { + Ok(_) => panic!("posture check without a token must be refused"), + Err(err) => err, + }; + assert_eq!(err.code(), Code::Unauthenticated); + } + + // No session may be created and the gateway must not be touched. + assert!( + VpnClientSession::get_all_active_device_sessions_in_location( + &pool, + location.id, + device.id + ) + .await + .expect("failed to query sessions") + .is_empty() + ); + assert!(gateway_rx.try_recv().is_err()); + } + + /// An unknown token must be refused, so tokens cannot be guessed or replayed after rotation. + #[sqlx::test] + async fn test_posture_check_rejects_unknown_token(_: PgPoolOptions, options: PgConnectOptions) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).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; + let (mut server, _, _) = make_server(pool); + + let err = server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some("not-a-real-token".to_owned()), + }, + device_info(), + ) + .await; + let err = match err { + Ok(_) => panic!("posture check with an unknown token must be refused"), + Err(err) => err, + }; + + assert_eq!(err.code(), Code::Unauthenticated); + } + + /// Regression test for the session-hijack denial of service: holding a valid token for *one* + /// device must not allow authorizing — and thereby superseding the live session of — another. + #[sqlx::test] + async fn test_posture_check_rejects_token_belonging_to_another_device( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).await; + let user = create_user(&pool).await; + + let victim = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, victim.id).await; + + // The attacker is a legitimately enrolled device with a token of its own. + let attacker = Device::new( + "attacker-device".to_owned(), + "attacker-pubkey".to_owned(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .expect("failed to create attacker device"); + let attacker_token = create_polling_token(&pool, attacker.id).await; + + // The victim holds a live session. + let mut victim_session = VpnClientSession::new( + location.id, + user.id, + victim.id, + Some(Utc::now().naive_utc()), + None, + ); + victim_session.preshared_key = Some("victim-psk".to_owned()); + victim_session.state = VpnClientSessionState::Connected; + let victim_session = victim_session + .save(&pool) + .await + .expect("failed to create victim session"); + + let (mut server, _, mut gateway_rx) = make_server(pool.clone()); + + // Attacker presents its own valid token but claims the victim's public key. + let err = server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: victim.wireguard_pubkey.clone(), + device_posture_data: Some(passing_linux_posture_data()), + token: Some(attacker_token), + }, + device_info(), + ) + .await; + let err = match err { + Ok(_) => panic!("a token from another device must not authorize this one"), + Err(err) => err, + }; + assert_eq!(err.code(), Code::Unauthenticated); + + // The victim's session must survive untouched, and the gateway must see nothing. + let victim_session = VpnClientSession::find_by_id(&pool, victim_session.id) + .await + .expect("failed to reload victim session") + .expect("victim session should still exist"); + assert_eq!(victim_session.state, VpnClientSessionState::Connected); + assert_eq!( + victim_session.preshared_key.as_deref(), + Some("victim-psk"), + "the victim's preshared key must not have been rotated" + ); + assert!( + gateway_rx.try_recv().is_err(), + "no peer delete or re-create may be sent to the gateway" + ); + } + #[sqlx::test] async fn test_posture_check_rejects_mfa_enabled_location( _: PgPoolOptions, @@ -1350,14 +1766,22 @@ mod tests { ) { let pool = setup_pool(options).await; let location = create_mfa_location(&pool).await; + // A valid token is needed to get past authentication and reach the check under test. + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + let token = create_polling_token(&pool, device.id).await; let (mut server, _, _) = make_server(pool); let err = match server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: "irrelevant".to_owned(), - device_posture_data: None, - }) + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: "irrelevant".to_owned(), + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) .await { Ok(_) => panic!("MFA-enabled location should reject posture-only flow"), @@ -1367,31 +1791,486 @@ mod tests { assert_eq!(err.code(), Code::InvalidArgument); } + /// A location with no postures assigned hands its peers to the gateway without a preshared + /// key, so the only answer that lets a client connect is an empty one. Approving instead of + /// erroring is what allows a service location whose cached config still demands a posture check + /// to recover after an admin unassigns the last posture. #[sqlx::test] - async fn test_posture_check_rejects_location_without_postures( + async fn test_posture_check_without_postures_approves_with_empty_preshared_key( _: PgPoolOptions, options: PgConnectOptions, ) { + set_enterprise_license(); let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); let location = create_non_mfa_location(&pool).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; - let (mut server, _, _) = make_server(pool); + let token = create_polling_token(&pool, device.id).await; + let (mut server, _event_rx, mut gateway_rx) = make_server(pool.clone()); - let err = match server - .handle_posture_check(DevicePostureCheckRequest { - location_id: location.id, - pubkey: device.wireguard_pubkey, - device_posture_data: None, - }) + let outcome = server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) + .await + .expect("location without postures should be approved"); + + match outcome { + super::PostureCheckOutcome::Approved { preshared_key } => assert!( + preshared_key.is_empty(), + "a location without postures must not hand out a preshared key" + ), + super::PostureCheckOutcome::Rejected { failed_checks } => { + panic!("posture check unexpectedly failed: {failed_checks:?}") + } + } + + // No session may be created and the gateway must not be touched. + assert!( + VpnClientSession::get_all_active_device_sessions_in_location( + &pool, + location.id, + device.id + ) + .await + .expect("failed to query sessions") + .is_empty(), + "no VPN session may be created when a location has no postures" + ); + assert!( + gateway_rx.try_recv().is_err(), + "no gateway command may be sent when a location has no postures" + ); + } + + #[sqlx::test] + async fn test_posture_check_without_postures_rejects_device_not_assigned_to_location( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + let user = create_user(&pool).await; + let device = create_device(&pool, user.id).await; + let token = create_polling_token(&pool, device.id).await; + let (mut server, mut event_rx, mut gateway_rx) = make_server(pool); + + let status = match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey, + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) .await { - Ok(_) => panic!("location without postures should reject posture-only flow"), - Err(err) => err, + Ok(_) => panic!("a device not assigned to the location must not be approved"), + Err(status) => status, }; - assert_eq!(err.code(), Code::InvalidArgument); + assert_eq!(status.code(), Code::PermissionDenied); + assert_eq!(status.message(), "device is not assigned to location"); + assert!(event_rx.try_recv().is_err()); + assert!(gateway_rx.try_recv().is_err()); + } + + /// The empty-preshared-key approval must not outrank the access checks: deactivating a user has + /// to stop their devices from getting anything that reads as approval, even on a location with + /// no postures where the approval grants nothing by itself. + #[sqlx::test] + async fn test_posture_check_without_postures_still_rejects_inactive_user( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + let mut user = create_user(&pool).await; + user.is_active = false; + user.save(&pool).await.expect("failed to deactivate user"); + let device = create_device(&pool, user.id).await; + attach_device_to_location(&pool, location.id, device.id).await; + let token = create_polling_token(&pool, device.id).await; + let (mut server, _event_rx, _gateway_rx) = make_server(pool.clone()); + + let status = match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: None, + token: Some(token), + }, + device_info(), + ) + .await + { + Ok(super::PostureCheckOutcome::Approved { .. }) => { + panic!("an inactive user must not be approved, even without postures") + } + Ok(super::PostureCheckOutcome::Rejected { .. }) => { + panic!("expected an inactive-user error, not a posture rejection") + } + Err(status) => status, + }; + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert_eq!(status.message(), "user is inactive"); + } + + /// A passing posture evaluation must be auditable, so an operator can see that a headless + /// service location connected and why. + #[sqlx::test] + async fn test_posture_check_pass_emits_posture_check_passed_event( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).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; + let token = create_polling_token(&pool, device.id).await; + let (mut server, mut event_rx, _gateway_rx) = make_server(pool.clone()); + + let posture_data = passing_linux_posture_data(); + match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(posture_data.clone()), + token: Some(token), + }, + device_info(), + ) + .await + .expect("posture check should pass") + { + super::PostureCheckOutcome::Approved { preshared_key } => { + assert!(!preshared_key.is_empty()); + } + super::PostureCheckOutcome::Rejected { failed_checks } => { + panic!("posture check unexpectedly failed: {failed_checks:?}") + } + } + + let event = event_rx + .try_recv() + .expect("expected posture check passed audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::PostureCheckPassed { + device: event_device, + location: event_location, + device_posture_data, + } => { + assert_eq!(event_device.id, device.id); + assert_eq!(event_location.id, location.id); + assert_eq!(device_posture_data, Some(posture_data)); + } + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + assert_eq!(event.context.user_id, user.id); + assert_eq!(event.context.username, user.username); + assert_eq!(event.context.ip, Some(DEVICE_INFO_IP.parse().unwrap())); + } + + /// A failing posture evaluation must be auditable and revoke an existing posture session. + #[sqlx::test] + async fn test_posture_check_failure_revokes_active_session( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).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; + let mut active_session = VpnClientSession::new( + location.id, + user.id, + device.id, + Some(Utc::now().naive_utc()), + None, + ); + active_session.preshared_key = Some("active-posture-psk".to_owned()); + active_session.state = VpnClientSessionState::Connected; + let active_session = active_session + .save(&pool) + .await + .expect("failed to create active posture session"); + let token = create_polling_token(&pool, device.id).await; + let (mut server, mut event_rx, mut gateway_rx) = make_server(pool.clone()); + + // the policy requires disk encryption + let posture_data = DevicePostureData { + disk_encryption: Some(BoolCheck { + result: Some(bool_check::Result::Value(false)), + }), + ..passing_linux_posture_data() + }; + let rejected_checks = match server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(posture_data.clone()), + token: Some(token), + }, + device_info(), + ) + .await + .expect("posture check should complete") + { + super::PostureCheckOutcome::Approved { .. } => { + panic!("posture check with unencrypted disk should be rejected") + } + super::PostureCheckOutcome::Rejected { failed_checks } => failed_checks, + }; + assert!(!rejected_checks.is_empty()); + + let event = event_rx + .try_recv() + .expect("expected posture check failed audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::PostureCheckFailed { + device: event_device, + location: event_location, + device_posture_data, + failed_checks, + } => { + assert_eq!(event_device.id, device.id); + assert_eq!(event_location.id, location.id); + assert_eq!(device_posture_data, Some(posture_data)); + assert_eq!(failed_checks, rejected_checks); + } + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + assert_eq!(event.context.user_id, user.id); + assert_eq!(event.context.username, user.username); + + match gateway_rx + .try_recv() + .expect("expected rejected posture session to be deauthorized") + { + GatewayCommand::VpnSessionDeauthorized(location_id, disconnected_device) => { + assert_eq!(location_id, location.id); + assert_eq!(disconnected_device.id, device.id); + } + other => panic!("unexpected gateway event: {other:?}"), + } + assert!(gateway_rx.try_recv().is_err()); + + let event = event_rx + .try_recv() + .expect("expected session disconnected audit event"); + match event.event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { + DesktopClientMfaEvent::Disconnected { + location: event_location, + device: event_device, + is_mfa_session, + } => { + assert_eq!(event_location.id, location.id); + assert_eq!(event_device.id, device.id); + assert!(!is_mfa_session); + } + other => panic!("unexpected bidi event: {other:?}"), + }, + other => panic!("unexpected bidi stream event type: {other:?}"), + } + assert_eq!(event.context.ip, Some(DEVICE_INFO_IP.parse().unwrap())); + + let active_session = VpnClientSession::find_by_id(&pool, active_session.id) + .await + .expect("failed to reload active posture session") + .expect("expected active posture session"); + assert_eq!(active_session.state, VpnClientSessionState::Disconnected); + assert!(active_session.disconnected_at.is_some()); + assert!( + VpnClientSession::get_all_active_device_sessions_in_location( + &pool, + location.id, + device.id + ) + .await + .expect("failed to query sessions") + .is_empty() + ); + } + + #[sqlx::test] + async fn test_mfa_start_posture_failure_revokes_active_session( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).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; + let mut active_session = VpnClientSession::new( + location.id, + user.id, + device.id, + Some(Utc::now().naive_utc()), + Some(VpnClientMfaMethod::Totp), + ); + active_session.preshared_key = Some("active-mfa-psk".to_owned()); + let active_session = active_session + .save(&pool) + .await + .expect("failed to create active MFA session"); + let (mut server, mut event_rx, mut gateway_rx) = make_server(pool.clone()); + let posture_data = DevicePostureData { + disk_encryption: Some(BoolCheck { + result: Some(bool_check::Result::Value(false)), + }), + ..passing_linux_posture_data() + }; + + let outcome = server + .start_client_mfa_login( + ClientMfaStartRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + method: MfaMethod::Email as i32, + posture_data: Some(posture_data), + }, + device_info(), + ) + .await + .expect("posture check should complete"); + assert!(matches!( + outcome, + super::ClientMfaStartOutcome::Rejected { .. } + )); + + match gateway_rx + .try_recv() + .expect("expected rejected MFA session to be deauthorized") + { + GatewayCommand::VpnSessionDeauthorized(location_id, disconnected_device) => { + assert_eq!(location_id, location.id); + assert_eq!(disconnected_device.id, device.id); + } + other => panic!("unexpected gateway event: {other:?}"), + } + + event_rx + .try_recv() + .expect("expected posture check failed audit event"); + let event = event_rx + .try_recv() + .expect("expected session disconnected audit event"); + assert_eq!(event.context.ip, Some(DEVICE_INFO_IP.parse().unwrap())); + + let active_session = VpnClientSession::find_by_id(&pool, active_session.id) + .await + .expect("failed to reload active MFA session") + .expect("expected active MFA session"); + assert_eq!(active_session.state, VpnClientSessionState::Disconnected); + assert!(active_session.disconnected_at.is_some()); + } + + #[sqlx::test] + async fn test_session_revocation_survives_unavailable_side_effect_consumers( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + set_enterprise_license(); + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("failed to init settings"); + let location = create_non_mfa_location(&pool).await; + save_linux_posture_policy(&pool, location.id).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; + let session = VpnClientSession::new( + location.id, + user.id, + device.id, + Some(Utc::now().naive_utc()), + None, + ) + .save(&pool) + .await + .expect("failed to create active posture session"); + let token = create_polling_token(&pool, device.id).await; + let (mut server, event_rx, gateway_rx) = make_server(pool.clone()); + drop(event_rx); + drop(gateway_rx); + let posture_data = DevicePostureData { + disk_encryption: Some(BoolCheck { + result: Some(bool_check::Result::Value(false)), + }), + ..passing_linux_posture_data() + }; + + let outcome = server + .handle_posture_check( + DevicePostureCheckRequest { + location_id: location.id, + pubkey: device.wireguard_pubkey.clone(), + device_posture_data: Some(posture_data), + token: Some(token), + }, + device_info(), + ) + .await + .expect("side-effect delivery must not prevent posture rejection"); + assert!(matches!( + outcome, + super::PostureCheckOutcome::Rejected { .. } + )); + + let session = VpnClientSession::find_by_id(&pool, session.id) + .await + .expect("failed to reload session") + .expect("expected session"); + assert_eq!(session.state, VpnClientSessionState::Disconnected); } #[sqlx::test] @@ -1582,6 +2461,16 @@ mod tests { .expect("failed to create device") } + /// Issues a polling token for a device, as enrollment does. Posture checks require one to + /// authenticate the caller. + async fn create_polling_token(pool: &PgPool, device_id: Id) -> String { + PollingToken::new(device_id) + .save(pool) + .await + .expect("failed to create polling token") + .token + } + #[sqlx::test] async fn test_create_new_mfa_session_disconnects_previous_active_session( _: PgPoolOptions, diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 7f1fd478e0..5b8d040180 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -24,9 +24,7 @@ use tonic::Status; use super::InstanceInfo; use crate::{ device_access::build_device_config, - enterprise::db::models::{ - enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider, - }, + enterprise::db::models::openid_provider::OpenIdProvider, grpc::{client_version::ClientFeature, should_prevent_service_location_usage}, }; @@ -48,11 +46,6 @@ pub async fn build_device_config_response( Status::internal(format!("unexpected error: {err}")) })?; - let enterprise_settings = EnterpriseSettings::get(pool).await.map_err(|err| { - error!("Failed to get enterprise settings: {err}"); - Status::internal(format!("unexpected error: {err}")) - })?; - let mut configs = Vec::new(); let user = User::find_by_id(pool, device.user_id) .await @@ -231,16 +224,12 @@ pub async fn build_device_config_response( user.username, user.id, device.name, device.id ); - let instance_info = InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .map_err(|err| { - error!("Failed to build instance info: {err}"); - Status::internal(format!("unexpected error: {err}")) - })?; + let instance_info = InstanceInfo::build(pool, &settings, &user, openid_provider) + .await + .map_err(|err| { + error!("Failed to build instance info: {err}"); + Status::internal(format!("unexpected error: {err}")) + })?; Ok(DeviceConfigResponse { device: Some(device.into()), diff --git a/crates/defguard_core/src/handlers/activity_log.rs b/crates/defguard_core/src/handlers/activity_log.rs index aee8fe82c6..db40a61125 100644 --- a/crates/defguard_core/src/handlers/activity_log.rs +++ b/crates/defguard_core/src/handlers/activity_log.rs @@ -6,8 +6,12 @@ use chrono::{DateTime, NaiveDateTime, Utc}; use defguard_common::db::Id; use ipnetwork::IpNetwork; use sqlx::{FromRow, Postgres, QueryBuilder, Type}; +use utoipa::ToSchema; -use super::pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}; +use super::{ + ApiErrorResponse, + pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, +}; use crate::{appstate::AppState, auth::SessionInfo, db::models::activity_log::ActivityLogModule}; #[derive(Debug, Deserialize, Default)] @@ -94,14 +98,15 @@ impl fmt::Display for SortOrder { } } -/// Activity log event with additional info as returned by the API -#[derive(Serialize, FromRow)] +/// Activity log event as returned by the API. +#[derive(Serialize, FromRow, ToSchema)] pub struct ApiActivityLogEvent { pub id: Id, pub timestamp: NaiveDateTime, pub user_id: Option, pub username: String, pub location: Option, + #[schema(value_type = Option)] pub ip: Option, pub event: String, pub module: ActivityLogModule, @@ -109,20 +114,36 @@ pub struct ApiActivityLogEvent { pub description: Option, } -// TODO: add utoipa API schema -/// Filtered list of activity log events +/// List activity log events /// -/// Retrieves a paginated list of activity log events filtered by following query parameters: -/// TODO: add explanations -/// - from -/// - until -/// - module -/// - event_type -/// - username -/// - search -/// -/// # Returns -/// Returns a paginated list of `ApiActivityLogEvent` objects or `WebError` if error occurs. +/// Supports filtering by time range, module, event type and username, plus a free-text search +/// over event descriptions. +#[utoipa::path( + get, + path = "/api/v1/activity_log", + tag = "activity log", + params( + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ("from" = Option, Query, description = "Start of the reported period as an RFC 3339 timestamp."), + ("until" = Option, Query, description = "End of the reported period as an RFC 3339 timestamp."), + ("username" = Option, Query, description = "Filter by username. Admins only."), + ("event" = Option, Query, description = "Filter by event type."), + ("module" = Option, Query, description = "Filter by module."), + ("search" = Option, Query, description = "Free-text search across username, location, module, event type, device, and description."), + ("sort_by" = Option, Query, description = "Sort key: `timestamp`, `username`, `location`, `ip`, `event`, `module`, or `device`. Defaults to `timestamp`."), + ("sort_order" = Option, Query, description = "Sort direction: `asc` or `desc`. Defaults to `desc`."), + ), + responses( + (status = 200, description = "Paginated list of activity log events.", body = PaginatedApiResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to list activity log events.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_activity_log_events( session_info: SessionInfo, State(appstate): State, diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index dff65e6dda..3cba0ff15e 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -5,7 +5,7 @@ use defguard_common::{ db::models::{Settings, WireguardNetwork}, }; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{ appstate::AppState, auth::SessionInfo, @@ -31,6 +31,27 @@ pub struct AppInfo { demo_mode: bool, } +/// Get information about this defguard instance +#[utoipa::path( + get, + path = "/api/v1/info", + tag = "system", + responses( + (status = 200, description = "Instance information: enabled modules, version, license state.", body = Object, example = json!({ + "version": "2.1.0", + "network_present": true, + "smtp_enabled": true, + "ldap_info": {"enabled": false, "ad": false}, + "external_openid_enabled": false + })), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to get instance information.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_app_info(State(appstate): State, _session: SessionInfo) -> ApiResult { // both `await`s are executed upfront to avoid holding license `RwLock` across an await point let networks = WireguardNetwork::all(&appstate.pool).await?; diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 99a2d7b071..4780d126b6 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -26,8 +26,8 @@ use webauthn_rs::prelude::PublicKeyCredential; use webauthn_rs_proto::options::CollectedClientData; use super::{ - ApiResponse, ApiResult, Auth, AuthCode, AuthResponse, AuthTotp, RecoveryCode, RecoveryCodes, - SESSION_COOKIE_NAME, WebAuthnRegistration, + ApiErrorResponse, ApiResponse, ApiResult, Auth, AuthCode, AuthResponse, AuthTotp, RecoveryCode, + RecoveryCodes, SESSION_COOKIE_NAME, WebAuthnRegistration, }; use crate::{ appstate::AppState, @@ -124,17 +124,27 @@ pub async fn create_session( } } -/// Authenticate a user. -/// -/// # For successful login, returns: -/// * 200 with MFA disabled -/// * 201 with MFA enabled when additional authentication factor is required +/// Authenticate a user #[utoipa::path( post, path = "/api/v1/auth", + tag = "auth", + request_body(content = Auth, description = "`username` also accepts the user's email address.", example = json!({"username": "admin", "password": "some-password"})), responses( - (status = OK, description = "User authenticated"), - (status = CREATED, description = "User authenticated, but an additional authentication factor is required"), + (status = 200, description = "User authenticated.", body = AuthResponse, + headers( + ("Set-Cookie" = String, description = "`defguard_session` cookie."), + ), + ), + (status = 201, description = "A second authentication factor is required. Verify one of the listed methods with the matching `/api/v1/auth/{method}` endpoint.", body = MFAInfo, + headers( + ("Set-Cookie" = String, description = "`defguard_session` cookie of a not fully authenticated session."), + ), + ), + (status = 401, description = "Invalid credentials or user groups are not synced.", body = ApiErrorResponse, example = json!({"msg": "Invalid credentials"})), + (status = 403, description = "License user limit reached.", body = ApiErrorResponse, example = json!({"msg": "License limit reached."})), + (status = 429, description = "Too many failed login attempts for this user.", body = ApiErrorResponse, example = json!({"msg": "Too many login attempts"})), + (status = 500, description = "Unable to authenticate user.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), )] pub async fn authenticate( @@ -305,13 +315,25 @@ pub async fn authenticate( } } -/// Logout - forget the session cookie. +/// Log out and clear the session cookie #[utoipa::path( post, path = "/api/v1/auth/logout", + tag = "auth", responses( - (status = OK, description = "User logged out"), + (status = 200, description = "Session removed.", + headers( + ("Set-Cookie" = String, description = "Expired `defguard_session` cookie."), + ), + ), + (status = 400, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "User 1 does not exist"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to remove the session.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), ), + security( + ("cookie" = []), + ("api_token" = []) + ) )] pub async fn logout( cookies: CookieJar, @@ -347,6 +369,23 @@ pub async fn logout( } /// Enable MFA +/// +/// Applies to the user of the current session. +#[utoipa::path( + put, + path = "/api/v1/auth/mfa", + tag = "auth", + responses( + (status = 200, description = "MFA enabled. All other sessions of this user are terminated."), + (status = 304, description = "MFA could not be enabled, for example when no authentication factor is configured."), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to enable MFA.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn mfa_enable( cookies: CookieJar, SessionExtractor(_session): SessionExtractor, @@ -371,7 +410,23 @@ pub async fn mfa_enable( } } -/// Disable own MFA +/// Disable MFA +/// +/// Applies to the user of the current session. +#[utoipa::path( + delete, + path = "/api/v1/auth/mfa", + tag = "auth", + responses( + (status = 200, description = "MFA disabled."), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to disable MFA.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn mfa_disable( session_info: SessionInfo, context: ApiRequestContext, @@ -388,7 +443,26 @@ pub async fn mfa_disable( Ok(ApiResponse::default()) } -/// Disable specific user's MFA +/// Disable MFA of a user +#[utoipa::path( + delete, + path = "/api/v1/user/{username}/mfa", + tag = "user", + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "MFA disabled for the user."), + (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"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to disable MFA.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn disable_user_mfa( session_info: SessionInfo, context: ApiRequestContext, @@ -406,7 +480,30 @@ pub async fn disable_user_mfa( Ok(ApiResponse::default()) } -/// Initialize WebAuthn registration +/// Start WebAuthn registration +/// +/// Applies to the user of the current session. +#[utoipa::path( + post, + path = "/api/v1/auth/webauthn/init", + tag = "auth", + responses( + (status = 200, description = "WebAuthn registration challenge.", body = Object, example = json!({ + "publicKey": { + "rp": {"name": "defguard", "id": "vpn.example.com"}, + "user": {"id": "TDNkX2FkbWlu", "name": "admin", "displayName": "admin"}, + "challenge": "y5EiUNc9wZ0mGvJ0mQdKZg", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] + } + })), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to start WebAuthn registration.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn webauthn_init( mut session_info: SessionInfo, State(appstate): State, @@ -441,6 +538,24 @@ pub async fn webauthn_init( } /// Finish WebAuthn registration +/// +/// Applies to the user of the current session. +#[utoipa::path( + post, + path = "/api/v1/auth/webauthn/finish", + tag = "auth", + request_body = WebAuthnRegistration, + responses( + (status = 200, description = "Security key registered. Returns recovery codes when this is the first configured factor.", body = RecoveryCodes), + (status = 400, description = "Invalid registration data.", body = ApiErrorResponse, example = json!({"msg": "Webauthn registration error"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to finish WebAuthn registration.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn webauthn_finish( session: SessionInfo, context: ApiRequestContext, @@ -518,6 +633,31 @@ pub async fn webauthn_finish( } /// Start WebAuthn authentication +/// +/// Returns the challenge for the session started by `POST /api/v1/auth`. Send the answer to +/// `POST /api/v1/auth/webauthn`. +#[utoipa::path( + post, + path = "/api/v1/auth/webauthn/start", + tag = "auth", + responses( + (status = 200, description = "WebAuthn authentication challenge.", body = Object, example = json!({ + "publicKey": { + "challenge": "y5EiUNc9wZ0mGvJ0mQdKZg", + "rpId": "vpn.example.com", + "allowCredentials": [{"type": "public-key", "id": "AbCdEf0123456789"}], + "userVerification": "preferred" + } + })), + (status = 400, description = "No security key is registered for this user.", body = ApiErrorResponse, example = json!({"msg": "Bad Request"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to start WebAuthn authentication.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn webauthn_start( SessionExtractor(mut session): SessionExtractor, State(appstate): State, @@ -537,6 +677,24 @@ pub async fn webauthn_start( } /// Finish WebAuthn authentication +/// +/// Verifies the second factor of the session started by `POST /api/v1/auth`. +#[utoipa::path( + post, + path = "/api/v1/auth/webauthn", + tag = "auth", + request_body = Object, + responses( + (status = 200, description = "Security key verified, user is fully authenticated.", body = AuthResponse), + (status = 400, description = "Invalid security key assertion.", body = ApiErrorResponse, example = json!({"msg": "Bad Request"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to finish WebAuthn authentication.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn webauthn_end( private_cookies: PrivateCookieJar, SessionExtractor(mut session): SessionExtractor, @@ -643,7 +801,23 @@ pub async fn webauthn_end( Err(WebError::Http(StatusCode::BAD_REQUEST)) } -/// Generate new TOTP secret +/// Generate a new TOTP secret +/// +/// Applies to the user of the current session. +#[utoipa::path( + post, + path = "/api/v1/auth/totp/init", + tag = "auth", + responses( + (status = 200, description = "New TOTP secret. It becomes active only after `POST /api/v1/auth/totp`.", body = AuthTotp), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to generate TOTP secret.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn totp_secret(session: SessionInfo, State(appstate): State) -> ApiResult { let mut user = session.user; debug!("Generating new TOTP secret for user {}", user.username); @@ -654,6 +828,24 @@ pub async fn totp_secret(session: SessionInfo, State(appstate): State) } /// Enable TOTP +/// +/// Applies to the user of the current session. +#[utoipa::path( + post, + path = "/api/v1/auth/totp", + tag = "auth", + request_body = AuthCode, + responses( + (status = 200, description = "TOTP enabled. Returns recovery codes when this is the first configured factor.", body = RecoveryCodes), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Invalid TOTP code.", body = ApiErrorResponse, example = json!({"msg": "Invalid TOTP code"})), + (status = 500, description = "Unable to enable TOTP.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn totp_enable( session: SessionInfo, context: ApiRequestContext, @@ -696,7 +888,26 @@ pub async fn totp_enable( } } -/// Disable TOTP +/// Disable TOTP of a user +#[utoipa::path( + delete, + path = "/api/v1/user/{username}/totp", + tag = "user", + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "TOTP disabled for the user."), + (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"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to disable TOTP.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn totp_disable( session: SessionInfo, context: ApiRequestContext, @@ -715,7 +926,26 @@ pub async fn totp_disable( Ok(ApiResponse::default()) } -/// Validate one-time passcode +/// Verify a TOTP code +/// +/// Verifies the second factor of the session started by `POST /api/v1/auth`. +#[utoipa::path( + post, + path = "/api/v1/auth/totp/verify", + tag = "auth", + request_body = AuthCode, + responses( + (status = 200, description = "TOTP code verified, user is fully authenticated.", body = AuthResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Invalid TOTP code"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "Invalid user"})), + (status = 429, description = "Too many failed login attempts for this user.", body = ApiErrorResponse, example = json!({"msg": "Too many login attempts"})), + (status = 500, description = "Unable to verify TOTP code.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn totp_code( private_cookies: PrivateCookieJar, SessionExtractor(mut session): SessionExtractor, @@ -810,7 +1040,24 @@ pub async fn totp_code( } } -/// Initialize email MFA setup +/// Start email MFA setup +/// +/// Applies to the user of the current session. +#[utoipa::path( + post, + path = "/api/v1/auth/email/init", + tag = "auth", + responses( + (status = 200, description = "Configuration started, email code sent."), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to start email MFA configuration.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + (status = 503, description = "SMTP is not configured.", body = ApiErrorResponse, example = json!({"msg": "SMTP is not configured", "code": "smtp_not_configured"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn email_mfa_init(session: SessionInfo, State(appstate): State) -> ApiResult { // check if SMTP is configured let settings = Settings::get_current_settings(); @@ -836,6 +1083,7 @@ pub async fn email_mfa_init(session: SessionInfo, State(appstate): State, @@ -919,6 +1222,7 @@ pub async fn request_email_mfa_code( &user.first_name, &code, Some(&session.into()), + false, ) .await?; info!("Sent email MFA code for user {}", user.username); @@ -931,7 +1235,26 @@ pub async fn request_email_mfa_code( } } -/// Validate email MFA code +/// Verify an email MFA code +/// +/// Verifies the second factor of the session started by `POST /api/v1/auth`. +#[utoipa::path( + post, + path = "/api/v1/auth/email/verify", + tag = "auth", + request_body = AuthCode, + responses( + (status = 200, description = "Email MFA code verified, user is fully authenticated.", body = AuthResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Invalid email MFA code"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "Invalid user"})), + (status = 429, description = "Too many failed login attempts for this user.", body = ApiErrorResponse, example = json!({"msg": "Too many login attempts"})), + (status = 500, description = "Unable to verify email MFA code.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn email_mfa_code( private_cookies: PrivateCookieJar, SessionExtractor(mut session): SessionExtractor, @@ -1027,7 +1350,24 @@ pub async fn email_mfa_code( } } -/// Authenticate with a recovery code. +/// Authenticate with a recovery code +/// +/// Verifies the second factor of the session started by `POST /api/v1/auth`. +#[utoipa::path( + post, + path = "/api/v1/auth/recovery", + tag = "auth", + request_body = RecoveryCode, + responses( + (status = 200, description = "Recovery code verified, user is fully authenticated.", body = AuthResponse), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to verify recovery code.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn recovery_code( private_cookies: PrivateCookieJar, SessionExtractor(mut session): SessionExtractor, diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index b26a5acd07..c44a72205d 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -55,12 +55,14 @@ use tonic::{ transport::{Certificate, ClientTlsConfig, Endpoint}, }; use tracing::Instrument; +use utoipa::ToSchema; use crate::{ auth::{AdminOrSetupRole, SessionInfo}, cert_settings::ensure_https, enterprise::{LicenseFeature, has_enterprise_access}, error::WebError, + handlers::ApiErrorResponse, letsencrypt::{ACME_TIMEOUT, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, setup_logs::scope_setup_logs, version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, @@ -228,9 +230,29 @@ impl SetupFlow { } } -/// This is the endpoint responsible for the whole edge proxy TLS setup flow. -/// It uses Server-Sent Events (SSE) to stream progress updates back to the frontend in real-time. +/// Stream the progress of edge (proxy) TLS setup +/// +/// Drives the whole TLS setup flow and reports its progress as Server-Sent Events. // This is a get request, since HTML's EventSource only supports GET +#[utoipa::path( + get, + path = "/api/v1/proxy/setup/stream", + tag = "proxy", + params( + ("common_name" = String, Query, description = "Common name for the edge certificate."), + ("ip_or_domain" = String, Query, description = "Address the edge instance is reachable at."), + ("grpc_port" = u16, Query, description = "gRPC port of the edge instance."), + ), + responses( + (status = 200, description = "Server-Sent Event stream with setup progress. Each event carries the current step name and its result.", content_type = "text/event-stream", body = String, example = json!("data: {\"step\":\"CheckingConfiguration\",\"version\":null,\"message\":null,\"logs\":null,\"error\":false}\n\ndata: {\"step\":\"CheckingVersion\",\"version\":\"2.1.0\",\"message\":null,\"logs\":null,\"error\":false}\n\ndata: {\"step\":\"SigningCertificate\",\"version\":null,\"message\":null,\"logs\":null,\"error\":false}\n\ndata: {\"step\":\"Done\",\"version\":null,\"message\":null,\"logs\":null,\"error\":false}\n\n")), + (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": "requires privileged access"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn setup_proxy_tls_stream( _admin: AdminOrSetupRole, Query(request): Query, @@ -1068,9 +1090,30 @@ async fn perform_gateway_adoption( Ok(saved) } -/// This is the endpoint responsible for the whole gateway TLS setup flow. -/// It uses Server-Sent Events (SSE) to stream progress updates back to the frontend in real-time. +/// Stream the progress of gateway TLS setup +/// +/// Drives the whole TLS setup flow and reports its progress as Server-Sent Events. // This is a get request, since HTML's EventSource only supports GET +#[utoipa::path( + get, + path = "/api/v1/network/{network_id}/gateways/setup", + tag = "gateway", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ("common_name" = String, Query, description = "Common name for the gateway certificate."), + ("ip_or_domain" = String, Query, description = "Address the gateway is reachable at."), + ("grpc_port" = u16, Query, description = "gRPC port of the gateway."), + ), + responses( + (status = 200, description = "Server-Sent Event stream with setup progress. Each event carries the current step name and its result.", content_type = "text/event-stream", body = String, example = json!("data: {\"step\":\"CheckingConfiguration\",\"version\":null,\"message\":null,\"logs\":null,\"error\":false}\n\ndata: {\"step\":\"CheckingVersion\",\"version\":\"2.1.0\",\"message\":null,\"logs\":null,\"error\":false}\n\ndata: {\"step\":\"ConfiguringTls\",\"version\":null,\"message\":null,\"logs\":null,\"error\":false}\n\ndata: {\"step\":\"Done\",\"version\":null,\"message\":null,\"logs\":null,\"error\":false}\n\n")), + (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": "requires privileged access"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn setup_gateway_tls_stream( _admin: AdminOrSetupRole, session: SessionInfo, @@ -1167,14 +1210,38 @@ pub async fn setup_gateway_tls_stream( Sse::new(stream).keep_alive(KeepAlive::default()) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct GatewayAdoptRequest { pub name: String, pub ip_or_domain: String, pub grpc_port: u16, } -/// Programmatic gateway adoption endpoint. +/// Adopt a gateway in a location +/// +/// Registers the gateway, issues its client certificate and returns the created gateway. +/// Use `GET /api/v1/network/{network_id}/gateways/setup` to follow the setup progress. +#[utoipa::path( + post, + path = "/api/v1/network/{network_id}/gateways/adopt", + tag = "gateway", + request_body = GatewayAdoptRequest, + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), + responses( + (status = 201, description = "Gateway adopted.", body = Gateway), + (status = 400, description = "Invalid request, or the gateway is unreachable or too old.", body = ApiErrorResponse, example = json!({"msg": "Gateway version is not supported"})), + (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": "requires privileged access"})), + (status = 404, description = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to adopt gateway.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn adopt_gateway( _admin: AdminOrSetupRole, session: SessionInfo, @@ -1228,15 +1295,25 @@ fn acme_error_event(step: &'static str, message: String, logs: Option, diff --git a/crates/defguard_core/src/handlers/core_certs.rs b/crates/defguard_core/src/handlers/core_certs.rs index 77b5db89db..4965433cba 100644 --- a/crates/defguard_core/src/handlers/core_certs.rs +++ b/crates/defguard_core/src/handlers/core_certs.rs @@ -16,7 +16,7 @@ use crate::{ apply_external_url_settings, apply_internal_url_settings, }, error::WebError, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; fn cert_common_name(cert_pem: Option<&str>) -> Option { @@ -53,16 +53,20 @@ fn reload_core_web_server(appstate: &AppState) { } } +/// Set up the certificate for the internal (core) URL #[utoipa::path( post, path = "/api/v1/core/cert/internal_url_settings", + tag = "certificates", request_body = InternalUrlSettingsConfig, responses( - (status = 201, description = "Internal URL certificate settings applied.", body = ApiResponse), - (status = 400, description = "Invalid request.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse), - (status = 403, description = "Forbidden.", body = ApiResponse), - (status = 500, description = "Internal server error.", body = ApiResponse) + (status = 201, description = "Internal URL certificate settings applied.", body = Object, example = json!({ + "cert_info": {"common_name": "vpn.example.com", "valid_for_days": 365, "not_before": "2026-08-04T10:00:00", "not_after": "2027-08-04T10:00:00"} + })), + (status = 400, description = "Invalid certificate settings.", body = ApiErrorResponse, example = json!({"msg": "cert_pem is required for own_cert", "code": "cert_missing_cert_pem"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to apply internal URL certificate settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security(("cookie" = []), ("api_token" = [])) )] @@ -96,16 +100,20 @@ pub(crate) async fn set_internal_url_settings( )) } +/// Set up the certificate for the external (edge) URL #[utoipa::path( post, path = "/api/v1/proxy/cert/external_url_settings", + tag = "certificates", request_body = ExternalUrlSettingsConfig, responses( - (status = 201, description = "External URL certificate settings applied.", body = ApiResponse), - (status = 400, description = "Invalid request.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse), - (status = 403, description = "Forbidden.", body = ApiResponse), - (status = 500, description = "Internal server error.", body = ApiResponse) + (status = 201, description = "External URL certificate settings applied.", body = Object, example = json!({ + "cert_info": {"common_name": "vpn.example.com", "valid_for_days": 90, "not_before": "2026-08-04T10:00:00", "not_after": "2026-11-02T10:00:00"} + })), + (status = 400, description = "Invalid certificate settings.", body = ApiErrorResponse, example = json!({"msg": "cert_pem is required for own_cert", "code": "cert_missing_cert_pem"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to apply external URL certificate settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security(("cookie" = []), ("api_token" = [])) )] @@ -155,15 +163,25 @@ pub(crate) async fn set_external_url_settings( )) } +/// Get the certificate of the internal certificate authority #[utoipa::path( get, path = "/api/v1/core/cert/ca", + tag = "certificates", responses( - (status = 200, description = "CA cert data", body = ApiResponse), - (status = 400, description = "Invalid request (e.g. CA not configured).", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse), - (status = 403, description = "Forbidden.", body = ApiResponse), - (status = 500, description = "Internal server error.", body = ApiResponse) + (status = 200, description = "CA certificate in PEM format.", body = Object, example = json!({ + "ca_cert_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", + "subject_common_name": "defguard CA", + "not_before": "2026-08-04T10:00:00", + "not_after": "2036-08-01T10:00:00", + "valid_for_days": 3650, + "ca_expiry": "2036-08-01T10:00:00", + "subject_email": null + })), + (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": "requires privileged access"})), + (status = 404, description = "The internal CA is not configured.", body = ApiErrorResponse, example = json!({"msg": "CA certificate not found"})), + (status = 500, description = "Unable to get CA certificate.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security(("cookie" = []), ("api_token" = [])) )] @@ -203,15 +221,23 @@ pub(crate) async fn get_ca( } } +/// Get the certificates currently used by core and edge #[utoipa::path( get, path = "/api/v1/core/cert/certs", + tag = "certificates", responses( - (status = 200, description = "Core & edge cert data", body = ApiResponse), - (status = 400, description = "Invalid request (e.g. CA not configured).", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse), - (status = 403, description = "Forbidden.", body = ApiResponse), - (status = 500, description = "Internal server error.", body = ApiResponse) + (status = 200, description = "Certificates used by core and edge.", body = Object, example = json!({ + "core_http_cert_source": "SelfSigned", + "core_http_cert_expiry": "2027-08-04T10:00:00", + "core_http_cert_domain": "vpn.example.com", + "proxy_http_cert_source": "LetsEncrypt", + "proxy_http_cert_expiry": "2026-11-02T10:00:00", + "proxy_http_cert_domain": "vpn.example.com" + })), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get certificates.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security(("cookie" = []), ("api_token" = [])) )] diff --git a/crates/defguard_core/src/handlers/forward_auth.rs b/crates/defguard_core/src/handlers/forward_auth.rs index 8d0767e252..d1624b7f42 100644 --- a/crates/defguard_core/src/handlers/forward_auth.rs +++ b/crates/defguard_core/src/handlers/forward_auth.rs @@ -7,7 +7,7 @@ use axum_extra::extract::cookie::CookieJar; use defguard_common::db::models::{Session, SessionState, Settings, user::User}; use reqwest::Url; -use super::SESSION_COOKIE_NAME; +use super::{ApiErrorResponse, SESSION_COOKIE_NAME}; use crate::{appstate::AppState, error::WebError}; // Header names @@ -61,6 +61,20 @@ where } } +/// Authorize a request forwarded by a reverse proxy +/// +/// Meant to be used as a forward-auth endpoint, for example Traefik `forwardAuth`. The original +/// request URL is read from the `X-Forwarded-*` headers. +#[utoipa::path( + get, + path = "/api/v1/forward_auth", + tag = "system", + responses( + (status = 200, description = "Request is authorized."), + (status = 302, description = "User is not authenticated, redirect to the login page."), + (status = 500, description = "Unable to authorize request.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn forward_auth( State(appstate): State, cookies: CookieJar, diff --git a/crates/defguard_core/src/handlers/gateway.rs b/crates/defguard_core/src/handlers/gateway.rs index 3b20e687bf..64467c9b98 100644 --- a/crates/defguard_core/src/handlers/gateway.rs +++ b/crates/defguard_core/src/handlers/gateway.rs @@ -18,7 +18,7 @@ use crate::{ auth::{AdminRole, SessionInfo}, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; #[derive(Serialize, ToSchema)] @@ -108,14 +108,16 @@ pub struct GatewayUpdateData { pub enabled: bool, } +/// List gateways in all locations #[utoipa::path( get, path = "/api/v1/gateway", + tag = "gateway", responses( - (status = 200, description = "Gateway list", body = [GatewayInfo]), - (status = 401, description = "Unauthorized to get gateway list.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to get gateway list.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to get gateway list.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "All gateways.", body = [GatewayInfo]), + (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 gateways.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -134,15 +136,20 @@ pub async fn gateway_list( Ok(ApiResponse::json(gateways, StatusCode::OK)) } +/// Get a gateway #[utoipa::path( get, path = "/api/v1/gateway/{gateway_id}", + tag = "gateway", + params( + ("gateway_id" = i64, Path, description = "ID of the gateway."), + ), responses( - (status = 200, description = "Gateway details", body = GatewayInfo), - (status = 401, description = "Unauthorized to get gateway details.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to get gateway details.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Gateway not found", body = ApiResponse, example = json!({"msg": "gateway not found"})), - (status = 500, description = "Unable to get gateway details.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Gateway details.", body = Gateway), + (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 = "Gateway not found."), + (status = 500, description = "Unable to get gateway.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -179,16 +186,22 @@ pub(crate) async fn gateway_details( Ok(response) } +/// Rename a gateway, or enable or disable it #[utoipa::path( put, path = "/api/v1/gateway/{gateway_id}", + tag = "gateway", + params( + ("gateway_id" = i64, Path, description = "ID of the gateway."), + ), request_body = GatewayUpdateData, responses( - (status = 200, description = "Successfully modified gateway.", body = GatewayInfo), - (status = 401, description = "Unauthorized to modify gateway.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to modify a gateway.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Gateway not found", body = ApiResponse, example = json!({"msg": "gateway not found"})), - (status = 500, description = "Unable to modify gateway.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Gateway updated.", body = GatewayInfo), + (status = 400, description = "Malformed request body.", body = ApiErrorResponse, example = json!({"msg": "Failed to parse request data"})), + (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 = "Gateway not found.", body = ApiErrorResponse, example = json!({"msg": "gateway not found"})), + (status = 500, description = "Unable to update gateway.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -244,15 +257,20 @@ pub(crate) async fn update_gateway( Ok(ApiResponse::json(gateway, StatusCode::OK)) } +/// Delete a gateway #[utoipa::path( delete, path = "/api/v1/gateway/{gateway_id}", + tag = "gateway", + params( + ("gateway_id" = i64, Path, description = "ID of the gateway."), + ), responses( - (status = 200, description = "Successfully deleted gateway.", body = ApiResponse), - (status = 401, description = "Unauthorized to delete gateway.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission delete a gateway.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Gateway not found", body = ApiResponse, example = json!({"msg": "gateway not found"})), - (status = 500, description = "Unable to delete gateway.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Gateway 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 = "Gateway not found.", body = ApiErrorResponse, example = json!({"msg": "gateway not found"})), + (status = 500, description = "Unable to delete gateway.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), diff --git a/crates/defguard_core/src/handlers/group.rs b/crates/defguard_core/src/handlers/group.rs index 94ed928f2e..30baae5d2f 100644 --- a/crates/defguard_core/src/handlers/group.rs +++ b/crates/defguard_core/src/handlers/group.rs @@ -17,7 +17,7 @@ use defguard_common::{ use sqlx::query_as; use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, EditGroupInfo, GroupInfo, Username}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, EditGroupInfo, GroupInfo, Username}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -41,21 +41,18 @@ pub(crate) struct BulkAssignToGroupsRequest { users: Vec, } -/// Bulk assign users to groups -/// -/// Assign many users to many groups at once basing on `BulkAssignToGroupsRequest` object. -/// -/// # Returns -/// - `WebError` if error occurs +/// Assign multiple users to multiple groups #[utoipa::path( post, path = "/api/v1/groups-assign", + tag = "group", + request_body(content = BulkAssignToGroupsRequest, example = json!({"groups": ["admin", "developers"], "users": [1, 4, 6, 23, 35]})), responses( - (status = 200, description = "Successfully assign users to groups."), - (status = 400, description = "Bad request. Request contains users or groups that don't exist in db.", body = ApiResponse, example = json!({"msg": "Request contained users that doesn't exists in db."})), - (status = 401, description = "Unauthorized to assign users to groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to assign users to groups.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Cannot assign users to groups.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Users assigned to the groups."), + (status = 400, description = "The request contains unknown users or groups.", body = ApiErrorResponse, example = json!({"msg": "Request contained users that doesn't exists in db."})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to assign users.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -137,19 +134,13 @@ pub(crate) async fn bulk_assign_to_groups( Ok(ApiResponse::with_status(StatusCode::OK)) } -/// Retrieve all groups info -/// -/// For each group, the endpoint retrieves a `GroupInfo` object. -/// -/// # Returns -/// - list of `GroupInfo` objects -/// -/// - `WebError` if error occurs +/// List groups with their details #[utoipa::path( get, path = "/api/v1/group-info", + tag = "group", responses( - (status = 200, description = "Successfully listed groups info.", body = [GroupInfo], example = json!([ + (status = 200, description = "All groups with their members.", body = [GroupInfo], example = json!([ { "id": 1, "name": "name", @@ -158,9 +149,9 @@ pub(crate) async fn bulk_assign_to_groups( "is_admin": false } ])), - (status = 401, description = "Unauthorized to list groups info.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to list groups info.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Cannot list groups info.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (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": "requires privileged access"})), + (status = 500, description = "Unable to list groups.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -190,27 +181,23 @@ pub(crate) async fn list_groups_info( Ok(ApiResponse::json(q_result, StatusCode::OK)) } -/// Retrieve paginated list of group names. -/// -/// Returns only the **names** of all groups as a paginated list. -/// Use `/api/v1/group-info` if you need full group details including ID, members, and VPN locations. +/// List group names /// -/// # Returns -/// - paginated list of group name strings -/// -/// - `WebError` if error occurs +/// Returns group names only. Use `GET /api/v1/group-info` for full details, including +/// members and locations. #[utoipa::path( get, path = "/api/v1/group", + tag = "group", params( - ("page" = Option, Query, description = "Page number (default: 1)"), - ("per_page" = Option, Query, description = "Items per page, 1-100 (default: 50)") + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50.") ), responses( - (status = 200, description = "Successfully retrieved group names.", body = [String], example = json!({"data": ["admin"], "pagination": {"current_page": 1, "page_size": 50, "total_items": 1, "total_pages": 1, "next_page": null}})), - (status = 401, description = "Unauthorized to retrieve groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to retrieve groups.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Cannot retrieve groups.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Paginated list of group names.", body = PaginatedApiResponse, example = json!({"data": ["admin"], "pagination": {"current_page": 1, "page_size": 50, "total_items": 1, "total_pages": 1, "next_page": null}})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list groups.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -243,22 +230,16 @@ pub(crate) async fn list_groups( Ok(PaginatedApiResponse::new(groups, pagination, count as u32)) } -/// Retrieve group by ID -/// -/// Retrieves a `GroupInfo` object for the group with the given ID. -/// -/// # Returns -/// - `GroupInfo` object -/// -/// - `WebError` if error occurs +/// Get a group #[utoipa::path( get, path = "/api/v1/group/{id}", + tag = "group", params( - ("id" = i64, description = "Group ID") + ("id" = i64, description = "ID of the group.") ), responses( - (status = 200, description = "Retrieve a group.", body = GroupInfo, example = json!( + (status = 200, description = "Group details.", body = GroupInfo, example = json!( { "id": 1, "name": "name", @@ -267,9 +248,10 @@ pub(crate) async fn list_groups( "is_admin": false } )), - (status = 401, description = "Unauthorized to retrieve a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 404, description = "Incorrect ID of the group.", body = ApiResponse, example = json!({"msg": "Group not found"})), - (status = 500, description = "Cannot retrieve a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (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": "requires privileged access"})), + (status = 404, description = "Group not found.", body = ApiErrorResponse, example = json!({"msg": "Group not found"})), + (status = 500, description = "Unable to get group.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -301,32 +283,26 @@ pub(crate) async fn get_group( } } -/// Create group -/// -/// Create group based on `EditGroupInfo` object. +/// Create a group /// -/// You can also choose whether group should grant admin privileges by changing `is_admin` parameter. -/// -/// # Returns -/// - `EditGroupInfo` object -/// -/// - `WebError` if error occurs +/// Set `is_admin` to grant admin privileges to the group's members. #[utoipa::path( post, path = "/api/v1/group", - request_body = EditGroupInfo, + tag = "group", + request_body(content = EditGroupInfo, example = json!({"name": "engineering", "members": ["jdoe", "asmith"], "is_admin": false})), responses( - (status = 201, description = "Successfully created a group and added users.", body = EditGroupInfo, example = json!( + (status = 201, description = "Group created.", body = EditGroupInfo, example = json!( { "name": "name", "members": ["user"], "is_admin": false } )), - (status = 401, description = "Unauthorized to create a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to create a group.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Cannot create group: user does not exist.", body = ApiResponse, example = json!({"msg": "Failed to find user "})), - (status = 500, description = "Cannot create a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (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": "requires privileged access"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "Failed to find user "})), + (status = 500, description = "Unable to create group.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -394,29 +370,25 @@ pub(crate) async fn create_group( Ok(ApiResponse::json(group_info, StatusCode::CREATED)) } -/// Modify group -/// -/// Rename group and change members basing on `EditGroupInfo` object. -/// -/// You can also change `is_admin` parameter if you want to grant admin privileges to group members. -/// -/// # Returns -/// - empty JSON +/// Update a group /// -/// - `WebError` if error occurs +/// Renames the group and replaces its members. Set `is_admin` to grant admin privileges +/// to the group's members. #[utoipa::path( put, path = "/api/v1/group/{id}", + tag = "group", params( - ("id" = i64, description = "Group ID") + ("id" = i64, description = "ID of the group.") ), request_body = EditGroupInfo, responses( - (status = 200, description = "Successfully updated group."), - (status = 401, description = "Unauthorized to update user group.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to update user group.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Cannot update group: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Group not found"})), - (status = 500, description = "Cannot update a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Group updated."), + (status = 400, description = "Cannot remove admin permissions from the last admin group."), + (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": "requires privileged access"})), + (status = 404, description = "User or group not found.", body = ApiErrorResponse, example = json!({"msg": "Group not found"})), + (status = 500, description = "Unable to update group.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -565,25 +537,23 @@ pub(crate) async fn modify_group( Ok(ApiResponse::default()) } -/// Delete group by ID. +/// Delete a group /// -/// Delete group and remove all group members. -/// -/// # Returns -/// - `WebError` if error occurs +/// Removes the group and the group memberships of its members. #[utoipa::path( delete, path = "/api/v1/group/{id}", + tag = "group", params( - ("id" = i64, description = "Group ID") + ("id" = i64, description = "ID of the group.") ), responses( - (status = 200, description = "Successfully deleted a group."), - (status = 400, description = "Cannot delete admin group.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to delete group.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to delete a group.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Cannot delete group: group not found.", body = ApiResponse, example = json!({"msg": "Failed to find group "})), - (status = 500, description = "Cannot delete a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Group deleted."), + (status = 400, description = "The admin group cannot be 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": "requires privileged access"})), + (status = 404, description = "Group not found.", body = ApiErrorResponse, example = json!({"msg": "Failed to find group "})), + (status = 500, description = "Unable to delete group.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -644,25 +614,21 @@ pub(crate) async fn delete_group( } } -/// Add a group member -/// -/// Find a group by `id` and add `username` as a member. -/// -/// # Returns -/// - `WebError` if error occurs +/// Add a member to a group #[utoipa::path( post, path = "/api/v1/group/{id}", + tag = "group", params( - ("id" = i64, description = "Group ID") + ("id" = i64, description = "ID of the group.") ), request_body = Username, responses( - (status = 200, description = "Successfully add a new member to group."), - (status = 401, description = "Unauthorized to add a new group member.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to add a new group member.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Cannot add a new group member: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Group not found"})), - (status = 500, description = "Cannot add a new group member.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Member added to the group."), + (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": "requires privileged access"})), + (status = 404, description = "User or group not found.", body = ApiErrorResponse, example = json!({"msg": "Group not found"})), + (status = 500, description = "Unable to add group member.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -716,25 +682,21 @@ pub(crate) async fn add_group_member( } } -/// Remove `username` from group with `id`. -/// -/// Find a group by `id` and remove `username` as a member. -/// -/// # Returns -/// - `WebError` if error occurs +/// Remove a member from a group #[utoipa::path( delete, path = "/api/v1/group/{id}/user/{username}", + tag = "group", params( - ("id" = i64, description = "ID of the group from which you want to remove a user."), - ("username" = String, description = "Name of the user that you want to delete.") + ("id" = i64, description = "ID of the group."), + ("username" = String, description = "Name of the user.") ), responses( - (status = 200, description = "Successfully remove a member from group.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to remove a group member.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to remove a group member.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Cannot remove a group member: user or group don't exist.", body = ApiResponse, example = json!({"msg": "Group not found"})), - (status = 500, description = "Cannot remove a group member.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Member removed from the group."), + (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": "requires privileged access"})), + (status = 404, description = "User or group not found.", body = ApiErrorResponse, example = json!({"msg": "Group not found"})), + (status = 500, description = "Unable to remove group member.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), diff --git a/crates/defguard_core/src/handlers/license.rs b/crates/defguard_core/src/handlers/license.rs index e568086de6..ad09f6b8c1 100644 --- a/crates/defguard_core/src/handlers/license.rs +++ b/crates/defguard_core/src/handlers/license.rs @@ -1,7 +1,7 @@ use axum::{Json, http::StatusCode}; use utoipa::ToSchema; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{ enterprise::{ license::License, @@ -21,17 +21,19 @@ pub struct CheckResult { counts: Counts, } -/// Check given license. Return [`LicenseLimits`]. +/// Check a license key and return its limits #[utoipa::path( post, path = "/api/v1/license/check", + tag = "license", request_body = CheckParams, responses( ( status = 200, description = "Decoded license limits.", - // TODO: uncomment when LicenseLimits and Counts implement ToSchema. - // body = CheckResult, + // TODO: replace with `body = CheckResult` when LicenseLimits and Counts implement + // ToSchema. + body = Object, example = json!({ "users": 100, "devices": 250, @@ -39,8 +41,9 @@ pub struct CheckResult { "network_devices": 50 }) ), - (status = 400, description = "Invalid license key.", body = ApiResponse, example = json!({"msg": "License signature doesn't match its content"})), - (status = 404, description = "License not found.", body = ApiResponse, example = json!({"msg": "License not found"})) + (status = 400, description = "Invalid license key.", body = ApiErrorResponse, example = json!({"msg": "License signature doesn't match its content"})), + (status = 404, description = "License not found.", body = ApiErrorResponse, example = json!({"msg": "License not found"})), + (status = 500, description = "Unable to check license.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ) )] pub(crate) async fn license_check(Json(params): Json) -> ApiResult { diff --git a/crates/defguard_core/src/handlers/location_stats.rs b/crates/defguard_core/src/handlers/location_stats.rs index 0f5ee959ae..46920eb1e7 100644 --- a/crates/defguard_core/src/handlers/location_stats.rs +++ b/crates/defguard_core/src/handlers/location_stats.rs @@ -19,7 +19,7 @@ use crate::{ auth::AdminRole, error::WebError, handlers::{ - ApiResponse, ApiResult, + ApiErrorResponse, ApiResponse, ApiResult, pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, }, }; @@ -52,10 +52,35 @@ fn get_aggregation(from: NaiveDateTime) -> Result, Query, description = "Start of the reported period as an RFC 3339 timestamp. Defaults to 1 hour ago."), + ), + responses( + (status = 200, description = "Traffic statistics of all locations.", body = Object, example = json!({ + "current_active_users": 3, + "current_active_user_devices": 4, + "current_active_network_devices": 1, + "active_users": 12, + "active_user_devices": 18, + "active_network_devices": 2, + "upload": 1048576, + "download": 4194304, + "transfer_series": [{"collected_at": "2026-08-04T10:00:00", "upload": 1024, "download": 4096}] + })), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get location statistics.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn locations_overview_stats( _role: AdminRole, State(appstate): State, @@ -69,10 +94,37 @@ pub(crate) async fn locations_overview_stats( Ok(ApiResponse::json(all_networks_stats, StatusCode::OK)) } -/// Returns statistics for requested location -/// -/// # Returns -/// Returns an `WireguardNetworkStats` based on requested location and time period +/// Get traffic statistics for a location +#[utoipa::path( + get, + path = "/api/v1/network/{network_id}/stats", + tag = "location stats", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ("from" = Option, Query, description = "Start of the reported period as an RFC 3339 timestamp. Defaults to 1 hour ago."), + ), + responses( + (status = 200, description = "Traffic statistics of the location.", body = Object, example = json!({ + "current_active_users": 3, + "current_active_user_devices": 4, + "current_active_network_devices": 1, + "active_users": 12, + "active_user_devices": 18, + "active_network_devices": 2, + "upload": 1048576, + "download": 4194304, + "transfer_series": [{"collected_at": "2026-08-04T10:00:00", "upload": 1024, "download": 4096}] + })), + (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": "requires privileged access"})), + (status = 404, description = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to get location statistics.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn location_stats( _role: AdminRole, State(appstate): State, @@ -95,11 +147,29 @@ pub(crate) async fn location_stats( Ok(ApiResponse::json(stats, StatusCode::OK)) } -/// Returns paginated list of connected users for a given location -/// -/// # Returns -/// Returns a paginated list of `LocationConnectedUser` objects for requested location and time -/// period. +/// List connected users in a location +#[utoipa::path( + get, + path = "/api/v1/network/{location_id}/stats/connected_users", + tag = "location stats", + params( + ("location_id" = i64, Path, description = "ID of the location."), + ("from" = Option, Query, description = "Start of the reported period as an RFC 3339 timestamp. Defaults to 1 hour ago."), + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ), + responses( + (status = 200, description = "Paginated list of connected users.", body = PaginatedApiResponse), + (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": "requires privileged access"})), + (status = 404, description = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to get connected users.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn location_connected_users( _role: AdminRole, State(appstate): State, @@ -138,11 +208,29 @@ pub(crate) async fn location_connected_users( )) } -/// Returns paginated list of connected network devices for a given location -/// -/// # Returns -/// Returns a paginated list of `LocationConnectedNetworkDevice` objects for requested location and -/// time period. +/// List connected network devices in a location +#[utoipa::path( + get, + path = "/api/v1/network/{location_id}/stats/connected_network_devices", + tag = "location stats", + params( + ("location_id" = i64, Path, description = "ID of the location."), + ("from" = Option, Query, description = "Start of the reported period as an RFC 3339 timestamp. Defaults to 1 hour ago."), + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ), + responses( + (status = 200, description = "Paginated list of connected network devices.", body = PaginatedApiResponse), + (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": "requires privileged access"})), + (status = 404, description = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to get connected network devices.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn location_connected_network_devices( _role: AdminRole, State(appstate): State, @@ -187,11 +275,37 @@ pub(crate) struct ConnectedUserDevicesPath { user_id: Id, } -/// Returns list of connected devices for a specific user at a given location -/// -/// # Returns -/// Returns a list of `LocationConnectedUserDevice` objects for requested user, location and time -/// period. +/// List the connected devices of a user in a location +#[utoipa::path( + get, + path = "/api/v1/network/{location_id}/stats/connected_users/{user_id}/devices", + tag = "location stats", + params( + ("location_id" = i64, Path, description = "ID of the location."), + ("user_id" = i64, Path, description = "ID of the user."), + ("from" = Option, Query, description = "Start of the reported period as an RFC 3339 timestamp. Defaults to 1 hour ago."), + ), + responses( + (status = 200, description = "All connected devices of the user.", body = [Object], example = json!([{ + "device_id": 5, + "device_name": "laptop", + "public_ip": "203.0.113.10", + "vpn_ips": ["10.0.0.15"], + "connected_at": "2026-08-04T10:00:00", + "total_upload": 1048576, + "total_download": 4194304, + "stats": [{"collected_at": "2026-08-04T10:00:00", "upload": 1024, "download": 4096}] + }])), + (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": "requires privileged access"})), + (status = 404, description = "Network or user not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to get connected user devices.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn location_connected_user_devices( _role: AdminRole, State(appstate): State, diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index eeb79259d5..b857f59215 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -9,8 +9,9 @@ use sqlx::query_scalar; use tera::Context; use thiserror::Error; use tokio::fs::read_to_string; +use utoipa::ToSchema; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{ PgPool, appstate::AppState, @@ -24,11 +25,29 @@ use crate::{ support::dump_config, }; -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, ToSchema)] pub struct TestMail { pub to: String, } +/// Send a test email to verify the SMTP configuration +#[utoipa::path( + post, + path = "/api/v1/mail/test", + tag = "support", + request_body = TestMail, + responses( + (status = 200, description = "Test email sent."), + (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": "requires privileged access"})), + (status = 500, description = "Unable to send test email.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + (status = 503, description = "SMTP is not configured.", body = ApiErrorResponse, example = json!({"msg": "SMTP is not configured", "code": "smtp_not_configured"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn test_mail( _admin: AdminRole, session: SessionInfo, @@ -81,6 +100,23 @@ async fn read_logs() -> String { } } +/// Send the support data bundle to the defguard support address +#[utoipa::path( + post, + path = "/api/v1/mail/support", + tag = "support", + responses( + (status = 200, description = "Support data sent."), + (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": "requires privileged access"})), + (status = 500, description = "Unable to send support data.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + (status = 503, description = "SMTP is not configured.", body = ApiErrorResponse, example = json!({"msg": "SMTP is not configured", "code": "smtp_not_configured"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn send_support_data( _admin: AdminRole, session: SessionInfo, diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index dcef42c4d3..6bbb4d080d 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -62,6 +62,20 @@ pub mod wireguard; pub mod worker; pub(crate) mod yubikey; +/// Machine-readable error code. +/// +/// - `network_full`: the location has no free IP address left for another device. +/// - `user_groups_not_synced`: the groups of an externally authenticated user are not synced yet. +/// - `license_limit_reached`: the user limit of the license has been reached. +/// - `cert_missing_cert_pem`: `cert_pem` is missing. +/// - `cert_missing_key_pem`: `key_pem` is missing. +/// - `cert_invalid_cert_or_key`: the certificate or the private key is not valid PEM. +/// - `cert_invalid_validity_period`: the validity period of the certificate cannot be used. +/// - `cert_expired`: the certificate has expired. +/// - `cert_not_yet_valid`: the certificate is not valid yet. +/// - `cert_parse_error`: the certificate could not be parsed. +/// - `smtp_not_configured`: SMTP settings are empty. +/// - `mail_send_failed`: the message could not be sent. #[derive(Serialize)] #[serde(rename_all = "snake_case")] pub enum WebErrorCode { @@ -79,6 +93,16 @@ pub enum WebErrorCode { MailSendFailed, } +/// Body returned with error responses. +#[derive(ToSchema)] +pub struct ApiErrorResponse { + /// Human-readable error message. + pub msg: String, + /// Machine-readable error code, returned for selected errors. + #[schema(value_type = Option)] + pub code: Option, +} + pub static SESSION_COOKIE_NAME: &str = "defguard_session"; pub(crate) static SIGN_IN_COOKIE_NAME: &str = "defguard_sign_in"; pub(crate) const SIGN_IN_COOKIE_MAX_AGE: time::Duration = time::Duration::minutes(10); @@ -131,10 +155,9 @@ pub(crate) fn cookie_domain() -> Option { }) } -#[derive(Default, ToSchema)] +#[derive(Default)] pub struct ApiResponse { json: Value, - #[schema(value_type = u16)] status: StatusCode, } @@ -162,6 +185,23 @@ impl ApiResponse { } } +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiResponseCode { + LicenseReactivated, +} + +impl From for ApiResponse { + fn from(code: ApiResponseCode) -> Self { + match code { + ApiResponseCode::LicenseReactivated => Self::new( + json!({"code": ApiResponseCode::LicenseReactivated}), + StatusCode::OK, + ), + } + } +} + impl From for ApiResponse { fn from(web_error: WebError) -> Self { match web_error { @@ -433,7 +473,7 @@ impl Auth { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct AuthTotp { pub secret: String, } @@ -447,7 +487,7 @@ impl AuthTotp { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct AuthCode { code: String, } @@ -540,18 +580,19 @@ pub struct PasswordChange { pub new_password: String, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct WebAuthnRegistration { pub name: String, + #[schema(value_type = Object)] pub rpkc: RegisterPublicKeyCredential, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct RecoveryCode { code: String, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct RecoveryCodes { codes: Option>, } @@ -563,7 +604,7 @@ impl RecoveryCodes { } } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct WebHookData { pub url: String, pub description: String, @@ -593,7 +634,7 @@ impl From for WebHook { /// Return type needed for knowing if a user came from OpenID flow. /// If so, fill in the optional URL field to redirect him later. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct AuthResponse { pub user: UserInfo, pub url: Option, diff --git a/crates/defguard_core/src/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index 375d824677..9df878f738 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -22,8 +22,9 @@ use defguard_common::{ }; use serde_json::json; use sqlx::PgConnection; +use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, WebError}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, WebError}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -42,16 +43,17 @@ use crate::{ mail::templates::{TemplateLocation, new_device_added_mail}, }; -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] struct NetworkDeviceLocation { id: Id, name: String, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub(crate) struct NetworkDeviceInfo { id: Id, name: String, + #[schema(value_type = Vec)] assigned_ips: Vec, description: Option, added_by: String, @@ -124,9 +126,30 @@ pub(crate) struct DeviceWireGuardConfig { pub(crate) location_mfa_mode: LocationMfaMode, } -/// For a given device, retrieve all WireGuard configuations for all networks. +/// Get the WireGuard configuration of a network device /// -/// GET /device/network/{device_id}/config +/// Returns one configuration per location the device belongs to. +#[utoipa::path( + get, + path = "/api/v1/device/network/{device_id}/config", + tag = "network device", + params( + ("device_id" = i64, Path, description = "ID of the network device."), + ), + 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"} + ])), + (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"})), + (status = 404, description = "Network device not found.", body = ApiErrorResponse, example = json!({"msg": "device not found"})), + (status = 500, description = "Unable to get network device configuration.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn network_device_configs( session: SessionInfo, State(appstate): State, @@ -180,6 +203,26 @@ pub(crate) async fn network_device_configs( Ok(ApiResponse::json(result, StatusCode::OK)) } +/// Get a network device +#[utoipa::path( + get, + path = "/api/v1/device/network/{device_id}", + tag = "network device", + params( + ("device_id" = i64, Path, description = "ID of the network device."), + ), + responses( + (status = 200, description = "Network device details.", body = NetworkDeviceInfo), + (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": "requires privileged access"})), + (status = 404, description = "Network device not found.", body = ApiErrorResponse, example = json!({"msg": "device not found"})), + (status = 500, description = "Unable to get network device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn get_network_device( _admin_role: AdminRole, session: SessionInfo, @@ -208,7 +251,26 @@ pub(crate) async fn get_network_device( ))) } -/// GET /api/v1/device/network +/// List network devices +#[utoipa::path( + get, + path = "/api/v1/device/network", + tag = "network device", + params( + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ), + responses( + (status = 200, description = "Paginated list of network devices.", body = PaginatedApiResponse), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list network devices.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn list_network_devices( _admin_role: AdminRole, State(appstate): State, @@ -252,7 +314,7 @@ pub(crate) async fn list_network_devices( )) } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, ToSchema)] pub struct AddNetworkDevice { pub name: String, pub description: Option, @@ -267,7 +329,7 @@ pub struct AddNetworkDeviceResult { device: NetworkDeviceInfo, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct IpAvailabilityCheck { ips: Vec, device_id: Option, @@ -286,6 +348,27 @@ impl IpAvailabilityCheckResult { } } +/// Check whether the given IP addresses are free in a location +#[utoipa::path( + post, + path = "/api/v1/device/network/ip/{network_id}", + tag = "network device", + request_body = IpAvailabilityCheck, + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), + responses( + (status = 200, description = "Availability of the requested IP addresses.", body = [Object], example = json!([{"available": true, "valid": true}])), + (status = 400, description = "Location not found.", body = ApiErrorResponse, example = json!({"msg": "Failed to check IP availability, location not found"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to check IP availability.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn check_ip_availability( _admin_role: AdminRole, Path(network_id): Path, @@ -368,6 +451,28 @@ pub(crate) async fn check_ip_availability( Ok(ApiResponse::json(validation_results, StatusCode::OK)) } +/// Suggest free IP addresses in a location +#[utoipa::path( + get, + path = "/api/v1/device/network/ip/{network_id}", + tag = "network device", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), + responses( + (status = 200, description = "Suggested IP addresses.", body = [Object], example = json!([ + {"network_part": "10.0.0.", "modifiable_part": "15", "network_prefix": "/24", "ip": "10.0.0.15"} + ])), + (status = 400, description = "Location not found.", body = ApiErrorResponse, example = json!({"msg": "Failed to find available IP, network not found"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to find available IP addresses.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn find_available_ips( _admin_role: AdminRole, Path(network_id): Path, @@ -426,7 +531,7 @@ pub(crate) async fn find_available_ips( Ok(ApiResponse::json(split_ips, StatusCode::OK)) } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, ToSchema)] pub struct StartNetworkDeviceSetup { name: String, description: Option, @@ -441,6 +546,29 @@ impl From for WebError { } // Setup a network device to be later configured by a CLI client +/// Start CLI setup for a new network device +/// +/// Returns an enrollment token the `defguard-cli` client uses to configure itself. +#[utoipa::path( + post, + path = "/api/v1/device/network/start_cli", + tag = "network device", + request_body = StartNetworkDeviceSetup, + responses( + (status = 201, description = "Setup started. Returns the enrollment token and URL.", body = Object, example = json!({ + "enrollment_token": "yZbTsF0m9Xq7cVwPnR2Ld1Ku", + "enrollment_url": "https://vpn.example.com/" + })), + (status = 400, description = "Invalid IP assignment.", body = ApiErrorResponse, example = json!({"msg": "Invalid IP address"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to start network device setup.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn start_network_device_setup( _admin_role: AdminRole, session: SessionInfo, @@ -546,6 +674,29 @@ pub(crate) async fn start_network_device_setup( } // Make a new CLI configuration token for an already added network device +/// Start CLI setup for an existing network device +#[utoipa::path( + post, + path = "/api/v1/device/network/start_cli/{device_id}", + tag = "network device", + params( + ("device_id" = i64, Path, description = "ID of the network device."), + ), + responses( + (status = 201, description = "Setup started. Returns the enrollment token and URL.", body = Object, example = json!({ + "enrollment_token": "yZbTsF0m9Xq7cVwPnR2Ld1Ku", + "enrollment_url": "https://vpn.example.com/" + })), + (status = 400, description = "Device not found, or it is not a network device.", body = ApiErrorResponse, example = json!({"msg": "Failed to start network device setup for device with ID 1, device not found"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to start network device setup.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn start_network_device_setup_for_device( _admin_role: AdminRole, session: SessionInfo, @@ -610,6 +761,53 @@ pub(crate) async fn start_network_device_setup_for_device( )) } +/// Create a network device +/// +/// The device is created with the provided WireGuard public key. +#[utoipa::path( + post, + path = "/api/v1/device/network", + tag = "network device", + request_body(content = AddNetworkDevice, example = json!({"name": "office-printer", "location_id": 1, "assigned_ips": ["10.0.0.50"], "wireguard_pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=", "description": "Printer on the second floor"})), + responses( + (status = 201, description = "Network device created.", body = Object, example = json!({ + "config": { + "network_id": 1, + "network_name": "office", + "config": "[Interface]\n...", + "address": ["10.0.0.15"], + "endpoint": "vpn.example.com:50051", + "allowed_ips": ["10.0.0.0/24"], + "pubkey": "Zm9vYmFyMDEyMzQ1Njc4OWFiY2RlZmdoaWprbG1ub3A=", + "dns": "10.0.0.1", + "keepalive_interval": 25, + "location_mfa_mode": "disabled", + "service_location_mode": "disabled", + "posture_check_required": false + }, + "device": { + "id": 5, + "name": "printer", + "assigned_ips": ["10.0.0.15"], + "description": null, + "added_by": "admin", + "added_date": "2026-08-04T10:15:00", + "location": {"id": 1, "name": "office"}, + "wireguard_pubkey": "5ItSw7SLkVLXPFvNxLdEQaSMOFhLxD7YsTTAlR8CbCA=", + "configured": true, + "split_ips": [{"network_part": "10.0.0.", "modifiable_part": "15", "network_prefix": "/24", "ip": "10.0.0.15"}] + } + })), + (status = 400, description = "Invalid public key or IP assignment.", body = ApiErrorResponse, example = json!({"msg": "Public key invalid"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to create network device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn add_network_device( _admin_role: AdminRole, session: SessionInfo, @@ -731,13 +929,36 @@ pub(crate) async fn add_network_device( Ok(ApiResponse::json(result, StatusCode::CREATED)) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct ModifyNetworkDevice { name: String, description: Option, + #[schema(value_type = Vec)] assigned_ips: Vec, } +/// Update a network device +#[utoipa::path( + put, + path = "/api/v1/device/network/{device_id}", + tag = "network device", + request_body = ModifyNetworkDevice, + params( + ("device_id" = i64, Path, description = "ID of the network device."), + ), + responses( + (status = 200, description = "Network device updated.", body = NetworkDeviceInfo), + (status = 400, description = "Invalid IP assignment.", body = ApiErrorResponse, example = json!({"msg": "Invalid IP address"})), + (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": "requires privileged access"})), + (status = 404, description = "Network device not found.", body = ApiErrorResponse, example = json!({"msg": "device not found"})), + (status = 500, description = "Unable to update network device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn modify_network_device( _admin_role: AdminRole, session: SessionInfo, diff --git a/crates/defguard_core/src/handlers/openid_clients.rs b/crates/defguard_core/src/handlers/openid_clients.rs index 25553643ee..4b0216c94d 100644 --- a/crates/defguard_core/src/handlers/openid_clients.rs +++ b/crates/defguard_core/src/handlers/openid_clients.rs @@ -10,8 +10,9 @@ use defguard_common::{ random::gen_alphanumeric, }; use serde_json::json; +use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, webhooks::ChangeStateData}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, webhooks::ChangeStateData}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -19,7 +20,7 @@ use crate::{ handlers::pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, }; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct NewOpenIDClient { pub name: String, pub redirect_uri: Vec, @@ -43,6 +44,32 @@ impl From for OAuth2Client { } } +/// Create an OAuth2/OpenID client application +#[utoipa::path( + post, + path = "/api/v1/oauth/", + tag = "OAuth2", + request_body = NewOpenIDClient, + responses( + (status = 201, description = "OAuth2 client created.", body = Object, example = json!({ + "id": 1, + "client_id": "Kx7mQ2pR9tLvB4nZ", + "client_secret": "wY3hJ8sD1fG6kL0aP5vN2cX7bM4qR9tZ", + "redirect_uri": ["https://app.example.com/callback"], + "scope": ["openid", "profile", "email"], + "name": "internal app", + "enabled": true + })), + (status = 400, description = "Invalid OAuth2 client data.", body = ApiErrorResponse, example = json!({"msg": "Invalid redirect URI"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to create OAuth2 client.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn add_openid_client( _admin: AdminRole, session: SessionInfo, @@ -79,7 +106,37 @@ pub(crate) async fn add_openid_client( Ok(ApiResponse::json(client, StatusCode::CREATED)) } -/// GET: /api/v1/oauth +/// List OAuth2/OpenID client applications +#[utoipa::path( + get, + path = "/api/v1/oauth/", + tag = "OAuth2", + params( + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ), + responses( + (status = 200, description = "Paginated list of OAuth2 clients.", body = Object, example = json!({ + "data": [{ + "id": 1, + "client_id": "Kx7mQ2pR9tLvB4nZ", + "client_secret": "wY3hJ8sD1fG6kL0aP5vN2cX7bM4qR9tZ", + "redirect_uri": ["https://app.example.com/callback"], + "scope": ["openid", "profile", "email"], + "name": "internal app", + "enabled": true + }], + "pagination": {"current_page": 1, "page_size": 50, "total_items": 1, "total_pages": 1, "next_page": null} + })), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list OAuth2 clients.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn list_openid_clients( _admin: AdminRole, State(appstate): State, @@ -102,6 +159,35 @@ pub(crate) async fn list_openid_clients( Ok(PaginatedApiResponse::new(clients, pagination, count as u32)) } +/// Get an OAuth2/OpenID client application +/// +/// Non-admin users receive a reduced representation without the client secret. +#[utoipa::path( + get, + path = "/api/v1/oauth/{client_id}", + tag = "OAuth2", + params( + ("client_id" = String, Path, description = "ID of the OAuth2 client."), + ), + responses( + (status = 200, description = "OAuth2 client details.", body = Object, example = json!({ + "id": 1, + "client_id": "Kx7mQ2pR9tLvB4nZ", + "client_secret": "wY3hJ8sD1fG6kL0aP5vN2cX7bM4qR9tZ", + "redirect_uri": ["https://app.example.com/callback"], + "scope": ["openid", "profile", "email"], + "name": "internal app", + "enabled": true + })), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "OAuth2 client not found."), + (status = 500, description = "Unable to get OAuth2 client.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn get_openid_client( State(appstate): State, Path(client_id): Path, @@ -122,6 +208,28 @@ pub(crate) async fn get_openid_client( } } +/// Update an OAuth2/OpenID client application +#[utoipa::path( + put, + path = "/api/v1/oauth/{client_id}", + tag = "OAuth2", + request_body = NewOpenIDClient, + params( + ("client_id" = String, Path, description = "ID of the OAuth2 client."), + ), + responses( + (status = 200, description = "OAuth2 client updated."), + (status = 400, description = "Invalid client name.", body = ApiErrorResponse, example = json!({"msg": "invalid name"})), + (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": "requires privileged access"})), + (status = 404, description = "OAuth2 client not found."), + (status = 500, description = "Unable to update OAuth2 client.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn change_openid_client( _admin: AdminRole, session: SessionInfo, @@ -176,6 +284,27 @@ pub(crate) async fn change_openid_client( Ok(ApiResponse::with_status(status)) } +/// Enable or disable an OAuth2/OpenID client application +#[utoipa::path( + post, + path = "/api/v1/oauth/{client_id}", + tag = "OAuth2", + request_body = ChangeStateData, + params( + ("client_id" = String, Path, description = "ID of the OAuth2 client."), + ), + responses( + (status = 200, description = "OAuth2 client state changed."), + (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": "requires privileged access"})), + (status = 404, description = "OAuth2 client not found."), + (status = 500, description = "Unable to change OAuth2 client state.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn change_openid_client_state( _admin: AdminRole, session: SessionInfo, @@ -210,6 +339,26 @@ pub(crate) async fn change_openid_client_state( Ok(ApiResponse::with_status(status)) } +/// Delete an OAuth2/OpenID client application +#[utoipa::path( + delete, + path = "/api/v1/oauth/{client_id}", + tag = "OAuth2", + params( + ("client_id" = String, Path, description = "ID of the OAuth2 client."), + ), + responses( + (status = 200, description = "OAuth2 client 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": "requires privileged access"})), + (status = 404, description = "OAuth2 client not found."), + (status = 500, description = "Unable to delete OAuth2 client.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn delete_openid_client( _admin: AdminRole, session: SessionInfo, diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 3b8b7b66f9..877475f372 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -44,7 +44,7 @@ use serde::{ use sqlx::PgPool; use thiserror::Error; -use super::{ApiResponse, ApiResult, SESSION_COOKIE_NAME}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, SESSION_COOKIE_NAME}; use crate::{ appstate::AppState, auth::{SessionInfo, UserClaims}, @@ -93,6 +93,18 @@ impl From for StandardClaims { } } +/// Get the JSON Web Key Set used to verify ID token signatures +#[utoipa::path( + get, + path = "/api/v1/oauth/discovery/keys", + tag = "OAuth2", + responses( + (status = 200, description = "JSON Web Key Set.", body = Object, example = json!({ + "keys": [{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "defguard", "n": "0vx7ago...", "e": "AQAB"}] + })), + (status = 500, description = "Unable to build key set.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn discovery_keys() -> ApiResult { let mut keys = Vec::new(); if let Some(openid_key) = runtime_openid_key()? { @@ -458,8 +470,33 @@ fn login_redirect( Ok(redirect_to("/auth/login", private_cookies.add(cookie))) } -/// Authorization Endpoint -/// See https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint +/// Start the OAuth2 authorization flow +/// +/// Redirects to the login or consent page when the user is not authenticated or has not +/// yet approved the client. Implements the +/// [OpenID Connect authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). +#[utoipa::path( + get, + path = "/api/v1/oauth/authorize", + tag = "OAuth2", + params( + ("client_id" = String, Query, description = "ID of the OAuth2 client."), + ("redirect_uri" = String, Query, description = "Redirect URI registered for the client."), + ("response_type" = String, Query, description = "OAuth2 response type, for example `code`."), + ("scope" = String, Query, description = "Space-separated list of requested scopes."), + ("state" = String, Query, description = "Opaque value returned unchanged to the client."), + ("nonce" = Option, Query, description = "Value bound to the ID token to mitigate replay attacks."), + ("code_challenge" = Option, Query, description = "PKCE code challenge."), + ("code_challenge_method" = Option, Query, description = "PKCE code challenge method, for example `S256`."), + ("prompt" = Option, Query, description = "OpenID `prompt` parameter, for example `consent`."), + ("allow" = Option, Query, description = "Set by the consent screen to allow or deny the request."), + ), + responses( + (status = 302, description = "Redirect to the client, to the login page or to the consent page."), + (status = 400, description = "Invalid authorization request.", body = ApiErrorResponse, example = json!({"msg": "Invalid redirect URI"})), + (status = 500, description = "Unable to handle authorization request.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn authorization( State(appstate): State, Query(data): Query, @@ -642,7 +679,37 @@ async fn get_group_claims(pool: &PgPool, user: &User) -> Result, Query, description = "Value bound to the ID token to mitigate replay attacks."), + ("code_challenge" = Option, Query, description = "PKCE code challenge."), + ("code_challenge_method" = Option, Query, description = "PKCE code challenge method, for example `S256`."), + ("prompt" = Option, Query, description = "OpenID `prompt` parameter, for example `consent`."), + ("allow" = Option, Query, description = "Set by the consent screen to allow or deny the request."), + ), + responses( + (status = 302, description = "Redirect to the client redirect URI with an authorization code or an error."), + (status = 400, description = "Invalid authorization request.", body = ApiErrorResponse, example = json!({"msg": "Invalid redirect URI"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to handle authorization request.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn secure_authorization( session_info: SessionInfo, State(appstate): State, @@ -897,9 +964,33 @@ impl TokenRequest { } } -/// Token Endpoint -/// https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint -/// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens +/// Exchange an authorization code or a refresh token for tokens +/// +/// Accepts `application/x-www-form-urlencoded` and supports the `authorization_code` and +/// `refresh_token` grants. The client authenticates with HTTP Basic auth or with +/// `client_id`/`client_secret` in the form body. Implements the +/// [OpenID Connect token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). +#[utoipa::path( + post, + path = "/api/v1/oauth/token", + tag = "OAuth2", + request_body( + content = Object, + content_type = "application/x-www-form-urlencoded", + description = "`grant_type`, `code` or `refresh_token`, `redirect_uri`, `code_verifier`, and optionally `client_id`/`client_secret`." + ), + responses( + (status = 200, description = "Access token, and an ID token when the `openid` scope was requested.", body = Object, example = json!({ + "access_token": "hR4pV9mK2sT7dQ1xL0nB", + "token_type": "bearer", + "refresh_token": "gY6wC3jN8bF5rZ2tM7vK", + "id_token": "eyJhbGciOiJSUzI1NiJ9..." + })), + (status = 400, description = "Invalid grant or invalid request.", body = Object, example = json!({"error": "invalid_grant"})), + (status = 401, description = "Invalid client credentials.", body = ApiErrorResponse, example = json!({"msg": "Invalid credentials"})), + (status = 500, description = "Unable to issue token.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn token( State(appstate): State, OAuth2ClientExtractor(oauth2client): OAuth2ClientExtractor, @@ -1061,7 +1152,27 @@ pub async fn token( Ok(ApiResponse::json(response, StatusCode::BAD_REQUEST)) } -/// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo +/// Get the claims of the authenticated user +/// +/// Requires an access token in the `Authorization: Bearer ` header. Implements the +/// [OpenID Connect UserInfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). +#[utoipa::path( + get, + path = "/api/v1/oauth/userinfo", + tag = "OAuth2", + responses( + (status = 200, description = "Claims of the authenticated user.", body = Object, example = json!({ + "sub": "admin", + "name": "Jane Doe", + "given_name": "Jane", + "family_name": "Doe", + "email": "jane@example.com", + "email_verified": true + })), + (status = 401, description = "Access token is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Invalid token"})), + (status = 500, description = "Unable to get user claims.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn userinfo(State(appstate): State, headers: HeaderMap) -> ApiResult { let Some(token) = headers.get(AUTHORIZATION).and_then(|value| { if let Ok(value) = value.to_str() { @@ -1110,6 +1221,28 @@ pub async fn userinfo(State(appstate): State, headers: HeaderMap) -> A } // Must be served under /.well-known/openid-configuration +/// Get the OpenID Connect discovery document +/// +/// See [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html). +#[utoipa::path( + get, + path = "/.well-known/openid-configuration", + tag = "OAuth2", + responses( + (status = 200, description = "Discovery document of this OpenID provider.", body = Object, example = json!({ + "issuer": "https://vpn.example.com/", + "authorization_endpoint": "https://vpn.example.com/api/v1/oauth/authorize", + "token_endpoint": "https://vpn.example.com/api/v1/oauth/token", + "userinfo_endpoint": "https://vpn.example.com/api/v1/oauth/userinfo", + "jwks_uri": "https://vpn.example.com/api/v1/oauth/discovery/keys", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["HS256", "RS256"], + "scopes_supported": ["openid", "profile", "email", "phone", "groups"] + })), + (status = 500, description = "Unable to build discovery document.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn openid_configuration() -> ApiResult { let url = Settings::url().map_err(|e| OidcFlowError::Url(e.to_string()))?; let provider_metadata = CoreProviderMetadata::new( diff --git a/crates/defguard_core/src/handlers/pagination.rs b/crates/defguard_core/src/handlers/pagination.rs index c4da1e7594..ddc734ab32 100644 --- a/crates/defguard_core/src/handlers/pagination.rs +++ b/crates/defguard_core/src/handlers/pagination.rs @@ -6,6 +6,7 @@ use axum::{ }; use reqwest::StatusCode; use serde::{Deserialize, Deserializer, Serialize, de}; +use utoipa::ToSchema; use crate::error::WebError; @@ -151,13 +152,18 @@ impl fmt::Display for PaginationParams { } } -/// Metadata about the pagination included in response -#[derive(Serialize)] +/// Pagination metadata included in the response. +#[derive(Serialize, ToSchema)] struct PaginationMeta { + /// Number of the returned page, starting from 1. current_page: u32, + /// Requested page size. page_size: u32, + /// Total number of items matching the query. total_items: u32, + /// Total number of pages for the requested page size. total_pages: u32, + /// Number of the next page, or `null` on the last page. next_page: Option, } @@ -185,8 +191,10 @@ impl PaginationMeta { pub type PaginatedApiResult = Result, WebError>; -#[derive(Serialize)] +/// Envelope returned by paginated endpoints. +#[derive(Serialize, ToSchema)] pub struct PaginatedApiResponse { + /// Items of the requested page. data: Vec, pagination: PaginationMeta, } diff --git a/crates/defguard_core/src/handlers/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs index 533ace0b3d..ac7f04c28e 100644 --- a/crates/defguard_core/src/handlers/proxy.rs +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -16,7 +16,7 @@ use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; #[derive(Serialize, Deserialize, ToSchema)] @@ -25,14 +25,16 @@ pub struct ProxyUpdateData { pub enabled: bool, } +/// List edge instances #[utoipa::path( get, path = "/api/v1/proxy", + tag = "proxy", responses( - (status = 200, description = "Edge list", body = [ProxyInfo]), - (status = 401, description = "Unauthorized to get edge list.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to get edge list.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to get edge list.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "All edge instances.", body = [ProxyInfo]), + (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 edge instances.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -60,15 +62,20 @@ pub async fn proxy_list( Ok(ApiResponse::json(proxies, StatusCode::OK)) } +/// Get an edge instance #[utoipa::path( get, path = "/api/v1/proxy/{proxy_id}", + tag = "proxy", + params( + ("proxy_id" = i64, Path, description = "ID of the edge instance."), + ), responses( - (status = 200, description = "Edge details", body = Proxy), - (status = 401, description = "Unauthorized to get edge details.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to get edge details.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Edge not found", body = ApiResponse, example = json!({"msg": "network not found"})), - (status = 500, description = "Unable to get edge details.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Edge instance details.", body = Proxy), + (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 = "Edge instance not found."), + (status = 500, description = "Unable to get edge instance.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -105,16 +112,21 @@ pub(crate) async fn proxy_details( Ok(response) } +/// Rename an edge instance, or enable or disable it #[utoipa::path( put, path = "/api/v1/proxy/{proxy_id}", - request_body = Proxy, + tag = "proxy", + params( + ("proxy_id" = i64, Path, description = "ID of the edge instance."), + ), + request_body = ProxyUpdateData, responses( - (status = 200, description = "Successfully modified edge.", body = ProxyUpdateData), - (status = 401, description = "Unauthorized to modify edge.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to modify an edge.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Edge not found", body = ApiResponse, example = json!({"msg": "proxy not found"})), - (status = 500, description = "Unable to modify edge.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Edge instance updated.", body = Proxy), + (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 = "Edge instance not found."), + (status = 500, description = "Unable to update edge instance.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -181,16 +193,20 @@ pub(crate) async fn update_proxy( Ok(ApiResponse::json(proxy, StatusCode::OK)) } +/// Delete an edge instance #[utoipa::path( delete, path = "/api/v1/proxy/{proxy_id}", - request_body = Proxy, + tag = "proxy", + params( + ("proxy_id" = i64, Path, description = "ID of the edge instance."), + ), responses( - (status = 200, description = "Successfully deleted edge.", body = ApiResponse), - (status = 401, description = "Unauthorized to delete edge.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission delete an edge.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Edge not found", body = ApiResponse, example = json!({"msg": "proxy not found"})), - (status = 500, description = "Unable to delete edge.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Edge instance 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 = "Edge instance not found."), + (status = 500, description = "Unable to delete edge instance.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), diff --git a/crates/defguard_core/src/handlers/reserved.rs b/crates/defguard_core/src/handlers/reserved.rs index b2afa6fb53..5ed44cdf6c 100644 --- a/crates/defguard_core/src/handlers/reserved.rs +++ b/crates/defguard_core/src/handlers/reserved.rs @@ -5,7 +5,7 @@ use axum::{ use serde::Deserialize; use sqlx::PgPool; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{appstate::AppState, auth::AdminRole, error::WebError}; #[derive(Debug, Deserialize, utoipa::ToSchema)] @@ -41,19 +41,21 @@ async fn username_exists(pool: &PgPool, username: &str) -> Result, diff --git a/crates/defguard_core/src/handlers/session_info.rs b/crates/defguard_core/src/handlers/session_info.rs index 30ec0183be..20f902e39e 100644 --- a/crates/defguard_core/src/handlers/session_info.rs +++ b/crates/defguard_core/src/handlers/session_info.rs @@ -2,7 +2,7 @@ use axum::{extract::State, http::StatusCode}; use defguard_common::db::models::{ActiveWizard, User, Wizard}; use serde::Serialize; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{appstate::AppState, auth::SessionExtractor, error::WebError, server_config}; #[derive(Serialize)] @@ -14,6 +14,23 @@ struct SessionInfoResponse { demo_mode: bool, } +/// Get information about the current session +/// +/// The payload tells whether a valid session is present. +#[utoipa::path( + get, + path = "/api/v1/session-info", + tag = "system", + responses( + (status = 200, description = "Session information.", body = Object, example = json!({ + "authorized": true, + "is_admin": true, + "active_wizard": null, + "username": "admin" + })), + (status = 500, description = "Unable to get session information.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn get_session_info( State(appstate): State, session: Result, diff --git a/crates/defguard_core/src/handlers/settings.rs b/crates/defguard_core/src/handlers/settings.rs index 995ca01dd5..62918fb151 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -17,7 +17,7 @@ use defguard_common::{ use sqlx::PgPool; use struct_patch::Patch; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResponseCode, ApiResult}; use crate::{ AppState, auth::{AdminRole, SessionInfo}, @@ -28,7 +28,10 @@ use crate::{ LDAPConnection, sync::{Authority, LdapDryRunAction, LdapDryRunResult, LdapDryRunUser}, }, - license::update_cached_license, + license::{ + License, LicenseTier, get_cached_license, update_cached_license, validate_license, + }, + limits::{Counts, get_counts}, }, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, @@ -37,6 +40,22 @@ use crate::{ static DEFAULT_NAV_LOGO_URL: &str = "/svg/defguard-nav-logo.svg"; static DEFAULT_MAIN_LOGO_URL: &str = "/svg/logo-defguard-white.svg"; +/// Get instance settings +#[utoipa::path( + get, + path = "/api/v1/settings", + tag = "settings", + responses( + (status = 200, description = "Instance settings.", body = Settings), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_settings(_admin: AdminRole, State(appstate): State) -> ApiResult { debug!("Retrieving settings"); if let Some(mut settings) = Settings::get(&appstate.pool).await? { @@ -59,6 +78,26 @@ pub async fn get_settings(_admin: AdminRole, State(appstate): State) - Ok(ApiResponse::default()) } +/// Replace instance settings +/// +/// The whole settings object has to be sent. Use `PATCH` to update selected fields only. +#[utoipa::path( + put, + path = "/api/v1/settings", + tag = "settings", + request_body = Settings, + responses( + (status = 200, description = "Settings updated."), + (status = 400, description = "Invalid settings.", body = ApiErrorResponse, example = json!({"msg": "Invalid settings"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to update settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn update_settings( _admin: AdminRole, session: SessionInfo, @@ -118,6 +157,26 @@ pub(crate) async fn update_settings( Ok(ApiResponse::default()) } +/// Get settings required to render the web UI +/// +/// Public endpoint. Returns only non-sensitive settings. +#[utoipa::path( + get, + path = "/api/v1/settings_essentials", + tag = "settings", + responses( + (status = 200, description = "Essential settings.", body = Object, example = json!({ + "instance_name": "defguard", + "main_logo_url": "/svg/logo-defguard-white.svg", + "nav_logo_url": "/svg/defguard-nav-logo.svg", + "wireguard_enabled": true, + "webhooks_enabled": true, + "worker_enabled": false, + "openid_enabled": true + })), + (status = 500, description = "Unable to get essential settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn get_settings_essentials(Extension(pool): Extension) -> ApiResult { debug!("Retrieving essential settings"); let mut settings = SettingsEssentials::get_settings_essentials(&pool).await?; @@ -133,6 +192,25 @@ pub async fn get_settings_essentials(Extension(pool): Extension) -> ApiR Ok(ApiResponse::json(settings, StatusCode::OK)) } +/// Restore default branding settings +#[utoipa::path( + put, + path = "/api/v1/settings/{id}", + tag = "settings", + params( + ("id" = i64, Path, description = "Not used."), + ), + responses( + (status = 200, description = "Instance settings, with the branding fields restored to defaults.", body = Settings), + (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": "requires privileged access"})), + (status = 500, description = "Unable to restore default branding settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn set_default_branding( _admin: AdminRole, State(appstate): State, @@ -165,6 +243,41 @@ pub(crate) async fn set_default_branding( } } +fn is_license_reactivation( + current_license: Option<&License>, + new_license: &License, + counts: &Counts, +) -> bool { + let Some(current_license) = current_license else { + return false; + }; + let current_license_invalid = + validate_license(Some(current_license), counts, LicenseTier::Business).is_err(); + let new_license_valid = + validate_license(Some(new_license), counts, LicenseTier::Business).is_ok(); + current_license_invalid && new_license_valid +} + +/// Update selected instance settings +/// +/// Only the fields present in the request body are modified. Sending `null` clears a field. +#[utoipa::path( + patch, + path = "/api/v1/settings", + tag = "settings", + request_body = Object, + responses( + (status = 200, description = "Settings updated. The body carries a `license_reactivated` code when an invalid license has been replaced with a valid one.", body = Object, example = json!({"code": "license_reactivated"})), + (status = 400, description = "Invalid settings.", body = ApiErrorResponse, example = json!({"msg": "Invalid settings"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to update settings.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn patch_settings( _admin: AdminRole, State(appstate): State, @@ -213,7 +326,24 @@ pub async fn patch_settings( // clone for event let after = settings.clone(); update_current_settings(&appstate.pool, settings).await?; + let mut license_reactivated = false; if let Some(license_key) = &license { + if let Some(new_key) = license_key.as_deref() + && let Ok(new_license) = License::from_base64(new_key) + { + let counts = get_counts(); + license_reactivated = + is_license_reactivation(get_cached_license().as_ref(), &new_license, &counts); + if license_reactivated { + info!( + "Admin {} replaced a previously invalid license with a valid one", + session.user.username + ); + } + } else { + info!("Couldn't obtain current license"); + } + update_cached_license(license_key.as_deref())?; debug!("Updated cached license after saving settings patch"); } @@ -241,9 +371,31 @@ pub async fn patch_settings( context, event: Box::new(ApiEventType::SettingsUpdatedPartial { before, after }), })?; - Ok(ApiResponse::default()) + + if license_reactivated { + Ok(ApiResponseCode::LicenseReactivated.into()) + } else { + Ok(ApiResponse::default()) + } } +/// Test the LDAP connection using the currently saved settings +#[utoipa::path( + get, + path = "/api/v1/ldap/test", + tag = "LDAP", + responses( + (status = 200, description = "LDAP connection established."), + (status = 400, description = "Unable to connect to LDAP."), + (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 = 500, description = "Unable to test LDAP connection.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn test_ldap_settings(_admin: AdminRole, _license: LicenseInfo) -> ApiResult { debug!("Testing LDAP connection"); if server_config().is_demo_mode { @@ -312,7 +464,26 @@ fn demo_ldap_dry_run_result() -> LdapDryRunResult { } } -/// Tests the LDAP connection using the provided (not yet saved) settings. +/// Test the LDAP connection +/// +/// Uses the settings from the request body, which do not have to be saved yet. +#[utoipa::path( + post, + path = "/api/v1/ldap/test", + tag = "LDAP", + request_body = Object, + responses( + (status = 200, description = "LDAP connection established."), + (status = 400, description = "Unable to connect to LDAP."), + (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 = 500, description = "Unable to test LDAP connection.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn test_submitted_ldap_settings( _admin: AdminRole, _license: LicenseInfo, @@ -337,8 +508,30 @@ pub(crate) async fn test_submitted_ldap_settings( } } -/// Previews the user changes a full LDAP sync would make using the provided (not yet saved) -/// settings. This is strictly read-only: nothing is imported, removed or persisted. +/// Preview the changes a full LDAP sync would make +/// +/// Uses the settings from the request body, which do not have to be saved yet. Read-only: +/// nothing is imported, removed or persisted. +#[utoipa::path( + post, + path = "/api/v1/ldap/dry_run", + tag = "LDAP", + request_body = Object, + responses( + (status = 200, description = "Dry run result.", body = Object, example = json!({ + "defguard": [{"username": "jane", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "action": "add"}], + "ldap": [{"username": "john", "email": "john@example.com", "first_name": "John", "last_name": "Doe", "action": "remove"}] + })), + (status = 400, description = "Unable to connect to LDAP or to perform the dry run."), + (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 = 500, description = "Unable to perform LDAP dry run.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn ldap_dry_run( _admin: AdminRole, _license: LicenseInfo, @@ -379,3 +572,90 @@ pub(crate) async fn ldap_dry_run( } } } + +#[cfg(test)] +mod tests { + use chrono::{TimeDelta, Utc}; + + use super::is_license_reactivation; + use crate::enterprise::{ + license::{License, LicenseTier, SupportType}, + limits::Counts, + }; + + fn license(subscription: bool, valid_until_days: i64) -> License { + License::new( + "test-customer".into(), + subscription, + Some(Utc::now() + TimeDelta::days(valid_until_days)), + None, + None, + LicenseTier::Business, + SupportType::Basic, + Vec::new(), + ) + } + + #[test] + fn reactivation_past_grace_subscription_replaced_by_valid() { + assert!(is_license_reactivation( + Some(&license(true, -20)), + &license(true, 365), + &Counts::default() + )); + } + + #[test] + fn reactivation_expired_non_subscription_replaced_by_valid() { + assert!(is_license_reactivation( + Some(&license(false, -1)), + &license(true, 365), + &Counts::default() + )); + } + + #[test] + fn reactivation_when_new_subscription_still_within_grace() { + assert!(is_license_reactivation( + Some(&license(true, -20)), + &license(true, -5), + &Counts::default() + )); + } + + #[test] + fn no_reactivation_when_current_subscription_within_grace() { + assert!(!is_license_reactivation( + Some(&license(true, -5)), + &license(true, 365), + &Counts::default() + )); + } + + #[test] + fn no_reactivation_when_current_still_valid() { + assert!(!is_license_reactivation( + Some(&license(true, 365)), + &license(true, 365), + &Counts::default() + )); + } + + #[test] + fn no_reactivation_when_new_license_also_unusable() { + assert!(!is_license_reactivation( + Some(&license(true, -20)), + &license(true, -20), + &Counts::default() + )); + } + + #[test] + fn no_reactivation_without_current_license() { + assert!(!is_license_reactivation( + None, + &license(true, 365), + &Counts::default() + )); + } +} diff --git a/crates/defguard_core/src/handlers/ssh_authorized_keys.rs b/crates/defguard_core/src/handlers/ssh_authorized_keys.rs index f6baa792a6..b11337fa99 100644 --- a/crates/defguard_core/src/handlers/ssh_authorized_keys.rs +++ b/crates/defguard_core/src/handlers/ssh_authorized_keys.rs @@ -9,8 +9,9 @@ use defguard_common::db::{ }; use sqlx::{PgExecutor, PgPool, query}; use ssh_key::PublicKey; +use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, user_for_admin_or_self}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, user_for_admin_or_self}; use crate::{ appstate::AppState, auth::SessionInfo, @@ -83,12 +84,24 @@ pub struct SshKeysRequestParams { group: Option, } -/// Fetch public SSH keys for user +/// Get SSH authorized keys in the `authorized_keys` file format /// -/// Meant to be used with `AuthorizedKeysCommand` config option in `sshd`. -/// Should always return a response to partially mitigate user enumeration. -/// Optional query params `username` and `group` are used for filtering users. -/// If no params are specified an empty response is returned. +/// Meant to be used as an `AuthorizedKeysCommand` in `sshd`. Filter the users with the +/// `username` or `group` query parameter. +#[utoipa::path( + get, + path = "/api/v1/ssh_authorized_keys", + tag = "SSH key", + params( + ("username" = Option, Query, description = "Return the keys of this user."), + ("group" = Option, Query, description = "Return the keys of all members of this group."), + ), + responses( + (status = 200, description = "Authorized keys, one per line.", body = String, example = json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ7Gd0DdLZQwQEwXQyLcVQ0hVBHnJcnGRfPtVJHqO1zQ jdoe@example.com")), + (status = 400, description = "Neither username nor group was provided.", body = ApiErrorResponse, example = json!({"msg": "Bad Request"})), + (status = 500, description = "Unable to get authorized keys.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), +)] pub async fn get_authorized_keys( params: Query, State(appstate): State, @@ -146,13 +159,35 @@ pub async fn get_authorized_keys( Ok(ssh_keys.join("\n")) } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, ToSchema)] pub struct AddAuthenticationKeyData { key: String, name: String, key_type: AuthenticationKeyType, } +/// Add an SSH or GPG authentication key to a user +#[utoipa::path( + post, + path = "/api/v1/user/{username}/auth_key", + tag = "SSH key", + request_body = AddAuthenticationKeyData, + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 201, description = "Authentication key added."), + (status = 400, description = "Invalid key.", body = ApiErrorResponse, example = json!({"msg": "Invalid key"})), + (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"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to add authentication key.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn add_authentication_key( State(appstate): State, session: SessionInfo, @@ -219,6 +254,35 @@ pub async fn add_authentication_key( } // GET on user, returns AuthenticationKeyInfo vector in JSON +/// List SSH and GPG authentication keys of a user +#[utoipa::path( + get, + path = "/api/v1/user/{username}/auth_key", + tag = "SSH key", + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "All authentication keys of the user.", body = [Object], example = json!([{ + "id": 1, + "name": "laptop", + "key_type": "ssh", + "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...", + "user_id": 1, + "yubikey_serial": null, + "yubikey_id": null, + "yubikey_name": null + }])), + (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"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to list authentication keys.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn fetch_authentication_keys( State(appstate): State, Path(username): Path, @@ -230,6 +294,28 @@ pub async fn fetch_authentication_keys( Ok(ApiResponse::json(keys_info, StatusCode::OK)) } +/// Delete an authentication key of a user +#[utoipa::path( + delete, + path = "/api/v1/user/{username}/auth_key/{key_id}", + tag = "SSH key", + params( + ("username" = String, Path, description = "Name of the user."), + ("key_id" = i64, Path, description = "ID of the authentication key."), + ), + responses( + (status = 200, description = "Authentication key deleted."), + (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"})), + (status = 400, description = "Key not found.", body = ApiErrorResponse, example = json!({"msg": "Key not found"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to delete authentication key.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn delete_authentication_key( State(appstate): State, session: SessionInfo, @@ -258,11 +344,34 @@ pub async fn delete_authentication_key( Ok(ApiResponse::with_status(StatusCode::OK)) } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, ToSchema)] pub struct RenameRequest { name: String, } +/// Rename an authentication key of a user +#[utoipa::path( + post, + path = "/api/v1/user/{username}/auth_key/{key_id}/rename", + tag = "SSH key", + request_body = RenameRequest, + params( + ("username" = String, Path, description = "Name of the user."), + ("key_id" = i64, Path, description = "ID of the authentication key."), + ), + responses( + (status = 200, description = "Authentication key renamed."), + (status = 400, description = "The key belongs to a YubiKey.", body = ApiErrorResponse, example = json!({"msg": "Rename yubikey instead."})), + (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"})), + (status = 404, description = "User or key not found.", body = ApiErrorResponse, example = json!({"msg": "key not found"})), + (status = 500, description = "Unable to rename authentication key.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn rename_authentication_key( State(appstate): State, session: SessionInfo, diff --git a/crates/defguard_core/src/handlers/static_ips.rs b/crates/defguard_core/src/handlers/static_ips.rs index fe285c34d7..edeba0aa13 100644 --- a/crates/defguard_core/src/handlers/static_ips.rs +++ b/crates/defguard_core/src/handlers/static_ips.rs @@ -8,11 +8,12 @@ use axum::{ use defguard_common::db::Id; use defguard_static_ip::{DeviceLocationIp, LocationDevices, get_ips_for_device, get_ips_for_user}; use serde::Serialize; +use utoipa::ToSchema; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - handlers::{ApiResponse, ApiResult}, + handlers::{ApiErrorResponse, ApiResponse, ApiResult}, }; #[derive(Serialize)] @@ -25,6 +26,32 @@ pub struct DeviceLocationIpsResponse { pub locations: Vec, } +/// List the IP addresses of all devices of a user, grouped by location +#[utoipa::path( + get, + path = "/api/v1/device/user/{username}/ip", + tag = "static IP", + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "IP addresses of all devices of the user, grouped by location.", body = Object, example = json!({ + "locations": [{ + "location_id": 1, + "location_name": "office", + "devices": [{"device_id": 5, "device_name": "laptop", "wireguard_ips": [{"network_part": "10.0.0.", "modifiable_part": "15", "network_prefix": "/24", "ip": "10.0.0.15"}]}] + }] + })), + (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": "requires privileged access"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to get user device IP addresses.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_all_user_device_ips( _admin_role: AdminRole, _session: SessionInfo, @@ -38,6 +65,29 @@ pub async fn get_all_user_device_ips( )) } +/// List the IP addresses of a user device, grouped by location +#[utoipa::path( + get, + path = "/api/v1/device/user/{username}/ip/{device_id}", + tag = "static IP", + params( + ("username" = String, Path, description = "Name of the user."), + ("device_id" = i64, Path, description = "ID of the device."), + ), + responses( + (status = 200, description = "IP addresses of the device, grouped by location.", body = Object, example = json!({ + "locations": [{"location_id": 1, "location_name": "office", "wireguard_ips": [{"network_part": "10.0.0.", "modifiable_part": "15", "network_prefix": "/24", "ip": "10.0.0.15"}]}] + })), + (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": "requires privileged access"})), + (status = 404, description = "User or device not found.", body = ApiErrorResponse, example = json!({"msg": "device not found"})), + (status = 500, description = "Unable to get device IP addresses.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_device_ips( _admin_role: AdminRole, _session: SessionInfo, @@ -51,13 +101,35 @@ pub async fn get_device_ips( )) } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct StaticIpAssignment { pub device_id: i64, pub location_id: Id, + #[schema(value_type = Vec)] pub ips: Vec, } +/// Assign static IP addresses to user devices +#[utoipa::path( + post, + path = "/api/v1/device/user/{username}/ip", + tag = "static IP", + request_body = Vec, + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "IP addresses assigned.", body = Object, example = json!({"message": "Static IPs assigned successfully"})), + (status = 400, description = "Invalid IP assignment.", body = ApiErrorResponse, example = json!({"msg": "IP address is already in use"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to assign IP addresses.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn assign_static_ips( _admin_role: AdminRole, _session: SessionInfo, @@ -81,13 +153,35 @@ pub async fn assign_static_ips( }) } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct ValidateIpAssignmentRequest { pub device_id: i64, + #[schema(value_type = String)] pub ip: IpAddr, pub location: Id, } +/// Check whether a single static IP assignment would be valid +#[utoipa::path( + post, + path = "/api/v1/device/user/{username}/ip/validate", + tag = "static IP", + request_body = ValidateIpAssignmentRequest, + params( + ("username" = String, Path, description = "Name of the user."), + ), + responses( + (status = 200, description = "Validation result.", body = Object, example = json!({"message": "IP assignment is valid"})), + (status = 400, description = "Invalid IP assignment.", body = ApiErrorResponse, example = json!({"msg": "IP address is already in use"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to validate IP assignment.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn validate_ip_assignment( _admin_role: AdminRole, _session: SessionInfo, diff --git a/crates/defguard_core/src/handlers/support.rs b/crates/defguard_core/src/handlers/support.rs index 3dfc4c57db..b70028e7b5 100644 --- a/crates/defguard_core/src/handlers/support.rs +++ b/crates/defguard_core/src/handlers/support.rs @@ -1,6 +1,6 @@ use axum::{extract::State, http::StatusCode}; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{ AppState, auth::{AdminRole, SessionInfo}, @@ -9,6 +9,25 @@ use crate::{ support::dump_config, }; +/// Get instance configuration for support purposes +/// +/// Secrets are stripped from the returned configuration. +#[utoipa::path( + get, + path = "/api/v1/support/configuration", + tag = "support", + responses( + (status = 200, description = "Instance configuration, with the `settings`, `networks`, `devices`, `users`, `config`, `proxies`, `gateways` and `version` sections.", body = Object), + (status = 400, description = "Unable to dump the configuration.", body = Object, example = json!({"err": "database error"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get configuration.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn configuration( _admin: AdminRole, State(appstate): State, @@ -38,6 +57,22 @@ pub(crate) async fn configuration( }) } +/// Get recent instance logs for support purposes +#[utoipa::path( + get, + path = "/api/v1/support/logs", + tag = "support", + responses( + (status = 200, description = "Instance logs as plain text.", body = String, example = json!("2026-08-05T09:12:44.001Z INFO defguard_core: Starting defguard Core 2.1.0\n2026-08-05T09:12:44.512Z INFO defguard_core::grpc: gRPC server listening on 0.0.0.0:50055")), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get logs.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn logs(_admin: AdminRole, session: SessionInfo) -> Result { debug!("User {} dumping app logs", session.user.username); if server_config().is_demo_mode { diff --git a/crates/defguard_core/src/handlers/updates.rs b/crates/defguard_core/src/handlers/updates.rs index 23ea76a828..75ad06e51d 100644 --- a/crates/defguard_core/src/handlers/updates.rs +++ b/crates/defguard_core/src/handlers/updates.rs @@ -1,7 +1,7 @@ use axum::{extract::State, http::StatusCode}; use serde_json::{Value, json}; -use super::{ApiResponse, ApiResult}; +use super::{ApiErrorResponse, ApiResponse, ApiResult}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -9,6 +9,29 @@ use crate::{ version::IncompatibleComponents, }; +/// Get information about a newer defguard release, if any +#[utoipa::path( + get, + path = "/api/v1/updates", + tag = "system", + responses( + (status = 200, description = "Information about the newest release, or `null` when up to date.", body = Object, example = json!({ + "version": "2.1.0", + "release_date": "2026-07-01", + "release_notes_url": "https://github.com/DefGuard/defguard/releases/tag/v2.1.0", + "update_url": "https://defguard.net/download", + "critical": false, + "notes": "Bug fixes and improvements." + })), + (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": "requires privileged access"})), + (status = 500, description = "Unable to check for updates.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn check_new_version(_admin: AdminRole, session: SessionInfo) -> ApiResult { debug!( "User {} is checking if there is a new version available", @@ -26,6 +49,25 @@ pub(crate) async fn check_new_version(_admin: AdminRole, session: SessionInfo) - } // FIXME: Switch to SSE and generally make it better. +/// List connected components whose version is incompatible with this Core +#[utoipa::path( + get, + path = "/api/v1/outdated", + tag = "system", + responses( + (status = 200, description = "All incompatible components.", body = Object, example = json!({ + "gateways": [{"version": "1.3.0", "hostname": "gateway-1", "network_id": "1", "created": "2026-07-01T10:15:00"}], + "proxy": null + })), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list outdated components.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn outdated_components( _admin: AdminRole, State(appstate): State, diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index 96e719f0d2..fb3dcb902a 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -21,7 +21,7 @@ use thiserror::Error; use utoipa::ToSchema; use super::{ - AddUserData, ApiResponse, ApiResult, PasswordChange, PasswordChangeSelf, + AddUserData, ApiErrorResponse, ApiResponse, ApiResult, PasswordChange, PasswordChangeSelf, StartEnrollmentRequest, Username, user_for_admin_or_self, }; use crate::{ @@ -66,7 +66,8 @@ pub(crate) struct BulkStartEnrollmentRequest { /// Whether to send enrollment email to each user (uses user's stored email). #[serde(default)] pub send_enrollment_notification: bool, - /// Optional token expiration override (humantime, e.g. "24h"). Falls back to system setting. + /// Token expiration override in humantime format, for example `24h`. Falls back to the + /// system setting. pub token_expiration_time: Option, } @@ -232,49 +233,61 @@ pub struct UserFilterParams { pub search: Option, } -/// Retrieves list of users. -/// -/// # Returns -/// - List of `UserInfo` objects. -/// -/// - `WebError` if error occurs +/// List users #[utoipa::path( get, path = "/api/v1/user", + tag = "user", params( - ("groups" = Option>, Query, description = "Filter users by group names (OR logic - returns users in any of the specified groups)"), - ("no_group" = Option, Query, description = "Filter users with no group memberships. When combined with groups, returns the union (users in specified groups OR users with no groups)."), - ("search" = Option, Query, description = "Free-text search across username, first name, last name, and email"), - ("sort_by" = Option, Query, description = "Sort key: name (default), username, or email"), - ("sort_order" = Option, Query, description = "Sort direction: asc or desc (default)"), + ("page" = Option, Query, description = "Page number. Defaults to 1."), + ("per_page" = Option, Query, description = "Number of items per page, from 1 to 100. Defaults to 50."), + ("groups" = Option>, Query, description = "Filter by group names. Returns users belonging to any of the given groups."), + ("no_group" = Option, Query, description = "Filter users with no group membership. Combined with `groups`, returns users in the given groups and users with no group."), + ("search" = Option, Query, description = "Free-text search across username, first name, last name, and email."), + ("sort_by" = Option, Query, description = "Sort key: `name`, `username`, or `email`. Defaults to `name`."), + ("sort_order" = Option, Query, description = "Sort direction: `asc` or `desc`. Defaults to `asc`."), ), responses( - (status = 200, description = "List of all users.", body = [UserInfo], example = json!( - [ - { - "authorized_apps": [], - "email": "mail@mail", - "email_mfa_enabled": false, - "enrolled": true, - "first_name": "first_name", - "groups": [ - "admin" - ], - "id": 1, - "is_active": true, - "is_admin": true, - "last_name": "last_name", - "ldap_pass_requires_change": false, - "mfa_enabled": false, - "mfa_method": "None", - "phone": null, - "totp_enabled": false, - "username": "admin" + (status = 200, description = "Paginated list of users.", body = PaginatedApiResponse, example = json!( + { + "data": [ + { + "authorized_apps": [], + "devices": [], + "email": "jane@example.com", + "email_mfa_enabled": false, + "enrolled": true, + "first_name": "Jane", + "groups": [ + "admin" + ], + "has_non_mfa_location_access": false, + "has_non_posture_location_access": false, + "id": 1, + "is_active": true, + "is_admin": true, + "last_name": "Doe", + "ldap_pass_requires_change": false, + "mfa_enabled": false, + "mfa_method": "None", + "name": "Jane Doe", + "password_management_disabled": false, + "phone": null, + "totp_enabled": false, + "username": "jane" + } + ], + "pagination": { + "current_page": 1, + "page_size": 50, + "total_items": 1, + "total_pages": 1, + "next_page": null } - ])), - (status = 401, description = "Unauthorized to list all users.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to list all users.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable return list of users.", body = ApiResponse, example = json!({"msg": "Internal error"})) + })), + (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 users.", body = ApiErrorResponse, example = json!({"msg": "Internal error"})) ), security( ("cookie" = []), @@ -406,48 +419,48 @@ fn apply_sorting(query_builder: &mut QueryBuilder, sorting: &SortParam .push(sorting.sort_order.to_string()); } -/// Get user -/// -/// Return a user based on provided username parameter. -/// -/// # Returns -/// - `UserDetails` object -/// - `WebError` if error occurs +/// Get a user #[utoipa::path( get, path = "/api/v1/user/{username}", + tag = "user", params( - ("username" = String, description = "Name of a user"), + ("username" = String, description = "Name of the user."), ), responses( - (status = 200, description = "Return details about user.", body = UserDetails, example = json!( + (status = 200, description = "User details.", body = UserDetails, example = json!( { "biometric_enabled_devices": [], - "devices": [], "security_keys": [], "user": { "authorized_apps": [], - "email": "mail@defguard.net", + "devices": [], + "email": "jdoe@example.com", "email_mfa_enabled": false, "enrolled": true, - "first_name": "first_name", + "first_name": "John", "groups": [], + "has_non_mfa_location_access": false, + "has_non_posture_location_access": false, "id": 2, "is_active": true, "is_admin": false, - "last_name": "last_name", + "last_name": "Doe", "ldap_pass_requires_change": false, "mfa_enabled": false, "mfa_method": "None", - "phone": "000000000", + "name": "John Doe", + "password_management_disabled": false, + "phone": "+48123456789", "totp_enabled": false, - "username": "username" + "username": "jdoe" } } )), - (status = 401, description = "Unauthorized to return details about user.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to return details about user.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to return user details.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (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": "access denied"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to get user.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -467,42 +480,42 @@ pub(crate) async fn get_user( Ok(ApiResponse::json(user_details, StatusCode::OK)) } -/// Add user -/// -/// Add a new user based on `AddUserData` object. -/// -/// # Returns -/// - `UserInfo` object -/// - `WebError` if error occurs +/// Create a user #[utoipa::path( post, path = "/api/v1/user", - request_body = AddUserData, + tag = "user", + request_body(content = AddUserData, description = "Leave `password` out to enroll the user instead.", example = json!({"username": "jdoe", "first_name": "John", "last_name": "Doe", "email": "jdoe@example.com", "phone": "+48123456789"})), responses( - (status = 201, description = "Add a new user.", body = UserInfo, example = json!( + (status = 201, description = "User created.", body = UserInfo, example = json!( { "authorized_apps": [], - "email": "mail@mail", + "devices": [], + "email": "jdoe@example.com", "email_mfa_enabled": false, "enrolled": true, - "first_name": "first_name", + "first_name": "John", "groups": [], + "has_non_mfa_location_access": false, + "has_non_posture_location_access": false, "id": 3, "is_active": true, "is_admin": false, - "last_name": "last_name", + "last_name": "Doe", "ldap_pass_requires_change": false, "mfa_enabled": false, "mfa_method": "None", - "phone": "000000000", + "name": "John Doe", + "password_management_disabled": false, + "phone": "+48123456789", "totp_enabled": false, - "username": "new_user" + "username": "jdoe" } )), - (status = 400, description = "Bad request, invalid user data.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to create a user.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to create a user.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to create a user.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 400, description = "Invalid user data."), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 500, description = "Unable to create user.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -609,33 +622,25 @@ pub(crate) async fn add_user( Ok(ApiResponse::json(&user_info, StatusCode::CREATED)) } -/// Trigger enrollment process manually -/// -/// Allows admin to start new enrollment for user that is provided as a parameter in endpoint. -/// -/// Thanks to this endpoint you are able to trigger manually enrollment process, where after finishing you receive an enrollment token. -/// -/// **Enrollment token** allows to start the process of gaining access to the company infrastructure **(The enrollment token is valid for 24 hours)**. -/// -/// On the other hand, enrollment url allows the user to access the enrollment form via the web browser or perform the enrollment through the desktop client. +/// Start enrollment for a user /// -/// Optionally this endpoint can send an email notification to the user about the enrollment. -/// -/// # Returns -/// - JSON with `enrollment_token` and `enrollment_url` -/// -/// - `WebError` if error occurs +/// Returns an enrollment token, valid for 24 hours, and the URL the user opens to finish +/// enrollment in a browser or in the desktop client. The user can also be notified by email. #[utoipa::path( post, path = "/api/v1/user/{username}/start_enrollment", + tag = "user", + params( + ("username" = String, Path, description = "Name of the user."), + ), request_body = StartEnrollmentRequest, responses( - (status = 201, description = "Trigger enrollment process manually.", body = ApiResponse, example = json!({"enrollment_token": "your_enrollment_token", "enrollment_url": "your_enrollment_token"})), - (status = 400, description = "Bad request, invalid enrollment request.", body = ApiResponse, example = json!({"msg": "Email notification is enabled, but email was not provided"})), - (status = 401, description = "Unauthorized to start enrollment.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to start enrollment.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Provided user does not exist.", body = ApiResponse, example = json!({"msg": "user not found"})), - (status = 500, description = "Unable to start enrollment.", body = ApiResponse, example = json!({"msg": "unexpected error"})) + (status = 201, description = "Enrollment token and URL.", body = Object, example = json!({"enrollment_token": "5nT2xK9wQpR7vL1yZbH3cD8fG5aQeJmU", "enrollment_url": "https://vpn.example.com/enrollment"})), + (status = 400, description = "Invalid enrollment request.", body = ApiErrorResponse, example = json!({"msg": "Email notification is enabled, but email was not provided"})), + (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 = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to start enrollment.", body = ApiErrorResponse, example = json!({"msg": "unexpected error"})) ), security( ("cookie" = []), @@ -731,30 +736,24 @@ pub(crate) async fn start_enrollment( /// Start remote desktop configuration /// -/// Allows admin to start new remote desktop configuration for user that is provided as a parameter in endpoint. -/// -/// Thanks to this endpoint you are able to receive a new desktop client configuration or update an existing one. Users need the configuration to connect to the company infrastrcture. -/// -/// `Enrollment token` allows to start the process of gaining access to the company infrastructure **(The enrollment token is valid for 24 hours)**. -/// -/// On the other hand, enrollment url allows the user to access the enrollment form via the web browser or perform the enrollment through the desktop client. -/// -/// Optionally this endpoint can send an email notification to the user about the enrollment. -/// -/// # Returns -/// - JSON with `enrollment_token` and `enrollment_url` -/// -/// - `WebError` if error occurs +/// Creates or updates the desktop client configuration of the user. Returns an enrollment +/// token, valid for 24 hours, and the URL the user opens to finish the setup. The user can +/// also be notified by email. #[utoipa::path( post, path = "/api/v1/user/{username}/start_desktop", + tag = "user", + params( + ("username" = String, Path, description = "Name of the user."), + ), request_body = StartEnrollmentRequest, responses( - (status = 201, description = "Trigger enrollment process manually.", body = ApiResponse, example = json!({"enrollment_token": "your_enrollment_token", "enrollment_url": "your_enrollment_token"})), - (status = 400, description = "Bad request, invalid enrollment request.", body = ApiResponse, example = json!({"msg": "Email notification is enabled, but email was not provided"})), - (status = 401, description = "Unauthorized to start remote desktop configuration.", body = ApiResponse, example = json!({"msg": "Can't create desktop configuration enrollment token for disabled user "})), - (status = 404, description = "Provided user does not exist.", body = ApiResponse, example = json!({"msg": "user not found"})), - (status = 500, description = "Unable to start remote desktop configuration.", body = ApiResponse, example = json!({"msg": "unexpected error"})) + (status = 201, description = "Enrollment token and URL.", body = Object, example = json!({"enrollment_token": "5nT2xK9wQpR7vL1yZbH3cD8fG5aQeJmU", "enrollment_url": "https://vpn.example.com/enrollment"})), + (status = 400, description = "Invalid enrollment request.", body = ApiErrorResponse, example = json!({"msg": "Email notification is enabled, but email was not provided"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Can't create desktop configuration enrollment token for disabled user "})), + (status = 403, description = "Requires admin privileges or the request must target your own account.", body = ApiErrorResponse, example = json!({"msg": "requires privileged access"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to start remote desktop configuration.", body = ApiErrorResponse, example = json!({"msg": "unexpected error"})) ), security( ("cookie" = []), @@ -833,27 +832,18 @@ pub(crate) async fn start_remote_desktop_configuration( )) } -/// Verify if the user is available -/// -/// Check if user is available by provided `Username` object. -/// Username is unique so database returns only single user or nothing. -/// -/// # Returns -/// - `200` if the user is available -/// -/// - `WebError` if error occurs -/// -/// **Please take notice that if user exists in database, endpoint will return status code 400.** +/// Check whether a username is available #[utoipa::path( post, path = "/api/v1/user/available", + tag = "user", request_body = Username, responses( - (status = 200, description = "Provided username is available to use.", body = ApiResponse, example = json!({})), - (status = 400, description = "Bad request, provided username is not available or username is invalid.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to check is username available.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to check is username available.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to check is username available.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Username is available."), + (status = 400, description = "Username is invalid or already taken."), + (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 check username.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -879,31 +869,25 @@ pub(crate) async fn username_available( Ok(ApiResponse::with_status(status)) } -/// Modify user -/// -/// Update user's data basing on `UserInfo` object, it can also remove/add authorized apps and groups assigned to user. -/// -/// Endpoint is able to disable a user, but **admin cannot disable himself**. -/// -/// Disabling a user can be done by setting `is_active` to `false`. +/// Update a user /// -/// -/// # Returns -/// - empty JSON -/// -/// - `WebError` if error occurs +/// Can also add or remove the user's groups and authorized apps. Set `is_active` to +/// `false` to disable the user. An admin cannot disable their own account. #[utoipa::path( put, path = "/api/v1/user/{username}", + tag = "user", params( - ("username" = String, description = "Name of a user"), + ("username" = String, description = "Name of the user."), ), request_body = UserInfo, responses( - (status = 200, description = "User has been updated."), - (status = 400, description = "Bad request, unable to change user data. Verify user data that you want to update.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to modify user.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 500, description = "Unable to modify user.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "User updated."), + (status = 400, description = "Invalid user data."), + (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"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to update user.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1132,25 +1116,23 @@ pub(crate) async fn modify_user( Ok(ApiResponse::default()) } -/// Delete user -/// -/// Deletes user, however, **you can't delete yourself as an administrator**. +/// Delete a user /// -/// # Returns -/// - `WebError` if error occurs +/// You cannot delete your own account. #[utoipa::path( delete, path = "/api/v1/user/{username}", + tag = "user", params( - ("username" = String, description = "Name of a user"), + ("username" = String, description = "Name of the user."), ), responses( - (status = 200, description = "User has been deleted."), - (status = 400, description = "Bad request, unable to delete user.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to delete user.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to delete user.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "User does not exist with username: ", body = ApiResponse, example = json!({"msg": "User not found"})), - (status = 500, description = "Unable to delete user.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "User deleted."), + (status = 400, description = "You cannot delete your own account."), + (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 = "User not found.", body = ApiErrorResponse, example = json!({"msg": "User not found"})), + (status = 500, description = "Unable to delete user.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1216,21 +1198,18 @@ async fn user_password_management_disabled(pool: &PgPool, user: &User) -> sq /// Change your own password /// -/// Changes your own password basing on `PasswordChangeSelf` object. -/// -/// It can return error if password is not strong enough. -/// -/// # Returns -/// - `WebError` if error occurs +/// Fails when the new password is not strong enough. #[utoipa::path( put, path = "/api/v1/user/change_password", + tag = "user", request_body = PasswordChangeSelf, responses( - (status = 200, description = "Pasword has been changed.", body = ApiResponse, example = json!({})), - (status = 400, description = "Bad request, provided passwords are not same or new password does not satisfy requirements.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to change password.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 500, description = "Unable to change your password", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Password changed."), + (status = 400, description = "Passwords do not match, or the new password does not satisfy the requirements."), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Password management is disabled for this user.", body = ApiErrorResponse, example = json!({"msg": "Password management is disabled for this user"})), + (status = 500, description = "Unable to change your password.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1288,30 +1267,25 @@ pub(crate) async fn change_self_password( Ok(ApiResponse::with_status(StatusCode::OK)) } -/// Change user password -/// -/// Change user password basing on `PasswordChange` object, it can return error if password is not strong enough. -/// -/// This endpoint doesn't allow you to **change your own** password. +/// Change the password of a user /// -/// If you want to change your own password please go to: `/api/v1/user/change_password`. -/// -/// # Returns -/// - `WebError` if error occurs +/// Fails when the new password is not strong enough. Cannot be used to change your own +/// password, use `PUT /api/v1/user/change_password` for that. #[utoipa::path( put, path = "/api/v1/user/{username}/password", + tag = "user", params( - ("username" = String, description = "Name of a user"), + ("username" = String, description = "Name of the user."), ), request_body = PasswordChange, responses( - (status = 200, description = "Password has been changed.", body = ApiResponse, example = json!({})), - (status = 400, description = "Bad request, password does not satisfy requirements. This endpoint does not change your own password.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to change password.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to change user password.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Cannot change user password that does not exist.", body = ApiResponse, example = json!({})), - (status = 500, description = "Unable to change user password", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Password changed."), + (status = 400, description = "Password does not satisfy the requirements, or the request targets your own account."), + (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 = "User not found."), + (status = 500, description = "Unable to change user password.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1380,27 +1354,24 @@ pub(crate) async fn change_password( } } -/// Reset user password -/// -/// Reset user password, it will send a new enrollment token to the user's email. +/// Send a password reset email to a user /// -/// **This endpoint doesn't allow you to reset your own password.** -/// -/// # Returns -/// - `WebError` if error occurs +/// Sends a new enrollment token to the user's email. You cannot reset your own password +/// this way. #[utoipa::path( post, path = "/api/v1/user/{username}/reset_password", + tag = "user", params( - ("username" = String, description = "Name of a user"), + ("username" = String, description = "Name of the user."), ), responses( - (status = 200, description = "Successfully reset user password."), - (status = 400, description = "Bad request, this endpoint does not change your own password.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to change password.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to change user password.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Cannot reset user password that does not exist.", body = ApiResponse, example = json!({})), - (status = 500, description = "Unable to send reset password to email", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Password reset email sent."), + (status = 400, description = "This endpoint does not reset your own password."), + (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 = "User not found."), + (status = 500, description = "Unable to send password reset email.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1477,25 +1448,21 @@ pub(crate) async fn reset_password( } } -/// Delete security key -/// -/// Delete WebAuthn security key that allows users to authenticate. -/// -/// # Returns -/// - `WebError` if error occurs +/// Delete a security key of a user #[utoipa::path( delete, path = "/api/v1/user/{username}/security_key/{id}", + tag = "user", params( - ("username" = String, description = "Name of a user"), - ("id" = i64, description = "ID of security key that could point to passkey") + ("username" = String, description = "Name of the user."), + ("id" = i64, description = "ID of the security key.") ), responses( - (status = 200, description = "Successfully deleted security key."), - (status = 401, description = "Unauthorized to delete security key.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to delete security key.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Incorrect authorized app, not found.", body = ApiResponse, example = json!({"msg": "security key not found"})), - (status = 500, description = "Cannot delete authorized app.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Security key deleted."), + (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"})), + (status = 404, description = "Security key not found.", body = ApiErrorResponse, example = json!({"msg": "wrong security key"})), + (status = 500, description = "Unable to delete security key.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1542,42 +1509,41 @@ pub(crate) async fn delete_security_key( } } -/// Returns your data -/// -/// Endpoint returns the data associated with the current session user -/// -/// # Returns -/// - `UserInfo` object -/// -/// - `WebError` if error occurs +/// Get the currently authenticated user #[utoipa::path( get, path = "/api/v1/me", + tag = "user", responses( - (status = 200, description = "Returns your own data.", body = UserInfo, example = json!( + (status = 200, description = "Your own account details.", body = UserInfo, example = json!( { "authorized_apps": [], - "email": "mail@mail", + "devices": [], + "email": "jane@example.com", "email_mfa_enabled": false, "enrolled": true, - "first_name": "first_name", + "first_name": "Jane", "groups": [ "admin" ], + "has_non_mfa_location_access": false, + "has_non_posture_location_access": false, "id": 1, "is_active": true, "is_admin": true, - "last_name": "last_name", + "last_name": "Doe", "ldap_pass_requires_change": false, "mfa_enabled": false, "mfa_method": "None", - "phone": 000_000_000, + "name": "Jane Doe", + "password_management_disabled": false, + "phone": "+48123456789", "totp_enabled": false, - "username": "username" + "username": "jane" } )), - (status = 401, description = "Unauthorized return own user data.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 500, description = "Cannot retrieve own user data.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Unable to get your own account details.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1596,25 +1562,21 @@ pub async fn me(session: SessionInfo, State(appstate): State) -> ApiRe Ok(ApiResponse::json(user_info, StatusCode::OK)) } -/// Delete OAuth token. -/// -/// Deletes an authorized application by `OAuth2` ID. -/// -/// # Returns -/// - `WebError` if error occurs +/// Delete an authorized OAuth2 application of a user #[utoipa::path( delete, path = "/api/v1/user/{username}/oauth_app/{oauth2client_id}", + tag = "user", params( - ("username" = String, description = "Name of a user"), - ("oauth2client_id" = i64, description = "id of OAuth2 client") + ("username" = String, description = "Name of the user."), + ("oauth2client_id" = i64, description = "ID of the OAuth2 client.") ), responses( - (status = 200, description = "Successfully deleted authorized app."), - (status = 401, description = "Unauthorized to delete authorized app.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to delete authorized app.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 404, description = "Incorrect authorized app, not found.", body = ApiResponse, example = json!({"msg": "Authorized app not found"})), - (status = 500, description = "Cannot delete authorized app.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Authorized OAuth2 application deleted."), + (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"})), + (status = 404, description = "Authorized OAuth2 application not found.", body = ApiErrorResponse, example = json!({"msg": "Authorized app not found"})), + (status = 500, description = "Unable to delete authorized OAuth2 application.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1663,20 +1625,18 @@ pub(crate) async fn delete_authorized_app( /// Bulk disable users /// -/// Disables every user listed in `BulkUserOperationRequest`. Admin only. -/// The session user cannot disable themselves; the request is rejected -/// with 400 if the session user's id is in the list. The request is also -/// rejected with 400 if any of the supplied ids does not exist. +/// The request is rejected when any of the given IDs does not exist or is your own. #[utoipa::path( post, path = "/api/v1/user/bulk-disable", - request_body = BulkUserOperationRequest, + tag = "user", + request_body(content = BulkUserOperationRequest, example = json!({"users": [1, 4, 6, 23, 35]})), responses( (status = 200, description = "Users disabled."), - (status = 400, description = "Bad request. List contains the session user or unknown user ids.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "Forbidden.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Internal server error.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 400, description = "The list contains unknown user IDs or your own account.", body = ApiErrorResponse, example = json!({"msg": "Request contained users that don't exist in db."})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to disable users.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1762,18 +1722,18 @@ pub(crate) async fn bulk_disable_users( /// Bulk enable users /// -/// Enables every user listed in `BulkUserOperationRequest`. Admin only. -/// The request is rejected with 400 if any of the supplied ids does not exist. +/// The request is rejected when any of the given IDs does not exist. #[utoipa::path( post, path = "/api/v1/user/bulk-enable", - request_body = BulkUserOperationRequest, + tag = "user", + request_body(content = BulkUserOperationRequest, example = json!({"users": [1, 4, 6, 23, 35]})), responses( (status = 200, description = "Users enabled."), - (status = 400, description = "Bad request. List contains unknown user ids.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "Forbidden.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Internal server error.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 400, description = "The list contains unknown user IDs.", body = ApiErrorResponse, example = json!({"msg": "Request contained users that don't exist in db."})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to enable users.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1873,20 +1833,18 @@ pub(crate) async fn bulk_enable_users( /// Bulk delete users /// -/// Deletes every user listed in `BulkUserOperationRequest`. Admin only. -/// The session user cannot delete themselves; the request is rejected -/// with 400 if the session user's id is in the list. The request is also -/// rejected with 400 if any of the supplied ids does not exist. +/// The request is rejected when any of the given IDs does not exist or is your own. #[utoipa::path( post, path = "/api/v1/user/bulk-delete", - request_body = BulkUserOperationRequest, + tag = "user", + request_body(content = BulkUserOperationRequest, example = json!({"users": [1, 4, 6, 23, 35]})), responses( (status = 200, description = "Users deleted."), - (status = 400, description = "Bad request. List contains the session user or unknown user ids.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "Forbidden.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Internal server error.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 400, description = "The list contains unknown user IDs or your own account.", body = ApiErrorResponse, example = json!({"msg": "Request contained users that don't exist in db."})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to delete users.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1969,25 +1927,22 @@ pub(crate) async fn bulk_delete_users( Ok(ApiResponse::default()) } -/// Bulk start enrollment -/// -/// Starts the self-enrollment process for every user listed in -/// `BulkStartEnrollmentRequest`. Admin only. +/// Bulk start user enrollment /// -/// Disabled users are skipped and counted in the `skipped` response field; -/// already-enrolled users are re-enrolled (enrollment_pending reset to true). -/// The request is rejected with 400 if any of the supplied ids does not exist -/// or if the session user's id is in the list. +/// Disabled users are skipped and counted in the `skipped` response field. Already +/// enrolled users are enrolled again. The request is rejected when any of the given IDs +/// does not exist or is your own. #[utoipa::path( post, path = "/api/v1/user/bulk-start-enrollment", - request_body = BulkStartEnrollmentRequest, + tag = "user", + request_body(content = BulkStartEnrollmentRequest, example = json!({"users": [1, 4, 6, 23, 35], "send_enrollment_notification": true, "token_expiration_time": "24h"})), responses( - (status = 200, description = "Enrollment started.", body = ApiResponse, example = json!({"started": 3, "skipped": 1})), - (status = 400, description = "Bad request. List contains the session user or unknown user ids.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "Forbidden.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Internal server error.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Enrollment started.", body = Object, example = json!({"started": 3, "skipped": 1})), + (status = 400, description = "The list contains unknown user IDs or your own account.", body = ApiErrorResponse, example = json!({"msg": "Request contained users that don't exist in db."})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to start enrollments.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), diff --git a/crates/defguard_core/src/handlers/webhooks.rs b/crates/defguard_core/src/handlers/webhooks.rs index c35d083c88..d6bb090450 100644 --- a/crates/defguard_core/src/handlers/webhooks.rs +++ b/crates/defguard_core/src/handlers/webhooks.rs @@ -3,8 +3,9 @@ use axum::{ http::StatusCode, }; use defguard_common::{config::server_config, db::Id}; +use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, WebHookData}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, WebHookData}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -12,6 +13,24 @@ use crate::{ events::{ApiEvent, ApiEventType, ApiRequestContext}, }; +/// Create a webhook +#[utoipa::path( + post, + path = "/api/v1/webhook", + tag = "webhook", + request_body = WebHookData, + responses( + (status = 201, description = "Webhook created."), + (status = 400, description = "Unable to save the webhook."), + (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": "requires privileged access"})), + (status = 500, description = "Unable to create webhook.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn add_webhook( _admin: AdminRole, session: SessionInfo, @@ -41,12 +60,48 @@ pub async fn add_webhook( } // TODO: paginate +/// List webhooks +#[utoipa::path( + get, + path = "/api/v1/webhook", + tag = "webhook", + responses( + (status = 200, description = "All webhooks.", body = [WebHook]), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list webhooks.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn list_webhooks(_admin: AdminRole, State(appstate): State) -> ApiResult { let webhooks = WebHook::all(&appstate.pool).await?; Ok(ApiResponse::json(webhooks, StatusCode::OK)) } +/// Get a webhook +#[utoipa::path( + get, + path = "/api/v1/webhook/{id}", + tag = "webhook", + params( + ("id" = i64, Path, description = "ID of the webhook."), + ), + responses( + (status = 200, description = "Webhook details.", body = WebHook), + (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": "requires privileged access"})), + (status = 404, description = "Webhook not found."), + (status = 500, description = "Unable to get webhook.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn get_webhook( _admin: AdminRole, State(appstate): State, @@ -58,6 +113,27 @@ pub async fn get_webhook( } } +/// Update a webhook +#[utoipa::path( + put, + path = "/api/v1/webhook/{id}", + tag = "webhook", + request_body = WebHookData, + params( + ("id" = i64, Path, description = "ID of the webhook."), + ), + responses( + (status = 200, description = "Webhook updated."), + (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": "requires privileged access"})), + (status = 404, description = "Webhook not found."), + (status = 500, description = "Unable to update webhook.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn change_webhook( _admin: AdminRole, session: SessionInfo, @@ -100,6 +176,26 @@ pub async fn change_webhook( Ok(ApiResponse::with_status(status)) } +/// Delete a webhook +#[utoipa::path( + delete, + path = "/api/v1/webhook/{id}", + tag = "webhook", + params( + ("id" = i64, Path, description = "ID of the webhook."), + ), + responses( + (status = 200, description = "Webhook 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": "requires privileged access"})), + (status = 404, description = "Webhook not found."), + (status = 500, description = "Unable to delete webhook.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn delete_webhook( _admin: AdminRole, State(appstate): State, @@ -123,11 +219,32 @@ pub async fn delete_webhook( Ok(ApiResponse::with_status(status)) } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct ChangeStateData { pub enabled: bool, } +/// Enable or disable a webhook +#[utoipa::path( + post, + path = "/api/v1/webhook/{id}", + tag = "webhook", + request_body = ChangeStateData, + params( + ("id" = i64, Path, description = "ID of the webhook."), + ), + responses( + (status = 200, description = "Webhook state changed."), + (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": "requires privileged access"})), + (status = 404, description = "Webhook not found."), + (status = 500, description = "Unable to change webhook state.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub async fn change_enabled( _admin: AdminRole, session: SessionInfo, diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index ff96b79b46..ec4148c3fd 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -21,7 +21,10 @@ use serde_json::{Value, json}; use sqlx::PgPool; use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, WebError, device_for_admin_or_self, user_for_admin_or_self}; +use super::{ + ApiErrorResponse, ApiResponse, ApiResult, WebError, device_for_admin_or_self, + user_for_admin_or_self, +}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -139,6 +142,32 @@ impl WireguardNetworkData { Ok(()) } + /// Rejects service-location mode combined with location MFA: core cannot serve it and the + /// client cannot represent it (`Location::is_service_location()` requires MFA disabled). + pub(crate) fn validate_service_location_mfa(&self) -> Result<(), WebError> { + if self.service_location_mode == ServiceLocationMode::Disabled + || self.location_mfa_mode == LocationMfaMode::Disabled + { + return Ok(()); + } + + Err(WebError::BadRequest( + "Service location mode cannot be combined with location MFA".into(), + )) + } + + /// Rejects a zero (or negative) keepalive interval to prevent idle service locations + /// from disconnecting. + pub(crate) fn validate_keepalive_interval(&self) -> Result<(), WebError> { + if self.keepalive_interval >= 1 { + return Ok(()); + } + + Err(WebError::BadRequest( + "keepalive_interval must be at least 1".into(), + )) + } + pub(crate) fn validate_allowed_groups(&self) -> Result<(), WebError> { if self.allow_all_groups || !self.allowed_groups.is_empty() { return Ok(()); @@ -150,12 +179,12 @@ impl WireguardNetworkData { } // Used in process of importing network from WireGuard config. -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub(crate) struct MappedDevices { devices: Vec, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub(crate) struct ImportNetworkData { name: String, endpoint: String, @@ -164,29 +193,24 @@ pub(crate) struct ImportNetworkData { allowed_groups: Vec, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, ToSchema)] pub struct ImportedNetworkData { pub network: WireguardNetwork, pub devices: Vec, } -/// Create new network -/// -/// Create new network based on `WireguardNetworkData` object. -/// -/// # Returns -/// - `WireguardNetwork` object -/// -/// - `WebError` if error occurs +/// Create a network #[utoipa::path( post, path = "/api/v1/network", - request_body = WireguardNetworkData, + 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"})), responses( - (status = 201, description = "Successfully created network.", body = WireguardNetwork), - (status = 401, description = "Unauthorized to create network.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to create a network.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to create network.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (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"})), + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 403, description = "Requires admin privileges.", body = ApiErrorResponse, example = json!({"msg": "access denied"})), + (status = 500, description = "Unable to create network.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -233,6 +257,8 @@ 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()?; let allowed_ips = data.parse_allowed_ips(); @@ -309,24 +335,22 @@ async fn find_network(id: Id, pool: &PgPool) -> Result, Web .ok_or_else(|| WebError::ObjectNotFound(format!("Network {id} not found"))) } -/// Modify network -/// -/// Modify existing network basing on `WireguardNetworkData` object. -/// -/// # Returns -/// - `WireguardNetwork` object -/// -/// - `WebError` if error occurs +/// Update a network #[utoipa::path( put, path = "/api/v1/network/{network_id}", + tag = "network", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), request_body = WireguardNetworkData, responses( - (status = 200, description = "Successfully modified network.", body = WireguardNetwork), - (status = 401, description = "Unauthorized to modify network.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to modify a network.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Network not found", body = ApiResponse, example = json!({"msg": "network not found"})), - (status = 500, description = "Unable to modify network.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Network updated.", body = WireguardNetwork), + (status = 400, description = "Invalid location settings.", body = ApiErrorResponse, example = json!({"msg": "Enterprise license required."})), + (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 = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to update network.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -362,6 +386,8 @@ 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()?; let network = find_network(network_id, &appstate.pool).await?; @@ -385,35 +411,25 @@ pub(crate) async fn modify_network( network.acl_enabled = data.acl_enabled; network.acl_default_allow = data.acl_default_allow; network.allowed_ips_from_acl = data.allowed_ips_from_acl; - network.service_location_mode = if data.location_mfa_mode == LocationMfaMode::Disabled { - data.service_location_mode - } else { - warn!( - "Disabling service location mode for location {} because location MFA is enabled", - network.name - ); - ServiceLocationMode::Disabled - }; + network.service_location_mode = data.service_location_mode; network.location_mfa_mode = data.location_mfa_mode; network.save(&mut *transaction).await?; network .set_allowed_groups(&mut transaction, &data.allowed_groups) .await?; + let _events = sync_location_allowed_devices(&network, &mut transaction, None).await?; let peers = get_location_allowed_peers(&network, &mut transaction).await?; let maybe_firewall_config = try_get_location_firewall_config(&network, &mut transaction).await?; - appstate.send_gateway_command(GatewayCommand::NetworkModified( - network.id, - network.clone(), - peers, - maybe_firewall_config, - )); + let gateway_command = + GatewayCommand::NetworkModified(network.id, network.clone(), peers, maybe_firewall_config); // commit DB transaction transaction.commit().await?; + appstate.send_gateway_command(gateway_command); info!( "User {} updated WireGuard network {network_id}", @@ -429,21 +445,20 @@ pub(crate) async fn modify_network( Ok(ApiResponse::json(network, StatusCode::OK)) } -/// Delete network -/// -/// # Returns -/// - empty JSON -/// -/// - `WebError` if error occurs +/// Delete a network #[utoipa::path( delete, path = "/api/v1/network/{network_id}", + tag = "network", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), responses( - (status = 200, description = "Successfully deleted network.", body = ApiResponse), - (status = 401, description = "Unauthorized to delete network.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to delete a network.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Network not found", body = ApiResponse, example = json!({"msg": "network not found"})), - (status = 500, description = "Unable to delete network.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Network 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 = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to delete network.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -486,22 +501,16 @@ pub(crate) async fn delete_network( Ok(ApiResponse::default()) } -/// List of all networks -/// -/// Retrieve list of all networks -/// -/// # Returns -/// - List of `WireguardNetworkInfo` objects -/// -/// - `WebError` if error occurs +/// List networks #[utoipa::path( get, path = "/api/v1/network", + tag = "network", responses( - (status = 200, description = "List of all networks", body = [WireguardNetworkInfo]), - (status = 401, description = "Unauthorized to list all networks.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to list all networks.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to list all networks.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "All networks.", body = [WireguardNetworkInfo]), + (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 networks.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -535,22 +544,16 @@ pub async fn list_networks(_role: AdminRole, State(appstate): State) - Ok(ApiResponse::json(network_info, StatusCode::OK)) } -/// Number of all networks -/// -/// Retrieve count of all networks. -/// -/// # Returns -/// - `LocationsCount` object -/// -/// - `WebError` if error occurs +/// Count networks #[utoipa::path( get, path = "/api/v1/network/count", + tag = "network", responses( - (status = 200, description = "Count of all networks", body = LocationsCount), - (status = 401, description = "Unauthorized to count networks.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to count networks.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 500, description = "Unable to count networks.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Number of networks.", body = LocationsCount), + (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 count networks.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -568,23 +571,20 @@ pub async fn count_networks(_role: AdminRole, State(appstate): State) )) } -/// Details of network -/// -/// Retrieve details about network with `network_id`. -/// -/// # Returns -/// - `WireguardNetworkInfo` object -/// -/// - `WebError` if error occurs +/// Get a network #[utoipa::path( get, path = "/api/v1/network/{network_id}", + tag = "network", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), responses( - (status = 200, description = "Network details", body = WireguardNetworkInfo), - (status = 401, description = "Unauthorized to get network details.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to get network details.", body = ApiResponse, example = json!({"msg": "access denied"})), - (status = 404, description = "Network not found", body = ApiResponse, example = json!({"msg": "network not found"})), - (status = 500, description = "Unable to get network details.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Network details.", body = WireguardNetworkInfo), + (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 = "Network not found."), + (status = 500, description = "Unable to get network.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -623,10 +623,26 @@ pub(crate) async fn network_details( Ok(response) } -/// Returns state of gateways in a given network -/// -/// # Returns -/// Returns `Vec` for requested network. +/// Get the state of gateways in a location +#[utoipa::path( + get, + path = "/api/v1/network/{network_id}/gateways", + tag = "gateway", + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), + responses( + (status = 200, description = "Gateway status in the location.", body = [GatewayInfo]), + (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": "requires privileged access"})), + (status = 404, description = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to get gateway status.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn gateway_status( Path(network_id): Path, _role: AdminRole, @@ -641,10 +657,24 @@ pub(crate) async fn gateway_status( Ok(ApiResponse::json(gateways, StatusCode::OK)) } -/// Returns state of gateways for all networks +/// Get the state of gateways in all locations /// -/// Returns current state of gateways as `HashMap>` where key is ID of -/// `WireguardNetwork`. +/// Each entry carries the ID of the location the gateway belongs to. +#[utoipa::path( + get, + path = "/api/v1/network/gateways", + tag = "gateway", + responses( + (status = 200, description = "Gateway status in all locations.", body = [GatewayInfo]), + (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": "requires privileged access"})), + (status = 500, description = "Unable to get gateway status.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn all_gateways_status( _role: AdminRole, State(appstate): State, @@ -656,6 +686,28 @@ pub(crate) async fn all_gateways_status( Ok(ApiResponse::json(gateways, StatusCode::OK)) } +/// Import a network from a WireGuard configuration file +/// +/// Devices found in the configuration are returned unmapped; use +/// `POST /api/v1/network/{network_id}/devices` to assign them to users. +#[utoipa::path( + post, + path = "/api/v1/network/import", + tag = "network", + request_body = ImportNetworkData, + responses( + (status = 201, description = "Network imported.", body = ImportedNetworkData), + (status = 400, description = "Invalid WireGuard configuration.", body = ApiErrorResponse, example = json!({"msg": "Invalid config file"})), + (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": "requires privileged access"})), + (status = 413, description = "Configuration file too large.", body = ApiErrorResponse), + (status = 500, description = "Unable to import network.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn import_network( _role: AdminRole, State(appstate): State, @@ -714,6 +766,31 @@ pub(crate) async fn import_network( } // This is used exclusively for the wizard to map imported devices to users. +/// Assign imported devices to users +/// +/// Used to finish the network import started with `POST /api/v1/network/import`. +#[utoipa::path( + post, + path = "/api/v1/network/{network_id}/devices", + tag = "network", + request_body = MappedDevices, + params( + ("network_id" = i64, Path, description = "ID of the network."), + ), + responses( + (status = 201, description = "Devices assigned to users."), + (status = 204, description = "Empty device list, nothing was assigned."), + (status = 400, description = "Invalid device data.", body = ApiErrorResponse, example = json!({"msg": "Public key invalid"})), + (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": "requires privileged access"})), + (status = 404, description = "Network not found.", body = ApiErrorResponse, example = json!({"msg": "network not found"})), + (status = 500, description = "Unable to assign devices.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn add_user_devices( _role: AdminRole, session: SessionInfo, @@ -765,31 +842,20 @@ pub(crate) struct AddDeviceResult { device: Device, } -/// Add device -/// -/// Add a new device for a user by sending `AddDevice` object. -/// -/// Notice that `wireguard_pubkey` must be unique to successfully add the device. -/// -/// You can't add devices for `disabled` users, unless you are an admin. -/// -/// Device will be added to all networks in your company infrastructure. +/// Add a device for a user /// -/// User will receive all new device details on email. -/// -/// # Returns -/// - `AddDeviceResult` object -/// -/// - `WebError` if error occurs +/// The device is added to every location. `wireguard_pubkey` has to be unique. Devices of +/// disabled users can only be added by an admin. #[utoipa::path( post, path = "/api/v1/device/{device_id}", + tag = "device", params( - ("device_id" = String, description = "ID of device.") + ("device_id" = String, description = "Name of the user the device is created for.") ), - request_body = AddDevice, + request_body(content = AddDevice, example = json!({"name": "work laptop", "wireguard_pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg="})), responses( - (status = 201, description = "Successfully added a new device for a user.", body = AddDeviceResult, example = json!( + (status = 201, description = "Device added.", body = AddDeviceResult, example = json!( { "configs": [ { @@ -807,18 +873,22 @@ pub(crate) struct AddDeviceResult { } ], "device": { - "id": 0, - "name": "name", - "wireguard_pubkey": "wireguard_pubkey", - "user_id": 0, - "created": "2024-07-10T10:25:43.231Z" + "id": 1, + "name": "work laptop", + "wireguard_pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=", + "user_id": 1, + "created": "2024-07-10T10:25:43.231", + "device_type": "user", + "description": null, + "configured": true } } )), - (status = 400, description = "Bad request, no networks found or device with pubkey that you want to send with already exists.", body = ApiResponse, example = json!({})), - (status = 401, description = "Unauthorized to add a new device for a user.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to add a new device for a user. You can't add a new device for a disabled user.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), - (status = 500, description = "Cannot add a new device for a user.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 400, description = "No networks are configured, or a device with this public key already exists.", body = ApiErrorResponse, example = json!({"msg": "Failed to add device , identical pubkey () already exists"})), + (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"})), + (status = 404, description = "User not found.", body = ApiErrorResponse, example = json!({"msg": "user not found"})), + (status = 500, description = "Unable to add device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -851,8 +921,10 @@ pub(crate) async fn add_device( return Err(WebError::Forbidden("Manual device management is disabled")); } - // Let admins manage devices for disabled users - if !user.is_active && !session.is_admin { + // Disabled users' devices never get network access (see + // `is_device_allowed_in_network`), and get stripped on the next sync even if + // briefly assigned. + if !user.is_active { warn!( "User {} tried to add a device for a disabled user {username}", session.user.username @@ -984,39 +1056,35 @@ pub(crate) async fn add_device( Ok(ApiResponse::json(result, StatusCode::CREATED)) } -/// Modify device -/// -/// Update a device for a user by sending `ModifyDevice` object. -/// -/// Notice that `wireguard_pubkey` must be different from server's pubkey. +/// Update a device /// -/// Endpoint will trigger new update in gateway server. -/// -/// # Returns -/// - `Device` object -/// -/// - `WebError` if error occurs +/// `wireguard_pubkey` has to be different from the public key of the location. #[utoipa::path( put, path = "/api/v1/device/{device_id}", + tag = "device", params( - ("device_id" = i64, description = "ID of device.") + ("device_id" = i64, description = "ID of the device.") ), request_body = ModifyDevice, responses( - (status = 200, description = "Successfully updated a device.", body = Device, example = json!( + (status = 200, description = "Device updated.", body = Device, example = json!( { - "id": 0, - "name": "name", - "wireguard_pubkey": "wireguard_pubkey", - "user_id": 0, - "created": "2024-07-10T10:25:43.231Z" + "id": 1, + "name": "work laptop", + "wireguard_pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=", + "user_id": 1, + "created": "2024-07-10T10:25:43.231", + "device_type": "user", + "description": null, + "configured": true } )), - (status = 400, description = "Bad request, no networks found or device with pubkey that you want to send with is a server's pubkey.", body = ApiResponse, example = json!({"msg": "device's pubkey must be different from server's pubkey"})), - (status = 401, description = "Unauthorized to update a device.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 404, description = "Device not found.", body = ApiResponse, example = json!({"msg": "device id not found"})), - (status = 500, description = "Cannot update a device.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 400, description = "No networks are configured, or the public key belongs to a location.", body = ApiErrorResponse, example = json!({"msg": "device's pubkey must be different from server's pubkey"})), + (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"})), + (status = 404, description = "Device not found.", body = ApiErrorResponse, example = json!({"msg": "device id not found"})), + (status = 500, description = "Unable to update device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1119,33 +1187,30 @@ pub(crate) async fn modify_device( Ok(ApiResponse::json(device, StatusCode::OK)) } -/// Get device -/// -/// Retrieve information about device based on their `device_id` -/// -/// # Returns -/// - `Device` object -/// -/// - `WebError` if error occurs +/// Get a device #[utoipa::path( get, path = "/api/v1/device/{device_id}", + tag = "device", params( - ("device_id" = i64, description = "ID of device to update details.") + ("device_id" = i64, description = "ID of the device.") ), responses( - (status = 200, description = "Successfully updated a device.", body = Device, example = json!( + (status = 200, description = "Device details.", body = Device, example = json!( { - "id": 0, - "name": "name", - "wireguard_pubkey": "wireguard_pubkey", - "user_id": 0, - "created": "2024-07-10T10:25:43.231Z" + "id": 1, + "name": "work laptop", + "wireguard_pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=", + "user_id": 1, + "created": "2024-07-10T10:25:43.231", + "device_type": "user", + "description": null, + "configured": true } )), - (status = 400, description = "Bad request, no networks found or device with pubkey that you want to send with is a server's pubkey.", body = ApiResponse, example = json!({"msg": "device's pubkey must be different from server's pubkey"})), - (status = 401, description = "Unauthorized to update a device.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 404, description = "Device not found.", body = ApiResponse, example = json!({"msg": "device id not found"})) + (status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})), + (status = 404, description = "Device not found.", body = ApiErrorResponse, example = json!({"msg": "device id not found"})), + (status = 500, description = "Unable to get device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1163,25 +1228,22 @@ pub(crate) async fn get_device( Ok(ApiResponse::json(device, StatusCode::OK)) } -/// Delete device -/// -/// Delete user device and trigger new update in gateway server. -/// -/// # Returns -/// - empty JSON +/// Delete a device /// -/// - `WebError` if error occurs +/// The device is removed from every location and the gateways are updated. #[utoipa::path( delete, path = "/api/v1/device/{device_id}", + tag = "device", params( - ("device_id" = i64, description = "ID of device to update details.") + ("device_id" = i64, description = "ID of the device.") ), responses( - (status = 200, description = "Successfully deleted device."), - (status = 401, description = "Unauthorized to update a device.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 404, description = "Device not found.", body = ApiResponse, example = json!({"msg": "device id not found"})), - (status = 500, description = "Cannot update a device.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 200, description = "Device deleted."), + (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"})), + (status = 404, description = "Device not found.", body = ApiErrorResponse, example = json!({"msg": "device id not found"})), + (status = 500, description = "Unable to delete device.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1275,29 +1337,27 @@ pub(crate) async fn delete_device( Ok(ApiResponse::default()) } -/// List all devices -/// -/// Retrieves all devices -/// -/// # Returns -/// - List of `Device` objects -/// -/// - `WebError` if error occurs +/// List devices #[utoipa::path( get, path = "/api/v1/device", + tag = "device", responses( - (status = 200, description = "List all devices.", body = [Device], example = json!([ + (status = 200, description = "All devices.", body = [Device], example = json!([ { - "id": 0, - "name": "name", - "wireguard_pubkey": "wireguard_pubkey", - "user_id": 0, - "created": "2024-07-10T10:25:43.231Z" + "id": 1, + "name": "work laptop", + "wireguard_pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=", + "user_id": 1, + "created": "2024-07-10T10:25:43.231", + "device_type": "user", + "description": null, + "configured": true } ])), - (status = 401, description = "Unauthorized to list all devices.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 403, description = "You don't have permission to list all devices.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), + (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": "requires privileged access"})), + (status = 500, description = "Unable to list devices.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1312,34 +1372,30 @@ pub(crate) async fn list_devices(_role: AdminRole, State(appstate): State, @@ -1411,9 +1487,30 @@ pub(crate) async fn download_config( } } -/// For a given user device, retrieve WireGuard configurations for all allowed locations. +/// Get the WireGuard configuration of a user device /// -/// GET /device/{device_id}/config +/// Returns one configuration per location the device is allowed to connect to. +#[utoipa::path( + get, + path = "/api/v1/device/{device_id}/config", + tag = "device", + params( + ("device_id" = i64, Path, description = "ID of the device."), + ), + 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"} + ])), + (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"})), + (status = 404, description = "Device not found.", body = ApiErrorResponse, example = json!({"msg": "device not found"})), + (status = 500, description = "Unable to get device configuration.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn user_device_configs( session: SessionInfo, State(appstate): State, diff --git a/crates/defguard_core/src/handlers/worker.rs b/crates/defguard_core/src/handlers/worker.rs index 129e393fdb..d7c343617b 100644 --- a/crates/defguard_core/src/handlers/worker.rs +++ b/crates/defguard_core/src/handlers/worker.rs @@ -34,6 +34,7 @@ struct JobResponseError { message: String, } +/// Create a YubiKey provisioning job. pub async fn create_job( session: SessionInfo, State(appstate): State, @@ -78,6 +79,7 @@ pub async fn create_job( } } +/// Create a token used by a provisioning worker to register itself. pub async fn create_worker_token(session: SessionInfo, _admin: AdminRole) -> ApiResult { let username = session.user.username; let token = Claims::new( @@ -94,6 +96,7 @@ pub async fn create_worker_token(session: SessionInfo, _admin: AdminRole) -> Api )) } +/// List registered provisioning workers. pub async fn list_workers( _admin: AdminRole, Extension(worker_state): Extension>>, @@ -105,6 +108,7 @@ pub async fn list_workers( Ok(ApiResponse::json(workers, StatusCode::OK)) } +/// Remove a provisioning worker. pub async fn remove_worker( _admin: AdminRole, session: SessionInfo, @@ -124,6 +128,7 @@ pub async fn remove_worker( } } +/// Get the status of a YubiKey provisioning job. pub async fn job_status( session: SessionInfo, Extension(worker_state): Extension>>, diff --git a/crates/defguard_core/src/handlers/yubikey.rs b/crates/defguard_core/src/handlers/yubikey.rs index d93f49d69d..8bf76b9062 100644 --- a/crates/defguard_core/src/handlers/yubikey.rs +++ b/crates/defguard_core/src/handlers/yubikey.rs @@ -4,10 +4,32 @@ use axum::{ http::StatusCode, }; use defguard_common::db::{Id, models::YubiKey}; +use utoipa::ToSchema; -use super::{ApiResponse, ApiResult, user_for_admin_or_self}; +use super::{ApiErrorResponse, ApiResponse, ApiResult, user_for_admin_or_self}; use crate::{appstate::AppState, auth::SessionInfo, error::WebError}; +/// Delete a YubiKey of a user +#[utoipa::path( + delete, + path = "/api/v1/user/{username}/yubikey/{key_id}", + tag = "user", + params( + ("username" = String, Path, description = "Name of the user."), + ("key_id" = i64, Path, description = "ID of the YubiKey."), + ), + responses( + (status = 200, description = "YubiKey deleted."), + (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"})), + (status = 404, description = "User or YubiKey not found.", body = ApiErrorResponse, example = json!({"msg": "YubiKey not found"})), + (status = 500, description = "Unable to delete YubiKey.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn delete_yubikey( State(appstate): State, session: SessionInfo, @@ -31,11 +53,33 @@ pub(crate) async fn delete_yubikey( Ok(ApiResponse::with_status(StatusCode::OK)) } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub(crate) struct RenameRequest { name: String, } +/// Rename a YubiKey of a user +#[utoipa::path( + post, + path = "/api/v1/user/{username}/yubikey/{key_id}/rename", + tag = "user", + request_body = RenameRequest, + params( + ("username" = String, Path, description = "Name of the user."), + ("key_id" = i64, Path, description = "ID of the YubiKey."), + ), + responses( + (status = 200, description = "YubiKey renamed.", body = Object, example = json!({"id": 1, "name": "work key", "serial": "12345678", "user_id": 1})), + (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"})), + (status = 404, description = "User or YubiKey not found.", body = ApiErrorResponse, example = json!({"msg": "YubiKey not found"})), + (status = 500, description = "Unable to rename YubiKey.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"})), + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] pub(crate) async fn rename_yubikey( State(appstate): State, session: SessionInfo, diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 4958ea96e6..d1d7b0aaec 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -23,12 +23,14 @@ use defguard_common::{ VERSION, auth::claims::{Claims, ClaimsType}, config::{ - ChangePasswordArgs, DefGuardConfig, GatewayConfigArgs, InitVpnLocationArgs, server_config, + AddUserToGroupArgs, ChangePasswordArgs, CreateAdminArgs, CreateGroupArgs, DefGuardConfig, + GatewayConfigArgs, InitVpnLocationArgs, SetAdminGroupArgs, server_config, }, db::{ - init_db, + Id, init_db, models::{ Certificates, Device, DeviceType, Settings, User, WireguardNetwork, + group::{Group, Permission}, initial_setup_wizard::{InitialSetupState, InitialSetupStep}, oauth2client::OAuth2Client, settings::{initialize_current_settings, update_current_settings}, @@ -91,6 +93,7 @@ use crate::{ auth::failed_login::FailedLoginMap, db::AppEvent, enterprise::{ + db::models::openid_provider::OpenIdProvider, firewall::try_get_location_firewall_config, handlers::{ acl::{ @@ -245,9 +248,17 @@ static PHONE_NUMBER_REGEX: LazyLock = LazyLock::new(|| { .expect("Failed to parse phone number regex") }); -mod openapi; +pub mod openapi; -/// Simple health-check. +/// Check instance health +#[utoipa::path( + get, + path = "/api/v1/health", + tag = "system", + responses( + (status = 200, description = "Core is running.", body = String, example = json!("alive")), + ) +)] pub async fn health_check() -> &'static str { "alive" } @@ -256,6 +267,10 @@ pub async fn handle_404() -> (StatusCode, &'static str) { (StatusCode::NOT_FOUND, "Not found") } +/// Serves this OpenAPI specification, same as `/api-docs/openapi.json`. +/// +/// Not listed in the specification itself, because the handler name collides with the +/// `openapi` module holding `ApiDoc`. async fn openapi() -> Json { Json(openapi::ApiDoc::openapi()) } @@ -1231,15 +1246,182 @@ pub async fn gateway_config( Ok(config) } +pub async fn create_admin_user(pool: &PgPool, args: &CreateAdminArgs) -> Result<(), anyhow::Error> { + let admin_group = match &args.group { + None => Group::find_by_permission(pool, Permission::IsAdmin) + .await? + .into_iter() + .next() + .ok_or_else(|| { + anyhow!( + "No admin group found. Create a group using create-group command and mark it as an admin group using the set-admin-group command." + ) + })?, + Some(selector) => { + let group = resolve_group(pool, selector.group_name.as_deref(), selector.group_id).await?; + if !group.is_admin { + return Err(anyhow!( + "Group '{}' is not a Defguard admin group.", + group.name + )); + } + group + } + }; + + if User::find_by_username(pool, &args.username) + .await? + .is_some() + { + return Err(anyhow!( + "A user with username '{}' already exists.", + args.username + )); + } + + let user = User::new( + args.username.clone(), + Some(args.password.expose_secret()), + "change-me".to_owned(), + "change-me".to_owned(), + format!("{}@change-me", args.username), + None, + ) + .save(pool) + .await?; + + user.add_to_group(pool, &admin_group).await?; + + Ok(()) +} + pub async fn change_user_password( pool: &PgPool, args: &ChangePasswordArgs, ) -> Result<(), anyhow::Error> { let mut user = User::find_by_username(pool, &args.username) .await? - .ok_or_else(|| anyhow!("User {} not found", args.username))?; - user.set_password(&args.password); + .ok_or_else(|| anyhow!("User '{}' not found.", args.username))?; + user.set_password(args.password.expose_secret()); user.save(pool).await?; + user.logout_all_sessions(pool).await?; + + Ok(()) +} + +async fn group_not_found_error(pool: &PgPool, name: &str) -> Result { + let available_groups = Group::all(pool) + .await? + .into_iter() + .map(|group| group.name) + .collect::>() + .join(", "); + Ok(anyhow!( + "Group '{name}' does not exist. Available groups: {available_groups}" + )) +} + +async fn resolve_group( + pool: &PgPool, + name: Option<&str>, + group_id: Option, +) -> Result, anyhow::Error> { + if let Some(name) = name { + match Group::find_by_name(pool, name).await? { + Some(group) => Ok(group), + None => Err(group_not_found_error(pool, name).await?), + } + } else if let Some(id) = group_id { + Group::find_by_id(pool, id) + .await? + .ok_or_else(|| anyhow!("Group with ID {id} does not exist.")) + } else { + Err(anyhow!( + "You must provide either --group-name or --group-id." + )) + } +} + +pub async fn set_admin_group( + pool: &PgPool, + args: &SetAdminGroupArgs, +) -> Result { + let group = resolve_group(pool, args.group.group_name.as_deref(), args.group.group_id).await?; + + if group.is_admin { + return Err(anyhow!( + "Group '{}' is already a Defguard admin group.", + group.name + )); + } + + group + .set_permission(pool, Permission::IsAdmin, true) + .await?; + + Ok(group.name) +} + +pub async fn create_new_group(pool: &PgPool, args: &CreateGroupArgs) -> Result<(), anyhow::Error> { + if Group::find_by_name(pool, &args.name).await?.is_some() { + return Err(anyhow!("A group named '{}' already exists.", args.name)); + } + + Group::new(args.name.clone()).save(pool).await?; + + Ok(()) +} + +pub async fn add_user_to_group( + pool: &PgPool, + args: &AddUserToGroupArgs, +) -> Result { + let user = User::find_by_username(pool, &args.username) + .await? + .ok_or_else(|| anyhow!("User '{}' not found.", args.username))?; + + let group = resolve_group(pool, args.group.group_name.as_deref(), args.group.group_id).await?; + + if user.member_of(pool).await?.iter().any(|g| g.id == group.id) { + return Err(anyhow!( + "User '{}' is already a member of group '{}'.", + args.username, + group.name + )); + } + + user.add_to_group(pool, &group).await?; + + Ok(group.name) +} + +pub async fn disable_ldap_integration(pool: &PgPool) -> Result<(), anyhow::Error> { + let mut settings = Settings::get_current_settings(); + + if !settings.ldap_enabled { + return Err(anyhow!("LDAP integration is already disabled.")); + } + + settings.ldap_enabled = false; + update_current_settings(pool, settings).await?; + + Ok(()) +} + +pub async fn disable_oidc_directory_sync(pool: &PgPool) -> Result<(), anyhow::Error> { + let mut provider = OpenIdProvider::get_current(pool) + .await? + .ok_or_else(|| anyhow!("No external identity provider is configured."))?; + + if !provider.directory_sync_enabled { + return Err(anyhow!( + "OIDC directory synchronization is already disabled." + )); + } + + provider.directory_sync_enabled = false; + provider.save(pool).await?; + Ok(()) } @@ -1278,3 +1460,469 @@ mod test { } } } + +#[cfg(test)] +mod cli_command_tests { + use defguard_common::{ + config::{ + AddUserToGroupArgs, ChangePasswordArgs, CreateAdminArgs, CreateGroupArgs, + GroupSelector, SetAdminGroupArgs, + }, + db::{ + models::{ + Settings, User, + group::{Group, Permission}, + settings::{initialize_current_settings, set_settings}, + }, + setup_pool, + }, + }; + use secrecy::SecretString; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + + use super::{ + add_user_to_group, change_user_password, create_admin_user, create_new_group, + disable_ldap_integration, disable_oidc_directory_sync, set_admin_group, + }; + use crate::enterprise::db::models::openid_provider::{ + DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider, OpenIdProviderKind, + }; + + #[sqlx::test] + async fn test_create_admin_user(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let args = CreateAdminArgs { + username: "cliadmin".to_owned(), + password: SecretString::from("pass123".to_owned()), + group: None, + }; + create_admin_user(&pool, &args).await.unwrap(); + + let user = User::find_by_username(&pool, "cliadmin") + .await + .unwrap() + .expect("admin user should exist"); + assert!(user.verify_password("pass123").is_ok()); + assert!(user.is_admin(&pool).await.unwrap()); + + // creating the same username again fails + assert!(create_admin_user(&pool, &args).await.is_err()); + } + + #[sqlx::test] + async fn test_create_admin_user_without_admin_group_fails( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + for group in Group::find_by_permission(&pool, Permission::IsAdmin) + .await + .unwrap() + { + group + .set_permission(&pool, Permission::IsAdmin, false) + .await + .unwrap(); + } + + let args = CreateAdminArgs { + username: "cliadmin".to_owned(), + password: SecretString::from("pass123".to_owned()), + group: None, + }; + assert!(create_admin_user(&pool, &args).await.is_err()); + } + + #[sqlx::test] + async fn test_create_admin_user_with_specific_group( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let mut admin_group = Group::new("admins"); + admin_group.is_admin = true; + let admin_group = admin_group.save(&pool).await.unwrap(); + + // by name + create_admin_user( + &pool, + &CreateAdminArgs { + username: "byname".to_owned(), + password: SecretString::from("pass123".to_owned()), + group: Some(GroupSelector { + group_name: Some("admins".to_owned()), + group_id: None, + }), + }, + ) + .await + .unwrap(); + let user = User::find_by_username(&pool, "byname") + .await + .unwrap() + .unwrap(); + assert!( + user.member_of(&pool) + .await + .unwrap() + .iter() + .any(|group| group.id == admin_group.id) + ); + + // by id + create_admin_user( + &pool, + &CreateAdminArgs { + username: "byid".to_owned(), + password: SecretString::from("pass123".to_owned()), + group: Some(GroupSelector { + group_name: None, + group_id: Some(admin_group.id), + }), + }, + ) + .await + .unwrap(); + let user = User::find_by_username(&pool, "byid") + .await + .unwrap() + .unwrap(); + assert!( + user.member_of(&pool) + .await + .unwrap() + .iter() + .any(|group| group.id == admin_group.id) + ); + } + + #[sqlx::test] + async fn test_create_admin_user_with_non_admin_group_fails( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + Group::new("plain").save(&pool).await.unwrap(); + + let result = create_admin_user( + &pool, + &CreateAdminArgs { + username: "cliadmin".to_owned(), + password: SecretString::from("pass123".to_owned()), + group: Some(GroupSelector { + group_name: Some("plain".to_owned()), + group_id: None, + }), + }, + ) + .await; + + assert!(result.is_err()); + // the user must not have been created when the group is rejected + assert!( + User::find_by_username(&pool, "cliadmin") + .await + .unwrap() + .is_none() + ); + } + + #[sqlx::test] + async fn test_create_admin_user_with_nonexistent_group_fails( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + assert!( + create_admin_user( + &pool, + &CreateAdminArgs { + username: "cliadmin".to_owned(), + password: SecretString::from("pass123".to_owned()), + group: Some(GroupSelector { + group_name: Some("ghost".to_owned()), + group_id: None, + }), + }, + ) + .await + .is_err() + ); + } + + #[sqlx::test] + async fn test_change_user_password(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + User::new( + "bob", + Some("old-pass"), + "Bar", + "Bob", + "bob@example.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + change_user_password( + &pool, + &ChangePasswordArgs { + username: "bob".to_owned(), + password: SecretString::from("new-pass".to_owned()), + }, + ) + .await + .unwrap(); + + let user = User::find_by_username(&pool, "bob").await.unwrap().unwrap(); + assert!(user.verify_password("new-pass").is_ok()); + assert!(user.verify_password("old-pass").is_err()); + + // unknown user fails + assert!( + change_user_password( + &pool, + &ChangePasswordArgs { + username: "nobody".to_owned(), + password: SecretString::from("whatever".to_owned()), + }, + ) + .await + .is_err() + ); + } + + #[sqlx::test] + async fn test_create_new_group(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let args = CreateGroupArgs { + name: "developers".to_owned(), + }; + create_new_group(&pool, &args).await.unwrap(); + assert!( + Group::find_by_name(&pool, "developers") + .await + .unwrap() + .is_some() + ); + + // creating a group with an existing name fails + assert!(create_new_group(&pool, &args).await.is_err()); + } + + #[sqlx::test] + async fn test_set_admin_group(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + Group::new("ops").save(&pool).await.unwrap(); + + // mark by name + let name = set_admin_group( + &pool, + &SetAdminGroupArgs { + group: GroupSelector { + group_name: Some("ops".to_owned()), + group_id: None, + }, + }, + ) + .await + .unwrap(); + assert_eq!(name, "ops"); + assert!( + Group::find_by_name(&pool, "ops") + .await + .unwrap() + .unwrap() + .is_admin + ); + + // marking an already-admin group fails + assert!( + set_admin_group( + &pool, + &SetAdminGroupArgs { + group: GroupSelector { + group_name: Some("ops".to_owned()), + group_id: None, + }, + } + ) + .await + .is_err() + ); + + // mark by id + let other = Group::new("ops2").save(&pool).await.unwrap(); + set_admin_group( + &pool, + &SetAdminGroupArgs { + group: GroupSelector { + group_name: None, + group_id: Some(other.id), + }, + }, + ) + .await + .unwrap(); + assert!( + Group::find_by_name(&pool, "ops2") + .await + .unwrap() + .unwrap() + .is_admin + ); + + // nonexistent group fails + assert!( + set_admin_group( + &pool, + &SetAdminGroupArgs { + group: GroupSelector { + group_name: Some("ghost".to_owned()), + group_id: None, + }, + } + ) + .await + .is_err() + ); + } + + #[sqlx::test] + async fn test_add_user_to_group(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + User::new( + "carol", + Some("pass"), + "Bar", + "Carol", + "carol@example.com", + None, + ) + .save(&pool) + .await + .unwrap(); + let group = Group::new("team").save(&pool).await.unwrap(); + + // add by name + let name = add_user_to_group( + &pool, + &AddUserToGroupArgs { + username: "carol".to_owned(), + group: GroupSelector { + group_name: Some("team".to_owned()), + group_id: None, + }, + }, + ) + .await + .unwrap(); + assert_eq!(name, "team"); + assert_eq!( + group.member_usernames(&pool).await.unwrap(), + ["carol".to_owned()] + ); + + // adding the same user again (by id) fails + assert!( + add_user_to_group( + &pool, + &AddUserToGroupArgs { + username: "carol".to_owned(), + group: GroupSelector { + group_name: None, + group_id: Some(group.id), + }, + }, + ) + .await + .is_err() + ); + + // unknown user fails + assert!( + add_user_to_group( + &pool, + &AddUserToGroupArgs { + username: "ghost".to_owned(), + group: GroupSelector { + group_name: Some("team".to_owned()), + group_id: None, + }, + }, + ) + .await + .is_err() + ); + } + + #[sqlx::test] + async fn test_disable_ldap_integration(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool).await.unwrap(); + + // seed enabled LDAP integration (save bypasses validation; refresh the in-memory cache + // the command reads from) + let mut settings = Settings::get_current_settings(); + settings.defguard_url = "https://defguard.example.com".to_owned(); + settings.ldap_enabled = true; + settings.save(&pool).await.unwrap(); + set_settings(Some(settings)); + + disable_ldap_integration(&pool).await.unwrap(); + assert!(!Settings::get(&pool).await.unwrap().unwrap().ldap_enabled); + + // already disabled -> error + assert!(disable_ldap_integration(&pool).await.is_err()); + } + + #[sqlx::test] + async fn test_disable_oidc_directory_sync(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // no provider configured -> error + assert!(disable_oidc_directory_sync(&pool).await.is_err()); + + OpenIdProvider::new( + "Test".to_owned(), + "https://idp.example.com".to_owned(), + OpenIdProviderKind::Google, + "client_id".to_owned(), + "client_secret".to_owned(), + None, + None, + None, + None, + true, + 60, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + None, + None, + Vec::new(), + None, + false, + false, + None, + ) + .save(&pool) + .await + .unwrap(); + + disable_oidc_directory_sync(&pool).await.unwrap(); + assert!( + !OpenIdProvider::get_current(&pool) + .await + .unwrap() + .unwrap() + .directory_sync_enabled + ); + + // already disabled -> error + assert!(disable_oidc_directory_sync(&pool).await.is_err()); + } +} diff --git a/crates/defguard_core/src/mail/templates.rs b/crates/defguard_core/src/mail/templates.rs index d3ca0085c6..2866ec331b 100644 --- a/crates/defguard_core/src/mail/templates.rs +++ b/crates/defguard_core/src/mail/templates.rs @@ -522,6 +522,7 @@ pub async fn mfa_activation_mail( first_name: &str, code: &str, session: Option<&SessionContext>, + send_and_forget: bool, ) -> Result<(), TemplateError> { let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; let settings = Settings::get_current_settings(); @@ -538,7 +539,11 @@ pub async fn mfa_activation_mail( let message = MailMessage::MFAActivation; message.fill_context(conn, &mut context).await?; - message.mail(&mut tera, &context, to)?.send().await?; + if send_and_forget { + message.mail(&mut tera, &context, to)?.send_and_forget(); + } else { + message.mail(&mut tera, &context, to)?.send().await?; + } Ok(()) } @@ -549,6 +554,7 @@ pub async fn mfa_code_mail( first_name: &str, code: &str, session: Option<&SessionContext>, + send_and_forget: bool, ) -> Result<(), TemplateError> { let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; let settings = Settings::get_current_settings(); @@ -565,7 +571,11 @@ pub async fn mfa_code_mail( let message = MailMessage::MFACode; message.fill_context(conn, &mut context).await?; - message.mail(&mut tera, &context, to)?.send().await?; + if send_and_forget { + message.mail(&mut tera, &context, to)?.send_and_forget(); + } else { + message.mail(&mut tera, &context, to)?.send().await?; + } Ok(()) } diff --git a/crates/defguard_core/src/mail/tests.rs b/crates/defguard_core/src/mail/tests.rs index 2c5dc62142..11e56a1e39 100644 --- a/crates/defguard_core/src/mail/tests.rs +++ b/crates/defguard_core/src/mail/tests.rs @@ -139,6 +139,7 @@ fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { first_name, code, None, + true, ) .await .unwrap(); @@ -184,6 +185,7 @@ fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { first_name, code, None, + true, ) .await .unwrap(); diff --git a/crates/defguard_core/src/openapi.rs b/crates/defguard_core/src/openapi.rs index 1724406789..92a05fccd9 100644 --- a/crates/defguard_core/src/openapi.rs +++ b/crates/defguard_core/src/openapi.rs @@ -1,4 +1,5 @@ use defguard_common::{ + CARGO_VERSION, db::models::{ Device, device::{AddDevice, ModifyDevice, UserDevice}, @@ -12,17 +13,22 @@ use utoipa::{ use super::{ enterprise::{ - handlers::{acl, openid_providers}, + handlers::{ + self as enterprise_handlers, acl, activity_log_stream, api_tokens, device_posture, + enterprise_settings, openid_login, openid_providers, + }, snat::handlers as snat, }, - error::WebError, handlers::{ - ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, - SESSION_COOKIE_NAME, StartEnrollmentRequest, Username, auth, + ApiErrorResponse, Auth, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, + SESSION_COOKIE_NAME, StartEnrollmentRequest, Username, activity_log, app_info, auth, + component_setup, core_certs, forward_auth, gateway, group::{self, BulkAssignToGroupsRequest}, - license, + license, location_stats, mail, network_devices, openid_clients, openid_flow, proxy, + reserved, resource_display, session_info, settings, ssh_authorized_keys, static_ips, + support, updates, user::{self, UserDetails}, - wireguard as device, wireguard as network, + webhooks, wireguard as device, wireguard as network, wireguard::AddDeviceResult, }, }; @@ -30,141 +36,278 @@ use super::{ #[derive(OpenApi)] #[openapi( modifiers(&SecurityAddon), + info( + title = "defguard Core API", + version = CARGO_VERSION, + description = " +REST API of defguard Core. + +Authentication is done either with the session cookie obtained from `POST /api/v1/auth`, +or with an API token passed as `Authorization: Bearer `. + +API tokens are created by an admin with `POST /api/v1/user/{username}/api_token` and their value is returned only once, in the response to that request. + +Errors are returned as a JSON object with a `msg` field and, for some of them, a machine-readable `code` field.", + ), paths( - // /auth + // system + forward_auth::forward_auth, + crate::health_check, + app_info::get_app_info, + updates::outdated_components, + reserved::check_reserved, + session_info::get_session_info, + updates::check_new_version, + // auth auth::authenticate, + auth::request_email_mfa_code, + auth::email_mfa_enable, + auth::email_mfa_init, + auth::email_mfa_code, auth::logout, - // /user + auth::mfa_disable, + auth::mfa_enable, + auth::recovery_code, + auth::totp_enable, + auth::totp_secret, + auth::totp_code, + auth::webauthn_end, + auth::webauthn_finish, + auth::webauthn_init, + auth::webauthn_start, + // user + user::me, user::list_users, - user::get_user, user::add_user, - user::start_enrollment, - user::start_remote_desktop_configuration, user::username_available, - user::modify_user, - user::delete_user, + user::bulk_delete_users, + user::bulk_disable_users, + user::bulk_enable_users, + user::bulk_start_enrollment, user::change_self_password, + user::delete_user, + user::get_user, + user::modify_user, + auth::email_mfa_disable, + auth::disable_user_mfa, + user::delete_authorized_app, user::change_password, user::reset_password, user::delete_security_key, - user::me, - user::delete_authorized_app, - // /group - group::bulk_assign_to_groups, - group::list_groups_info, + user::start_remote_desktop_configuration, + user::start_enrollment, + auth::totp_disable, + // group group::list_groups, - group::get_group, group::create_group, - group::modify_group, + group::list_groups_info, group::delete_group, + group::get_group, group::add_group_member, + group::modify_group, group::remove_group_member, - // /device - device::add_device, - device::modify_device, - device::get_device, - device::delete_device, + group::bulk_assign_to_groups, + // device device::list_devices, device::list_user_devices, - // /network - network::create_network, - network::modify_network, - network::delete_network, + device::delete_device, + device::get_device, + device::add_device, + device::modify_device, + device::user_device_configs, + // network device + network_devices::list_network_devices, + network_devices::add_network_device, + network_devices::find_available_ips, + network_devices::check_ip_availability, + network_devices::start_network_device_setup, + network_devices::start_network_device_setup_for_device, + network_devices::get_network_device, + network_devices::modify_network_device, + network_devices::network_device_configs, + // static IP + static_ips::get_all_user_device_ips, + static_ips::assign_static_ips, + static_ips::validate_ip_assignment, + static_ips::get_device_ips, + // network network::list_networks, + network::create_network, network::count_networks, + resource_display::get_locations_display, + network::import_network, + network::delete_network, network::network_details, - // /license + network::modify_network, + network::download_config, + network::add_user_devices, + // location stats + location_stats::locations_overview_stats, + location_stats::location_connected_network_devices, + location_stats::location_connected_users, + location_stats::location_connected_user_devices, + location_stats::location_stats, + // gateway + gateway::gateway_list, + gateway::delete_gateway, + gateway::gateway_details, + gateway::update_gateway, + network::all_gateways_status, + network::gateway_status, + component_setup::adopt_gateway, + component_setup::setup_gateway_tls_stream, + // proxy + proxy::proxy_list, + component_setup::stream_proxy_acme, + component_setup::setup_proxy_tls_stream, + proxy::delete_proxy, + proxy::proxy_details, + proxy::update_proxy, + // certificates + core_certs::get_ca, + core_certs::get_certs, + core_certs::set_internal_url_settings, + core_certs::set_external_url_settings, + // SSH key + ssh_authorized_keys::get_authorized_keys, + ssh_authorized_keys::fetch_authentication_keys, + ssh_authorized_keys::add_authentication_key, + ssh_authorized_keys::delete_authentication_key, + ssh_authorized_keys::rename_authentication_key, + // API token + api_tokens::fetch_api_tokens, + api_tokens::add_api_token, + api_tokens::delete_api_token, + api_tokens::rename_api_token, + // webhook + webhooks::list_webhooks, + webhooks::add_webhook, + webhooks::delete_webhook, + webhooks::get_webhook, + webhooks::change_enabled, + webhooks::change_webhook, + // settings + settings::get_settings, + settings::patch_settings, + settings::update_settings, + settings::set_default_branding, + enterprise_settings::get_enterprise_settings, + enterprise_settings::patch_enterprise_settings, + settings::get_settings_essentials, + // LDAP + settings::ldap_dry_run, + settings::test_ldap_settings, + settings::test_submitted_ldap_settings, + // activity log + activity_log::get_activity_log_events, + activity_log_stream::get_activity_log_stream, + activity_log_stream::create_activity_log_stream, + activity_log_stream::delete_activity_log_stream, + activity_log_stream::modify_activity_log_stream, + // ACL + acl::alias::list_acl_aliases, + acl::alias::create_acl_alias, + acl::alias::apply_acl_aliases, + acl::alias::count_acl_aliases, + acl::alias::delete_acl_alias, + acl::alias::get_acl_alias, + acl::alias::update_acl_alias, + acl::destination::list_acl_destinations, + acl::destination::create_acl_destination, + acl::destination::apply_acl_destinations, + acl::destination::count_acl_destinations, + acl::destination::delete_acl_destination, + acl::destination::get_acl_destination, + acl::destination::update_acl_destination, + acl::list_acl_rules, + acl::create_acl_rule, + acl::apply_acl_rules, + acl::count_acl_rules, + acl::delete_acl_rule, + acl::get_acl_rule, + acl::update_acl_rule, + // DevicePosture + device_posture::list_device_postures, + device_posture::create_device_posture, + device_posture::get_device_posture_versions, + device_posture::delete_device_posture, + device_posture::get_device_posture, + device_posture::update_device_posture, + device_posture::duplicate_device_posture, + device_posture::set_locations_for_posture, + device_posture::set_postures_for_location, + // SNAT + snat::list_snat_bindings, + snat::create_snat_binding, + snat::delete_snat_binding, + snat::modify_snat_binding, + // OpenID + openid_login::get_auth_info, + openid_login::auth_callback, + openid_providers::list_openid_providers, + openid_providers::add_openid_provider, + openid_providers::get_current_openid_provider, + openid_providers::delete_openid_provider, + openid_providers::get_openid_provider, + openid_providers::modify_openid_provider, + openid_providers::test_dirsync_connection, + // OAuth2 + openid_flow::openid_configuration, + openid_clients::list_openid_clients, + openid_clients::add_openid_client, + openid_flow::authorization, + openid_flow::secure_authorization, + openid_flow::discovery_keys, + openid_flow::token, + openid_flow::userinfo, + openid_clients::delete_openid_client, + openid_clients::get_openid_client, + openid_clients::change_openid_client_state, + openid_clients::change_openid_client, + // support + mail::send_support_data, + mail::test_mail, + support::configuration, + support::logs, + // license + enterprise_handlers::check_enterprise_info, license::license_check, - // /network/{location_id}/snat - snat::list_snat_bindings, - snat::create_snat_binding, - snat::modify_snat_binding, - snat::delete_snat_binding, - // /openid - openid_providers::add_openid_provider, - openid_providers::get_openid_provider, - openid_providers::delete_openid_provider, - openid_providers::modify_openid_provider, - openid_providers::list_openid_providers, - // /acl/rule - acl::list_acl_rules, - acl::count_acl_rules, - acl::create_acl_rule, - acl::apply_acl_rules, - acl::get_acl_rule, - acl::update_acl_rule, - acl::delete_acl_rule, - // /acl/alias - acl::alias::list_acl_aliases, - acl::alias::count_acl_aliases, - acl::alias::create_acl_alias, - acl::alias::get_acl_alias, - acl::alias::update_acl_alias, - acl::alias::delete_acl_alias, - acl::alias::apply_acl_aliases, - // /acl/destination - acl::destination::list_acl_destinations, - acl::destination::count_acl_destinations, - acl::destination::create_acl_destination, - acl::destination::get_acl_destination, - acl::destination::update_acl_destination, - acl::destination::delete_acl_destination, - acl::destination::apply_acl_destinations, ), components( schemas( - ApiResponse, UserInfo, UserDetails, UserDevice, Username, + ApiErrorResponse, Auth, UserInfo, UserDetails, UserDevice, Username, StartEnrollmentRequest, PasswordChangeSelf, PasswordChange, AddDevice, AddDeviceResult, - Device, ModifyDevice, BulkAssignToGroupsRequest, GroupInfo, EditGroupInfo, WebError, - license::CheckParams + Device, ModifyDevice, BulkAssignToGroupsRequest, GroupInfo, EditGroupInfo, + license::CheckParams, + user::SortKey, user::SortOrder, reserved::CheckResource ), ), tags( - (name = "user", description = " -### Endpoints for managing users -Available actions: -- list all users -- disable/enable user -- CRUD mechanism for handling users -- operations on security key and authorized app -- change user password. -- start remote desktop configuratiion -- trigger enrollment process - "), - (name = "group", description = " -### Endpoints for managing groups -Available actions: -- list all groups -- CRUD mechanism for handling groups -- add or delete a group member -- remove group -- bulk assign users to groups - "), - (name = "device", description = " -### Endpoints for managing devices - -Available actions: -- list all devices or user devices -- CRUD mechanism for handling devices. - "), - (name = "network", description = " -### Endpoints that allow to control your networks. - -Available actions: -- list all wireguard networks -- CRUD mechanism for handling devices. - "), - (name = "SNAT", description = " -### Endpoints that allow you to control user SNAT bindings for your locations. - -Available actions: -- list all SNAT bindings -- create new SNAT binding -- modify SNAT binding -- delete SNAT binding - "), - (name = "ACL", description = "Access Control Lists (ACL)"), - (name = "OpenID", description = "OpenID providers"), + (name = "system", description = "Health check, instance info and other utility endpoints."), + (name = "auth", description = "User authentication, second authentication factor and session management."), + (name = "user", description = "User accounts, their MFA methods, enrollment and bulk operations."), + (name = "group", description = "User groups and their members."), + (name = "device", description = "User devices and their WireGuard configuration."), + (name = "network device", description = "Network devices, as opposed to user devices, and their IP addresses."), + (name = "static IP", description = "Static IP addresses assigned to user devices."), + (name = "network", description = "WireGuard networks, including import from a WireGuard configuration file."), + (name = "location stats", description = "Traffic statistics and active connections per location."), + (name = "gateway", description = "Gateways registered in your locations, their connection status and setup."), + (name = "proxy", description = "Edge (proxy) instances, their TLS and ACME setup."), + (name = "certificates", description = "Internal (core) and external (edge) URL certificates."), + (name = "SSH key", description = "SSH and GPG authentication keys of users."), + (name = "API token", description = "API tokens used for `Authorization: Bearer` authentication."), + (name = "webhook", description = "Webhooks triggered by user and provisioning events."), + (name = "settings", description = "Instance and enterprise settings."), + (name = "LDAP", description = "LDAP connection tests and sync dry runs."), + (name = "activity log", description = "Activity log events and activity log streams."), + (name = "ACL", description = "Access Control Lists (ACL)."), + (name = "device posture", description = "Device posture check policies."), + (name = "SNAT", description = "User SNAT bindings in your locations."), + (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 = "license", description = "Enterprise license."), ) )] pub struct ApiDoc; @@ -173,6 +316,7 @@ struct SecurityAddon; impl Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + openapi.info.license = None; if let Some(components) = openapi.components.as_mut() { // session cookie auth components.add_security_scheme( diff --git a/crates/defguard_core/src/wg_config.rs b/crates/defguard_core/src/wg_config.rs index 37a4540c48..bbea192abe 100644 --- a/crates/defguard_core/src/wg_config.rs +++ b/crates/defguard_core/src/wg_config.rs @@ -13,13 +13,15 @@ use defguard_common::{ }; use ipnetwork::{IpNetwork, IpNetworkError}; use thiserror::Error; +use utoipa::ToSchema; use x25519_dalek::{PublicKey, StaticSecret}; -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, ToSchema)] pub struct ImportedDevice { pub user_id: Option, pub name: String, pub wireguard_pubkey: String, + #[schema(value_type = Vec)] pub wireguard_ips: Vec, } diff --git a/crates/defguard_core/tests/integration/api/auth.rs b/crates/defguard_core/tests/integration/api/auth.rs index 5dace4c6b4..ff6b1b05e5 100644 --- a/crates/defguard_core/tests/integration/api/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -1,9 +1,12 @@ -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use chrono::DateTime; -use defguard_common::db::models::{ - MFAInfo, MFAMethod, User, - user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, +use defguard_common::{ + db::models::{ + MFAInfo, MFAMethod, User, + user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, + }, + testing::smtp::{CapturedMail, MockSmtpServer}, }; use defguard_core::{ events::ApiEventType, @@ -16,6 +19,7 @@ use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, query, }; +use tokio::time::sleep; use totp_lite::{Sha1, totp_custom}; use webauthn_authenticator_rs::{WebauthnAuthenticator, prelude::Url, softpasskey::SoftPasskey}; use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; @@ -403,13 +407,28 @@ async fn dg25_15_test_totp_brute_force(_: PgPoolOptions, options: PgConnectOptio } } -// static EMAIL_CODE_REGEX: &str = r"(?\d{6})"; -// fn extract_email_code(content: &str) -> &str { -// let re = regex::Regex::new(EMAIL_CODE_REGEX).unwrap(); -// re.captures(content).unwrap().name("code").unwrap().as_str() -// } +/// Find the 6-digit MFA code in a captured email, if present. The plain-text +/// MIME part renders `{{ code }}` on its own line, so we look for a line that is +/// exactly six digits. +fn find_mfa_code(mail: &CapturedMail) -> Option<&str> { + mail.body + .lines() + .map(str::trim) + .find(|line| line.len() == 6 && line.bytes().all(|b| b.is_ascii_digit())) +} + +/// Whether a captured email carries a 6-digit MFA code. +fn has_mfa_code(mail: &CapturedMail) -> bool { + find_mfa_code(mail).is_some() +} + +/// Extract the 6-digit MFA code from a captured email (see [`find_mfa_code`]). +fn extract_email_code(mail: &CapturedMail) -> String { + find_mfa_code(mail) + .expect("no 6-digit MFA code found in email body") + .to_string() +} -/* #[sqlx::test] async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -417,6 +436,9 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { let (client, state) = make_test_client(pool).await; let pool = state.pool; + // stand up a mock SMTP server, but leave SMTP unconfigured for now + let smtp = MockSmtpServer::start().await; + // try to initialize email MFA setup before logging in let response = client.post("/api/v1/auth/email/init").send().await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); @@ -428,54 +450,28 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { // try to initialize email MFA setup without SMTP settings configured let response = client.post("/api/v1/auth/email/init").send().await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - // add dummy SMTP settings - let mut settings = Settings::get_current_settings(); - settings.smtp.server = Some("smtp_server".into()); - settings.smtp.port = Some(587); - settings.smtp.sender = Some("smtp@sender.pl".into()); - update_current_settings(&pool, settings).await.unwrap(); + // point SMTP at the mock server + smtp.configure(&pool).await; - // initialize email MFA setup + // initialize email MFA setup - sends the activation email (awaited) let response = client.post("/api/v1/auth/email/init").send().await; assert_eq!(response.status(), StatusCode::OK); - // check email was sent - let mail = mail_rx.try_recv().unwrap(); - assert_ok!(mail_rx.try_recv()); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "Defguard: new device logged in to your account" - ); - - // resend setup email - let response = client.post("/api/v1/auth/email/init").send().await; - assert_eq!(response.status(), StatusCode::OK); - let mail = mail_rx.try_recv().unwrap(); - assert_err!(mail_rx.try_recv()); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "Defguard: Multi-Factor Authentication activation" - ); - let code = extract_email_code(mail.content()); + // the activation email is the first one carrying a 6-digit code + let (activation_idx, activation_mail) = smtp.wait_for_from(0, has_mfa_code).await; + assert!(activation_mail.sent_to("h.potter@hogwart.edu.uk")); + let code = extract_email_code(&activation_mail); - // finish setup - let code = AuthCode::new(code); - let response = client.post("/api/v1/auth/email").json(&code).send().await; + // finish setup with the emailed code + let response = client + .post("/api/v1/auth/email") + .json(&AuthCode::new(code)) + .send() + .await; assert_eq!(response.status(), StatusCode::OK); - // check that confirmation email was sent - let mail = mail_rx.try_recv().unwrap(); - assert_err!(mail_rx.try_recv()); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "MFA method Email has been activated on your account" - ); - // check recovery codes let recovery_codes: RecoveryCodes = response.json().await; assert_eq!(recovery_codes.codes.as_ref().unwrap().len(), 8); // RECOVERY_CODES_COUNT @@ -488,15 +484,14 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::CREATED); - // still unauthorized + // still unauthorized until the emailed code is provided let response = client.get("/api/v1/me").send().await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); // provide wrong code - let code = AuthCode::new("0"); let response = client .post("/api/v1/auth/email/verify") - .json(&code) + .json(&AuthCode::new("0")) .send() .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); @@ -505,40 +500,19 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { let response = client.get("/api/v1/me").send().await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - // request code + // request a login code - sends the code email (awaited) let response = client.get("/api/v1/auth/email").send().await; assert_eq!(response.status(), StatusCode::OK); - // check that code email was sent - let mail = mail_rx.try_recv().unwrap(); - assert_ok!(mail_rx.try_recv()); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "Defguard: new device logged in to your account" - ); - - // resend code - let response = client.get("/api/v1/auth/email").send().await; - assert_eq!(response.status(), StatusCode::OK); - let mail = mail_rx.try_recv().unwrap(); - assert_err!(mail_rx.try_recv()); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "Defguard: Multi-Factor Authentication code for login" - ); - let code = extract_email_code(mail.content()); - - // login - let response = client.post("/api/v1/auth").json(&auth).send().await; - assert_eq!(response.status(), StatusCode::CREATED); + // grab the next code-bearing email after the activation one + let (_, code_mail) = smtp.wait_for_from(activation_idx + 1, has_mfa_code).await; + assert!(code_mail.sent_to("h.potter@hogwart.edu.uk")); + let code = extract_email_code(&code_mail); // provide correct code - let code = AuthCode::new(code); let response = client .post("/api/v1/auth/email/verify") - .json(&code) + .json(&AuthCode::new(code)) .send() .await; assert_eq!(response.status(), StatusCode::OK); @@ -551,14 +525,11 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { let response = client.delete("/api/v1/auth/mfa").send().await; assert_eq!(response.status(), StatusCode::OK); - // login again - let auth = Auth::new("hpotter", "pass123"); + // login again - MFA no longer required let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); } -*/ -/* #[sqlx::test] async fn dg25_15_test_email_mfa_brute_force(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -574,30 +545,25 @@ async fn dg25_15_test_email_mfa_brute_force(_: PgPoolOptions, options: PgConnect let auth = Auth::new("hpotter", "pass123"); let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); - // remove login confirmation email from queue - let _mail = mail_rx.try_recv().unwrap(); - // add dummy SMTP settings - let mut settings = Settings::get_current_settings(); - settings.smtp.server = Some("smtp_server".into()); - settings.smtp.port = Some(587); - settings.smtp.sender = Some("smtp@sender.pl".into()); - update_current_settings(&pool, settings).await.unwrap(); + // point SMTP at the mock server + let smtp = MockSmtpServer::start().await; + smtp.configure(&pool).await; - // initialize email MFA setup + // initialize email MFA setup - sends the activation email (awaited) let response = client.post("/api/v1/auth/email/init").send().await; assert_eq!(response.status(), StatusCode::OK); - let mail = mail_rx.try_recv().unwrap(); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "Defguard: Multi-Factor Authentication activation" - ); - let code = extract_email_code(mail.content()); - // finish setup - let code = AuthCode::new(code); - let response = client.post("/api/v1/auth/email").json(&code).send().await; + let activation = smtp.wait_for(has_mfa_code).await; + assert!(activation.sent_to("h.potter@hogwart.edu.uk")); + let code = extract_email_code(&activation); + + // finish setup with the emailed code + let response = client + .post("/api/v1/auth/email") + .json(&AuthCode::new(code)) + .send() + .await; assert_eq!(response.status(), StatusCode::OK); // enable MFA @@ -609,11 +575,11 @@ async fn dg25_15_test_email_mfa_brute_force(_: PgPoolOptions, options: PgConnect assert_eq!(response.status(), StatusCode::CREATED); // provide wrong code more than 5 times in a row - let code = AuthCode::new("0"); + let wrong_code = AuthCode::new("0"); for i in 0..10 { let response = client .post("/api/v1/auth/email/verify") - .json(&code) + .json(&wrong_code) .send() .await; if i >= 5 { @@ -623,7 +589,6 @@ async fn dg25_15_test_email_mfa_brute_force(_: PgPoolOptions, options: PgConnect } } } -*/ #[sqlx::test] async fn test_webauthn(_: PgPoolOptions, options: PgConnectOptions) { @@ -884,12 +849,12 @@ async fn test_mfa_method_is_updated_when_removing_last_webauthn_passkey( assert_eq!(mfa_info.current_mfa_method(), &MFAMethod::OneTimePassword); } -/* #[sqlx::test] async fn test_mfa_method_totp_enabled_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, state) = make_test_client(pool).await; + let pool = state.pool; let user_agent_header = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"; // login @@ -902,40 +867,44 @@ async fn test_mfa_method_totp_enabled_mail(_: PgPoolOptions, options: PgConnectO .await; assert_eq!(response.status(), StatusCode::OK); + // point SMTP at the mock server (after login, so only the activation mail is captured) + let smtp = MockSmtpServer::start().await; + smtp.configure(&pool).await; + // new TOTP secret let response = client.post("/api/v1/auth/totp/init").send().await; assert_eq!(response.status(), StatusCode::OK); let auth_totp: AuthTotp = response.json().await; - // enable TOTP + // enable TOTP - sends the "MFA method activated" notification (fire-and-forget) let code = totp_code(&auth_totp); let response = client.post("/api/v1/auth/totp").json(&code).send().await; assert_eq!(response.status(), StatusCode::OK); - mail_rx.try_recv().unwrap(); - let mail = mail_rx.try_recv().unwrap(); - assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - assert_eq!( - mail.subject(), - "MFA method TOTP has been activated on your account" - ); - assert!(mail.content().contains("IP Address: 127.0.0.1")); + let mail = smtp + .wait_for(|m| m.body_contains("Multi-Factor Authentication TOTP has been activated")) + .await; assert!( - mail.content() - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + mail.sent_to("h.potter@hogwart.edu.uk"), + "MFA activation notification should be addressed to the user" ); } -*/ #[sqlx::test] async fn test_new_device_login(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, _) = make_test_client(pool).await; + let (client, state) = make_test_client(pool).await; + let pool = state.pool; + + // point SMTP at a mock server so new-device-login notifications are delivered + let smtp = MockSmtpServer::start().await; + smtp.configure(&pool).await; + let user_agent_header_iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"; let user_agent_header_android = "Mozilla/5.0 (Linux; Android 7.0; SM-G930VC Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.83 Mobile Safari/537.36"; - // login + // login from a new device - triggers a notification email let auth = Auth::new("hpotter", "pass123"); let response = client .post("/api/v1/auth") @@ -945,22 +914,23 @@ async fn test_new_device_login(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::OK); - // assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - // assert_eq!( - // mail.subject(), - // "Defguard: new device logged in to your account" - // ); - // assert!(mail.content().contains("IP Address: 127.0.0.1")); - // assert!( - // mail.content() - // .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") - // ); + let mail = smtp + .wait_for(|m| m.sent_to("h.potter@hogwart.edu.uk")) + .await; + assert!( + mail.body_contains("Defguard: New device logged in to your account"), + "new-device notification should carry the expected subject" + ); + assert!( + mail.body_contains("127.0.0.1"), + "new-device notification should include the client IP address" + ); + assert_eq!(smtp.message_count(), 1); let response = client.post("/api/v1/auth/logout").send().await; assert_eq!(response.status(), StatusCode::OK); - // login using the same device - let auth = Auth::new("hpotter", "pass123"); + // login again from the SAME device - a known device must not re-notify let response = client .post("/api/v1/auth") .header(USER_AGENT, user_agent_header_iphone) @@ -969,8 +939,15 @@ async fn test_new_device_login(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::OK); - // login using a different device - let auth = Auth::new("hpotter", "pass123"); + // give any (erroneous) fire-and-forget mail a chance to arrive, then confirm none did + sleep(Duration::from_millis(300)).await; + assert_eq!( + smtp.message_count(), + 1, + "logging in from a known device must not send a new-device notification" + ); + + // login from a different device - triggers another notification let response = client .post("/api/v1/auth") .header(USER_AGENT, user_agent_header_android) @@ -979,22 +956,24 @@ async fn test_new_device_login(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::OK); - // assert_eq!( - // mail.subject(), - // "Defguard: new device logged in to your account" - // ); - // assert!(mail.content().contains("IP Address: 127.0.0.1")); - // assert!( - // mail.content() - // .contains("Device type: SM-G930VC, OS: Android 7.0, Chrome Mobile WebView") - // ); + let mails = smtp.wait_for_count(2).await; + assert!( + mails.iter().all(|m| m.sent_to("h.potter@hogwart.edu.uk")), + "both notifications should be addressed to the user" + ); } #[sqlx::test] async fn test_login_ip_headers(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, _) = make_test_client(pool).await; + let (client, state) = make_test_client(pool).await; + let pool = state.pool; + + // point SMTP at a mock server so the new-device notification is delivered + let smtp = MockSmtpServer::start().await; + smtp.configure(&pool).await; + let user_agent_header_iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"; // Works with X-Forwarded-For header @@ -1008,12 +987,20 @@ async fn test_login_ip_headers(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::OK); - // assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - // assert_eq!( - // mail.subject(), - // "Defguard: new device logged in to your account" - // ); - // assert!(mail.content().contains("IP Address: 10.0.0.20")); + // the notification records the IP from X-Forwarded-For rather than the socket + // address; ClientIpAddr uses the *rightmost* (trusted-proxy) entry, which is + // not client-spoofable, so 10.1.1.10 is expected here, not 10.0.0.20 + let mail = smtp + .wait_for(|m| m.sent_to("h.potter@hogwart.edu.uk")) + .await; + assert!( + mail.body_contains("Defguard: New device logged in to your account"), + "new-device notification should carry the expected subject" + ); + assert!( + mail.body_contains("10.1.1.10"), + "notification should record the rightmost X-Forwarded-For IP address" + ); } #[sqlx::test] diff --git a/crates/defguard_core/tests/integration/api/device_posture.rs b/crates/defguard_core/tests/integration/api/device_posture.rs index 3f802fc55b..7fea42fafa 100644 --- a/crates/defguard_core/tests/integration/api/device_posture.rs +++ b/crates/defguard_core/tests/integration/api/device_posture.rs @@ -1303,7 +1303,7 @@ async fn make_service_location(client: &TestClient, name: &str) -> i64 { } #[sqlx::test] -async fn test_set_postures_for_service_location_rejected( +async fn test_set_postures_for_service_location_allowed( _: PgPoolOptions, options: PgConnectOptions, ) { @@ -1319,7 +1319,7 @@ async fn test_set_postures_for_service_location_rejected( .await; client.drain_all_events(); - // assigning posture checks to a service location is rejected + // assigning posture checks to a service location is allowed let response = client .put(format!("/api/v1/network/{service_location_id}/postures")) .json(&AssignPosturesData { @@ -1327,16 +1327,24 @@ async fn test_set_postures_for_service_location_rejected( }) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - client.assert_event_queue_is_empty(); + assert_eq!(response.status(), StatusCode::OK); + let result: Vec = response.json().await; + assert_eq!(result, vec![posture.id]); + + let events = client.drain_all_events(); + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].0, + ApiEventType::LocationPosturesAssigned { .. } + )); - // nothing was assigned to the posture + // the assignment is visible on the posture let response = client .get(format!("/api/v1/device-posture/{}", posture.id)) .send() .await; let fetched: ApiDevicePosture = response.json().await; - assert!(fetched.locations.is_empty()); + assert_eq!(fetched.locations, vec![service_location_id]); // clearing (empty list) is still allowed on a service location let response = client @@ -1347,10 +1355,18 @@ async fn test_set_postures_for_service_location_rejected( .send() .await; assert_eq!(response.status(), StatusCode::OK); + client.drain_all_events(); + + let response = client + .get(format!("/api/v1/device-posture/{}", posture.id)) + .send() + .await; + let fetched: ApiDevicePosture = response.json().await; + assert!(fetched.locations.is_empty()); } #[sqlx::test] -async fn test_set_locations_for_posture_rejects_service_location( +async fn test_set_locations_for_posture_allows_service_location( _: PgPoolOptions, options: PgConnectOptions, ) { @@ -1369,7 +1385,7 @@ async fn test_set_locations_for_posture_rejects_service_location( .await; client.drain_all_events(); - // assigning a service location to a posture is rejected + // assigning a service location to a posture is allowed let response = client .put(format!("/api/v1/device-posture/{}/locations", posture.id)) .json(&AssignLocationsData { @@ -1377,10 +1393,18 @@ async fn test_set_locations_for_posture_rejects_service_location( }) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - client.assert_event_queue_is_empty(); + assert_eq!(response.status(), StatusCode::OK); + let result: Vec = response.json().await; + assert_eq!(result, vec![service_location_id]); - // a mix containing a service location is rejected too — nothing is assigned + let events = client.drain_all_events(); + assert_eq!(events.len(), 1); + assert!(matches!( + events[0].0, + ApiEventType::DevicePostureLocationsAssigned { .. } + )); + + // a mix containing a service location is accepted too let response = client .put(format!("/api/v1/device-posture/{}/locations", posture.id)) .json(&AssignLocationsData { @@ -1388,15 +1412,21 @@ async fn test_set_locations_for_posture_rejects_service_location( }) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - client.assert_event_queue_is_empty(); + assert_eq!(response.status(), StatusCode::OK); + let result: Vec = response.json().await; + assert_eq!(result.len(), 2); + assert!(result.contains(®ular_location_id)); + assert!(result.contains(&service_location_id)); + client.drain_all_events(); let response = client .get(format!("/api/v1/device-posture/{}", posture.id)) .send() .await; let fetched: ApiDevicePosture = response.json().await; - assert!(fetched.locations.is_empty()); + assert_eq!(fetched.locations.len(), 2); + assert!(fetched.locations.contains(®ular_location_id)); + assert!(fetched.locations.contains(&service_location_id)); // assigning only regular locations still works let response = client diff --git a/crates/defguard_core/tests/integration/api/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs index 4704557464..e9e41fd22b 100644 --- a/crates/defguard_core/tests/integration/api/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -1,9 +1,11 @@ use std::time::Duration; -use defguard_common::types::proxy::ProxyControlMessage; +use defguard_common::{db::models::group::Group, types::proxy::ProxyControlMessage}; use defguard_core::{ enterprise::{ - db::models::enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, + db::models::enterprise_settings::{ + ClientTrafficPolicy, EnterpriseSettings, EnterpriseSettingsInfo, + }, license::{get_cached_license, set_cached_license}, }, events::ApiEventType, @@ -40,6 +42,7 @@ async fn test_only_enterprise_can_modify_enterpise_settings( admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, + disable_tunnels: false, display_download_step: true, display_password_reset: true, }; @@ -108,6 +111,7 @@ async fn test_admin_devices_management_is_enforced(_: PgPoolOptions, options: Pg admin_device_management: true, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, + disable_tunnels: false, display_download_step: true, display_password_reset: true, }; @@ -224,6 +228,7 @@ async fn test_regular_user_device_management(_: PgPoolOptions, options: PgConnec admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, + disable_tunnels: false, display_download_step: true, display_password_reset: true, }; @@ -332,6 +337,7 @@ async fn dg25_12_test_enforce_client_activation_only(_: PgPoolOptions, options: admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: true, + disable_tunnels: false, display_download_step: true, display_password_reset: true, }; @@ -458,6 +464,7 @@ async fn dg25_13_test_disable_device_config(_: PgPoolOptions, options: PgConnect admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: true, + disable_tunnels: false, display_download_step: true, display_password_reset: true, }; @@ -510,6 +517,7 @@ async fn test_display_flags_round_trip(_: PgPoolOptions, options: PgConnectOptio admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, + disable_tunnels: false, display_download_step: false, display_password_reset: false, }; @@ -523,13 +531,13 @@ async fn test_display_flags_round_trip(_: PgPoolOptions, options: PgConnectOptio // Read back and verify the values persisted let response = client.get("/api/v1/settings_enterprise").send().await; assert_eq!(response.status(), StatusCode::OK); - let body: EnterpriseSettings = response.json().await; + let body: EnterpriseSettingsInfo = response.json().await; assert!( - !body.display_download_step, + !body.settings.display_download_step, "display_download_step should be false" ); assert!( - !body.display_password_reset, + !body.settings.display_password_reset, "display_password_reset should be false" ); @@ -538,6 +546,7 @@ async fn test_display_flags_round_trip(_: PgPoolOptions, options: PgConnectOptio admin_device_management: false, client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, + disable_tunnels: false, display_download_step: true, display_password_reset: true, }; @@ -551,17 +560,74 @@ async fn test_display_flags_round_trip(_: PgPoolOptions, options: PgConnectOptio // Read back and verify let response = client.get("/api/v1/settings_enterprise").send().await; assert_eq!(response.status(), StatusCode::OK); - let body: EnterpriseSettings = response.json().await; + let body: EnterpriseSettingsInfo = response.json().await; assert!( - body.display_download_step, + body.settings.display_download_step, "display_download_step should be true" ); assert!( - body.display_password_reset, + body.settings.display_password_reset, "display_password_reset should be true" ); } +#[sqlx::test] +async fn test_disable_tunnels_round_trip(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // admin login + let (client, _) = make_test_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 disable_tunnels to true + let settings = EnterpriseSettings { + admin_device_management: false, + client_traffic_policy: ClientTrafficPolicy::None, + only_client_activation: false, + disable_tunnels: true, + display_download_step: true, + display_password_reset: true, + }; + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Read back and verify + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let body: EnterpriseSettings = response.json().await; + assert!(body.disable_tunnels, "disable_tunnels should be true"); + + // Set back to false + let settings = EnterpriseSettings { + admin_device_management: false, + client_traffic_policy: ClientTrafficPolicy::None, + only_client_activation: false, + disable_tunnels: false, + display_download_step: true, + display_password_reset: true, + }; + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Read back and verify + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let body: EnterpriseSettings = response.json().await; + assert!(!body.disable_tunnels, "disable_tunnels should be false"); +} + #[sqlx::test] async fn test_display_flags_default_to_true_without_license( _: PgPoolOptions, @@ -734,3 +800,142 @@ async fn test_public_settings_broadcast_on_save(_: PgPoolOptions, options: PgCon } } } + +#[sqlx::test] +async fn test_group_client_traffic_policies_are_saved_and_validated( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (client, _) = make_test_client(pool.clone()).await; + let auth = Auth::new("admin", "pass123"); + assert_eq!( + client + .post("/api/v1/auth") + .json(&auth) + .send() + .await + .status(), + StatusCode::OK + ); + exceed_enterprise_limits(&client).await; + + let allow_choice = Group::new("allow-choice").save(&pool).await.unwrap(); + let disable = Group::new("disable").save(&pool).await.unwrap(); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({ + "client_traffic_policy": "force_all_traffic", + "group_client_traffic_policies": { + "none": [allow_choice.id], + "disable_all_traffic": [disable.id], + "force_all_traffic": [] + } + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + assert!( + settings + .group_client_traffic_policies + .force_all_traffic + .is_empty() + ); + + let license = get_cached_license().clone(); + set_cached_license(None); + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + set_cached_license(license); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({"display_download_step": false})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({ + "group_client_traffic_policies": { + "none": [allow_choice.id], + "disable_all_traffic": [allow_choice.id], + "force_all_traffic": [] + } + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); + + let response = client + .patch("/api/v1/settings_enterprise") + .json(&json!({ + "group_client_traffic_policies": { + "none": [999999], + "disable_all_traffic": [], + "force_all_traffic": [] + } + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = client.get("/api/v1/settings_enterprise").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings: EnterpriseSettingsInfo = response.json().await; + assert_eq!( + settings.group_client_traffic_policies.none, + vec![allow_choice.id] + ); + assert_eq!( + settings.group_client_traffic_policies.disable_all_traffic, + vec![disable.id] + ); +} diff --git a/crates/defguard_core/tests/integration/api/openid.rs b/crates/defguard_core/tests/integration/api/openid.rs index c750889162..2b95cf5663 100644 --- a/crates/defguard_core/tests/integration/api/openid.rs +++ b/crates/defguard_core/tests/integration/api/openid.rs @@ -1,13 +1,16 @@ use std::str::FromStr; use axum::http::header::ToStrError; -use defguard_common::db::{ - Id, - models::{ - OAuth2AuthorizedApp, Settings, User, - oauth2client::OAuth2Client, - settings::{OPENID_KEY_SIZE, update_current_settings}, +use defguard_common::{ + db::{ + Id, + models::{ + OAuth2AuthorizedApp, Settings, User, + oauth2client::OAuth2Client, + settings::{OPENID_KEY_SIZE, update_current_settings}, + }, }, + testing::smtp::MockSmtpServer, }; use defguard_core::handlers::{Auth, openid_clients::NewOpenIDClient}; use openidconnect::{ @@ -1529,9 +1532,14 @@ async fn dg25_21_test_openid_html_injection(_: PgPoolOptions, options: PgConnect async fn test_openid_flow_new_login_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, _) = make_test_client(pool).await; + let (client, state) = make_test_client(pool).await; + let pool = state.pool; let user_agent_header = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"; + // point SMTP at a mock server so the OIDC new-login notification is delivered + let smtp = MockSmtpServer::start().await; + smtp.configure(&pool).await; + let auth = Auth::new("admin", "pass123"); let response = client .post("/api/v1/auth") @@ -1587,16 +1595,19 @@ async fn test_openid_flow_new_login_mail(_: PgPoolOptions, options: PgConnectOpt let auth_response: AuthenticationResponse = serde_qs::from_str(query).unwrap(); assert_eq!(auth_response.state, "ABCDEF"); - // assert_eq!(mail.to(), "admin@defguard"); - // assert_eq!( - // mail.subject(), - // "New login to Test application with Defguard" - // ); - // assert!(mail.content().contains("IP Address: 127.0.0.1")); - // assert!( - // mail.content() - // .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") - // ); + // authorizing the app for the first time sends a new-OIDC-login notification; + // match it by subject to distinguish it from the password-login new-device mail + let mail = smtp + .wait_for(|m| m.body_contains("New login to OIDC application")) + .await; + assert!( + mail.sent_to("admin@defguard"), + "OIDC login notification should be addressed to the user" + ); + assert!( + mail.body_contains("Test"), + "OIDC login notification should name the authorized application" + ); let response = client .post(format!( diff --git a/crates/defguard_core/tests/integration/api/settings.rs b/crates/defguard_core/tests/integration/api/settings.rs index fdb3b399ff..cec5cf5fe1 100644 --- a/crates/defguard_core/tests/integration/api/settings.rs +++ b/crates/defguard_core/tests/integration/api/settings.rs @@ -340,8 +340,10 @@ async fn test_ldap_remote_enrollment_validation(_: PgPoolOptions, options: PgCon "ldap_remote_enrollment_enabled must be persisted to DB after enabling" ); - // enabling send_invite while remote enrollment is disabled must fail - // (use a fresh settings state: disable enrollment first) + // enabling send_invite while remote enrollment is disabled is allowed: the flag + // combination is no longer validated on submit (see issue #3394). The invite-sending + // guard requires BOTH flags, so a lingering send_invite has no effect while remote + // enrollment is off. Disable enrollment first to reach that state. let patch: SettingsPatch = serde_json::from_str(r#"{ "ldap_remote_enrollment_enabled": false }"#).unwrap(); let response = client.patch("/api/v1/settings").json(&patch).send().await; @@ -357,8 +359,17 @@ async fn test_ldap_remote_enrollment_validation(_: PgPoolOptions, options: PgCon let response = client.patch("/api/v1/settings").json(&patch).send().await; assert_eq!( response.status(), - StatusCode::BAD_REQUEST, - "enabling send_invite without remote enrollment enabled should return 400" + StatusCode::OK, + "enabling send_invite without remote enrollment enabled is allowed (validation relaxed, see #3394)" + ); + let from_db = Settings::get(&pool).await.unwrap().unwrap(); + assert!( + from_db.ldap_remote_enrollment_send_invite, + "ldap_remote_enrollment_send_invite must be persisted even while remote enrollment is disabled" + ); + assert!( + !from_db.ldap_remote_enrollment_enabled, + "remote enrollment must stay disabled - setting send_invite does not enable it" ); // re-enable remote enrollment, then enabling send_invite must succeed diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index 586c949655..7d0f942e1a 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -15,6 +15,7 @@ use defguard_common::{ vpn_session_stats::VpnSessionStats, }, }, + testing::smtp::MockSmtpServer, types::user_info::UserInfo, }; use defguard_core::{ @@ -1439,6 +1440,10 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { let (mut client, state) = make_test_client(pool).await; let user_agent_header = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"; + // point SMTP at a mock server so device/login notifications are delivered + let smtp = MockSmtpServer::start().await; + smtp.configure(&state.pool).await; + let mut expected_events = Vec::new(); // log in as admin @@ -1452,13 +1457,6 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); expected_events.push(ApiEventType::UserLogin); - // first email received is regarding admin login - // assert_eq!(mail.to(), "admin@defguard"); - // assert_eq!( - // mail.subject(), - // "Defguard: new device logged in to your account" - // ); - // create network make_network(&client, "network").await; expected_events.push(ApiEventType::VpnLocationAdded { @@ -1482,13 +1480,6 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { device: get_db_device(&state.pool, 1).await, }); - // send email regarding new device being added - // it does not contain session info - // assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - // assert_eq!(mail.subject(), "Defguard: new device added to your account"); - // assert!(!mail.content().contains("IP Address:")); - // assert!(!mail.content().contains("Device type:")); - // add device for themselves let device_data = AddDevice { name: "TestDevice2".into(), @@ -1506,16 +1497,6 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { device: get_db_device(&state.pool, 2).await, }); - // send email regarding new device being added - // it should contain session info - // assert_eq!(mail.to(), "admin@defguard"); - // assert_eq!(mail.subject(), "Defguard: new device added to your account"); - // assert!(mail.content().contains("IP Address: 127.0.0.1")); - // assert!( - // mail.content() - // .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") - // ); - // log in as normal user let auth = Auth::new("hpotter", "pass123"); let response = client @@ -1530,18 +1511,6 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { let response = client.get("/api/v1/me").send().await; assert_eq!(response.status(), StatusCode::OK); - // send email regarding user login - // assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - // assert_eq!( - // mail.subject(), - // "Defguard: new device logged in to your account" - // ); - // assert!(mail.content().contains("IP Address: 127.0.0.1")); - // assert!( - // mail.content() - // .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") - // ); - // a device with duplicate pubkey cannot be added let response = client .post("/api/v1/device/hpotter") @@ -1577,14 +1546,27 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { device: get_db_device(&state.pool, 3).await, }); - // send email regarding new device being added - // assert_eq!(mail.to(), "h.potter@hogwart.edu.uk"); - // assert_eq!(mail.subject(), "Defguard: new device added to your account"); - // assert!(mail.content().contains("IP Address: 127.0.0.1")); - // assert!( - // mail.content() - // .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") - // ); + // Verify the notifications delivered across the flow (all fire-and-forget, + // so assert the recipient/subject multiset rather than relying on order): + // - admin login -> new-device-login to admin + // - admin adds device (hpotter) -> new-device-added to hpotter + // - admin adds device (self) -> new-device-added to admin + // - hpotter login -> new-device-login to hpotter + // - hpotter adds device (self) -> new-device-added to hpotter + let mails = smtp.wait_for_count(5).await; + let login_subject = "Defguard: New device logged in to your account"; + let added_subject = "Defguard: new device added to your account"; + let count = |to: &str, subject: &str| { + mails + .iter() + .filter(|m| m.sent_to(to) && m.body_contains(subject)) + .count() + }; + assert_eq!(count("admin@defguard", login_subject), 1); + assert_eq!(count("admin@defguard", added_subject), 1); + assert_eq!(count("h.potter@hogwart.edu.uk", login_subject), 1); + assert_eq!(count("h.potter@hogwart.edu.uk", added_subject), 2); + assert_eq!(mails.len(), 5, "exactly five notifications expected"); client.verify_api_events(&expected_events); } @@ -2932,3 +2914,39 @@ async fn test_password_management_disabled_for_oidc_user( .await; assert_eq!(response.status(), StatusCode::FORBIDDEN); } + +#[sqlx::test] +async fn test_reset_password_sends_email(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, pool) = make_client_with_db(pool).await; + + // Configure a proxy URL (needed to build the reset link) and point SMTP at + // an in-process mock server so the reset email is actually delivered. + let mut settings = Settings::get_current_settings(); + settings.public_proxy_url = "https://proxy.example.com".to_string(); + update_current_settings(&pool, settings).await.unwrap(); + let smtp = MockSmtpServer::start().await; + smtp.configure(&pool).await; + + // Admin triggers a password reset for another user. + client.login_user("admin", "pass123").await; + let response = client + .post("/api/v1/user/hpotter/reset_password") + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // The reset email (sent fire-and-forget) is delivered to the target user + // and carries a tokenized reset link pointing at the configured proxy. + let mail = smtp + .wait_for(|m| m.sent_to("h.potter@hogwart.edu.uk")) + .await; + assert!( + mail.body_contains("token"), + "reset email should contain a reset token link" + ); + assert!( + mail.body_contains("proxy.example.com"), + "reset link should point at the configured proxy URL" + ); +} diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 3898f6d3ff..16cb7826e5 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -34,7 +34,8 @@ use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::common::{ - authenticate_admin, exceed_enterprise_limits, make_network, make_test_client, setup_pool, + authenticate_admin, client::TestClient, exceed_enterprise_limits, fetch_user_details, + make_network, make_test_client, setup_pool, }; const INVALID_MFA_PEER_DISCONNECT_THRESHOLD: i32 = 119; @@ -347,6 +348,466 @@ async fn test_create_network_with_posture_checks_requires_enterprise_license( })); } +/// Build a location payload with overridable name, address and mode fields. +/// `posture_checks` is intentionally absent — add it explicitly where it matters. +fn location_payload( + name: &str, + address: &str, + location_mfa_mode: &str, + service_location_mode: &str, +) -> serde_json::Value { + json!({ + "name": name, + "address": address, + "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, + "location_mfa_mode": location_mfa_mode, + "service_location_mode": service_location_mode + }) +} + +/// Create a posture check and return its ID. +async fn make_posture_check(client: &TestClient, name: &str) -> i64 { + let response = client + .post("/api/v1/device-posture") + .json(&json!({ + "name": name, + "description": null, + "min_desktop_client_version": null, + "min_mobile_client_version": null, + "allow_prerelease_client": false, + "os_rules": [] + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let posture: serde_json::Value = response.json().await; + posture["id"].as_i64().unwrap() +} + +/// Fetch the posture checks assigned to a location. +async fn fetch_location_postures(client: &TestClient, location_id: i64) -> Vec { + let response = client + .get(format!("/api/v1/network/{location_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let network: serde_json::Value = response.json().await; + serde_json::from_value(network["posture_checks"].clone()).unwrap() +} + +#[sqlx::test] +async fn test_modify_network_does_not_notify_gateway_when_commit_fails( + _: 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 response = client + .post("/api/v1/network") + .json(&location_payload( + "location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + + let pool = client_state.pool.clone(); + let mut gateway_rx = client_state.gateway_rx; + assert_matches!( + gateway_rx.try_recv().unwrap(), + GatewayCommand::NetworkCreated(..) + ); + + sqlx::query( + "CREATE FUNCTION fail_network_update_commit() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'forced commit failure'; + END; + $$ LANGUAGE plpgsql", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "CREATE CONSTRAINT TRIGGER fail_network_update_commit + AFTER UPDATE ON wireguard_network + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fail_network_update_commit()", + ) + .execute(&pool) + .await + .unwrap(); + + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "renamed-location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_matches!( + gateway_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + ); + + let response = client + .get(format!("/api/v1/network/{}", location.id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let persisted: WireguardNetwork = response.json().await; + assert_eq!(persisted.name, "location"); +} + +#[sqlx::test] +async fn test_create_network_rejects_service_location_with_mfa( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + for service_location_mode in ["prelogon", "alwayson"] { + let response = client + .post("/api/v1/network") + .json(&location_payload( + "mfa-service-location", + "10.1.1.1/24", + "internal", + service_location_mode, + )) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "MFA + service location mode {service_location_mode} must be rejected" + ); + } + + // MFA without service location mode is fine + let response = client + .post("/api/v1/network") + .json(&location_payload( + "mfa-only", + "10.1.1.1/24", + "internal", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // service location mode without MFA is fine + let response = client + .post("/api/v1/network") + .json(&location_payload( + "service-location-only", + "10.2.2.1/24", + "disabled", + "prelogon", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +/// A zero keepalive stops `last_handshake` from ever advancing on an idle tunnel, which would make +/// the posture health check re-authorize forever (D6/R7). The web forms block it, but an API caller +/// bypasses them entirely, so core has to reject it too. +#[sqlx::test] +async fn test_network_rejects_zero_keepalive_interval(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let mut payload = location_payload("zero-keepalive", "10.1.1.1/24", "disabled", "disabled"); + payload["keepalive_interval"] = json!(0); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "keepalive_interval 0 must be rejected on create" + ); + + // A valid location, so the same rule can be checked on the modify path. + let response = client + .post("/api/v1/network") + .json(&location_payload( + "good-keepalive", + "10.2.2.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + 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"); + payload["keepalive_interval"] = json!(0); + let response = client + .put(format!("/api/v1/network/{location_id}")) + .json(&payload) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "keepalive_interval 0 must be rejected on modify" + ); + + // 1 is the floor, not a rejected edge. + payload["keepalive_interval"] = json!(1); + let response = client + .put(format!("/api/v1/network/{location_id}")) + .json(&payload) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn test_modify_network_rejects_service_location_with_mfa( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let response = client + .post("/api/v1/network") + .json(&location_payload( + "location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + + for service_location_mode in ["prelogon", "alwayson"] { + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "location", + "10.1.1.1/24", + "internal", + service_location_mode, + )) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "MFA + service location mode {service_location_mode} must be rejected" + ); + } + + // the rejected combination was not persisted in any form + let response = client + .get(format!("/api/v1/network/{}", location.id)) + .send() + .await; + let fetched: WireguardNetwork = response.json().await; + assert_eq!(fetched.location_mfa_mode, LocationMfaMode::Disabled); + assert_eq!(fetched.service_location_mode, ServiceLocationMode::Disabled); + + // enabling service location mode alone is accepted and persisted + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "location", + "10.1.1.1/24", + "disabled", + "prelogon", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let modified: WireguardNetwork = response.json().await; + assert_eq!( + modified.service_location_mode, + ServiceLocationMode::PreLogon + ); +} + +#[sqlx::test] +async fn test_modify_network_without_posture_checks_keeps_assignments( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + let posture = make_posture_check(&client, "Posture").await; + + let mut payload = location_payload("location", "10.1.1.1/24", "disabled", "disabled"); + payload["posture_checks"] = json!([posture]); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); + + // a payload with the field omitted must leave the assignment alone + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "renamed-location", + "10.1.1.1/24", + "disabled", + "disabled", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let modified: WireguardNetwork = response.json().await; + assert_eq!(modified.name, "renamed-location"); + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); + + // an explicit `null` behaves the same way + let mut payload = location_payload("location", "10.1.1.1/24", "disabled", "disabled"); + payload["posture_checks"] = json!(null); + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&payload) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); +} + +#[sqlx::test] +async fn test_posture_checks_allowed_on_service_locations( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (mut client, _client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + set_enterprise_license(); + + 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"); + payload["posture_checks"] = json!([posture]); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let service_location: WireguardNetwork = response.json().await; + assert_eq!( + service_location.service_location_mode, + ServiceLocationMode::PreLogon + ); + assert_eq!( + fetch_location_postures(&client, service_location.id).await, + vec![posture] + ); + + // 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"); + payload["posture_checks"] = json!([posture]); + let response = client.post("/api/v1/network").json(&payload).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + + let response = client + .put(format!("/api/v1/network/{}", location.id)) + .json(&location_payload( + "regular-location", + "10.2.2.1/24", + "disabled", + "alwayson", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let modified: WireguardNetwork = response.json().await; + assert_eq!( + modified.service_location_mode, + ServiceLocationMode::AlwaysOn + ); + assert_eq!( + fetch_location_postures(&client, location.id).await, + vec![posture] + ); + + // dedicated assignment path: posture checks can be assigned to an existing service location + let response = client + .post("/api/v1/network") + .json(&location_payload( + "service-location-without-postures", + "10.3.3.1/24", + "disabled", + "alwayson", + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let service_location_without_postures: WireguardNetwork = response.json().await; + assert!( + fetch_location_postures(&client, service_location_without_postures.id) + .await + .is_empty() + ); + + let response = client + .put(format!( + "/api/v1/network/{}/postures", + service_location_without_postures.id + )) + .json(&json!({ "postures": [posture] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + fetch_location_postures(&client, service_location_without_postures.id).await, + vec![posture] + ); +} + #[sqlx::test] async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1451,6 +1912,66 @@ async fn test_user_device_configs_auth(_: PgPoolOptions, options: PgConnectOptio ); } +/// Regression test: an admin manually adding a device for a disabled user used to +/// silently succeed with an empty config list, since the device was never actually +/// allowed to join any network. Disabled users' devices are also stripped from every +/// network on the next sync anyway (see `process_device_access_changes`), so letting +/// an admin add one would only work until that sync runs. +#[sqlx::test] +async fn test_add_device_for_disabled_user(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // Network open to all users, so the only thing blocking the device is `is_active`. + client + .post("/api/v1/network") + .json(&json!({ + "name": "network", + "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": [], + "allow_all_groups": true, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "allowed_ips_from_acl": false, + "location_mfa_mode": "disabled", + "service_location_mode": "disabled" + })) + .send() + .await; + + // Disable hpotter. + let mut user_details = fetch_user_details(&client, "hpotter").await; + user_details.user.is_active = false; + let response = client + .put("/api/v1/user/hpotter") + .json(&user_details.user) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Admin tries to add a device for the disabled user manually. Should be rejected + // outright, not silently created with no usable config. + let device_payload = json!({ + "name": "disabled-user-device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device_payload) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + /// MFA locations (internal/external) must be excluded from the user device config endpoint. /// A user should only receive configs for regular (non-MFA) locations since MFA location /// connections are possible only with the Defguard client apps, not standard WireGuard clients. diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index d3a3964011..0fe69cb5d1 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -28,7 +28,8 @@ use defguard_core::{ }, events::{ ApiEvent, ApiEventType, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent, - DirectorySyncEvent, DirectorySyncEventType, LdapSyncEventType, PasswordResetEvent, + DirectorySyncEvent, DirectorySyncEventType, GatewayConnectionEvent, LdapSyncEventType, + PasswordResetEvent, ProxyConnectionEvent, }, }; use defguard_session_manager::events::{SessionManagerEvent, SessionManagerEventType}; @@ -54,6 +55,8 @@ pub async fn run_event_logger( session_manager_event_rx: UnboundedReceiver, ldap_sync_event_rx: UnboundedReceiver, directory_sync_event_rx: UnboundedReceiver, + gateway_connection_event_rx: UnboundedReceiver, + proxy_connection_event_rx: UnboundedReceiver, activity_log_stream_reload_notify: Arc, activity_log_messages_tx: tokio::sync::broadcast::Sender, ) -> Result<(), EventLoggerError> { @@ -68,6 +71,8 @@ pub async fn run_event_logger( session_manager_event_rx, ldap_sync_event_rx, directory_sync_event_rx, + gateway_connection_event_rx, + proxy_connection_event_rx, activity_log_stream_reload_notify, event_logger_tx, )); @@ -97,12 +102,15 @@ pub async fn run_event_logger( /// an `EventLoggerMessage`, and forwards it to the batch processing loop. /// When any source channel closes, the task exits and the forwarding channel /// is dropped, causing the batch loop to shut down gracefully. +#[allow(clippy::too_many_arguments)] async fn translate_and_forward( mut api_event_rx: UnboundedReceiver, mut bidi_event_rx: UnboundedReceiver, mut session_manager_event_rx: UnboundedReceiver, mut ldap_sync_event_rx: UnboundedReceiver, mut directory_sync_event_rx: UnboundedReceiver, + mut gateway_connection_event_rx: UnboundedReceiver, + mut proxy_connection_event_rx: UnboundedReceiver, reload_notify: Arc, event_logger_tx: tokio::sync::mpsc::UnboundedSender, ) { @@ -128,6 +136,14 @@ async fn translate_and_forward( error!("OIDC directory sync event channel closed"); break; }, + event = gateway_connection_event_rx.recv() => if let Some(e) = event { EventLoggerMessage::from_gateway_connection_event(e) } else { + error!("Gateway connection event channel closed"); + break; + }, + event = proxy_connection_event_rx.recv() => if let Some(e) = event { EventLoggerMessage::from_proxy_connection_event(e) } else { + error!("Proxy connection event channel closed"); + break; + }, }; if event_logger_tx.send(message).is_err() { @@ -969,6 +985,68 @@ fn map_to_activity_log_event(message: EventLoggerMessage) -> ActivityLogEvent { + let (event_type, description) = match &event { + GatewayConnectionEvent::Connected { gateway_name, .. } => ( + EventType::GatewayConnected, + format!("Gateway {gateway_name} connected"), + ), + GatewayConnectionEvent::Disconnected { gateway_name, .. } => ( + EventType::GatewayDisconnected, + format!("Gateway {gateway_name} disconnected"), + ), + }; + let metadata = match event { + GatewayConnectionEvent::Connected { + gateway_id, + gateway_name, + } + | GatewayConnectionEvent::Disconnected { + gateway_id, + gateway_name, + } => Some(serde_json::json!({ + "gateway_id": gateway_id, + "gateway_name": gateway_name, + })), + }; + ( + ActivityLogModule::Defguard, + event_type, + Some(description), + metadata, + ) + } + Event::ProxyConnection(event) => { + let (event_type, description) = match &event { + ProxyConnectionEvent::Connected { proxy_name, .. } => ( + EventType::ProxyConnected, + format!("Proxy {proxy_name} connected"), + ), + ProxyConnectionEvent::Disconnected { proxy_name, .. } => ( + EventType::ProxyDisconnected, + format!("Proxy {proxy_name} disconnected"), + ), + }; + let metadata = match event { + ProxyConnectionEvent::Connected { + proxy_id, + proxy_name, + } + | ProxyConnectionEvent::Disconnected { + proxy_id, + proxy_name, + } => Some(serde_json::json!({ + "proxy_id": proxy_id, + "proxy_name": proxy_name, + })), + }; + ( + ActivityLogModule::Defguard, + event_type, + Some(description), + metadata, + ) + } Event::OidcDirectorySync { provider, event } => { let module = ActivityLogModule::OidcDirectorySync; let description = match &event { @@ -978,6 +1056,9 @@ fn map_to_activity_log_event(message: EventLoggerMessage) -> ActivityLogEvent { Some(format!("{provider} directory sync deleted user {user}")) } + DirectorySyncEventType::UserModified { after, .. } => { + Some(format!("{provider} directory sync modified user {after}")) + } DirectorySyncEventType::UserEnabled { user } => { Some(format!("{provider} directory sync enabled user {user}")) } @@ -1014,6 +1095,14 @@ fn map_to_activity_log_event(message: EventLoggerMessage) -> ActivityLogEvent ( + EventType::OidcDirectorySyncUserModified, + serde_json::to_value(UserModifiedMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), DirectorySyncEventType::UserEnabled { user } => ( EventType::OidcDirectorySyncUserEnabled, serde_json::to_value(OidcDirectorySyncUserMetadata { diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index 48c6f3fd96..5a338fdade 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -8,7 +8,7 @@ use defguard_common::db::{ use defguard_core::events::{ ApiEvent, ApiEventType, ApiRequestContext, BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent, DirectorySyncEvent, DirectorySyncEventType, - GrpcRequestContext, LdapSyncEventType, + GatewayConnectionEvent, GrpcRequestContext, LdapSyncEventType, ProxyConnectionEvent, }; use defguard_session_manager::events::{ SessionManagerEvent, SessionManagerEventContext, SessionManagerEventType, @@ -34,6 +34,8 @@ pub enum Event { uses_ad: bool, event: LdapSyncEventType, }, + GatewayConnection(GatewayConnectionEvent), + ProxyConnection(ProxyConnectionEvent), OidcDirectorySync { /// Name of the directory provider the change came from (e.g. `Google`, /// `Microsoft`, `Okta`, `JumpCloud`). Included in the resulting activity @@ -126,6 +128,22 @@ impl EventLoggerMessage { } } + #[must_use] + pub fn from_gateway_connection_event(event: GatewayConnectionEvent) -> Self { + Self { + context: EventContext::system_gateway(), + event: Event::GatewayConnection(event), + } + } + + #[must_use] + pub fn from_proxy_connection_event(event: ProxyConnectionEvent) -> Self { + Self { + context: EventContext::system_proxy(), + event: Event::ProxyConnection(event), + } + } + /// Translate an OIDC directory sync event into a logger message. #[must_use] pub fn from_directory_sync_event(dirsync_event: DirectorySyncEvent) -> Self { @@ -233,6 +251,30 @@ impl EventContext { device: "system".to_owned(), } } + + #[must_use] + pub fn system_gateway() -> Self { + Self { + timestamp: chrono::Utc::now().naive_utc(), + user_id: None, + username: "system:gateway".to_owned(), + location: None, + ip: None, + device: "system".to_owned(), + } + } + + #[must_use] + pub fn system_proxy() -> Self { + Self { + timestamp: chrono::Utc::now().naive_utc(), + user_id: None, + username: "system:edge".to_owned(), + location: None, + ip: None, + device: "system".to_owned(), + } + } } impl From for EventContext { diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 9807d8babd..288ded831f 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -23,7 +23,7 @@ use defguard_core::{ activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, api_tokens::ApiToken, device_posture::{DevicePosture, DevicePostureSnapshot}, - enterprise_settings::EnterpriseSettings, + enterprise_settings::EnterpriseSettingsInfo, openid_provider::{ DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider, OpenIdProviderKind, }, @@ -32,7 +32,8 @@ use defguard_core::{ events::{ ApiEventType, BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent, DirectorySyncEvent, DirectorySyncEventType, - EnrollmentEvent as CoreEnrollmentEvent, LdapSyncEventType, PasswordResetEvent, + EnrollmentEvent as CoreEnrollmentEvent, GatewayConnectionEvent, LdapSyncEventType, + PasswordResetEvent, ProxyConnectionEvent, }, }; use defguard_session_manager::events::SessionManagerEventType; @@ -852,8 +853,8 @@ fn api_event_cases() -> Vec { EventTestCase { name: "EnterpriseSettingsUpdated", message: api_message(ApiEventType::EnterpriseSettingsUpdated { - before: EnterpriseSettings::default(), - after: EnterpriseSettings::default(), + before: EnterpriseSettingsInfo::default(), + after: EnterpriseSettingsInfo::default(), }), event_type: EventType::EnterpriseSettingsUpdated, module: ActivityLogModule::Defguard, @@ -1603,6 +1604,16 @@ fn directory_sync_event_cases() -> Vec { module: ActivityLogModule::OidcDirectorySync, description_contains: Some("deleted user"), }, + EventTestCase { + name: "OidcDirectorySyncUserModified", + message: dirsync_msg(DirectorySyncEventType::UserModified { + before: user.clone(), + after: user.clone(), + }), + event_type: EventType::OidcDirectorySyncUserModified, + module: ActivityLogModule::OidcDirectorySync, + description_contains: Some("modified user"), + }, EventTestCase { name: "OidcDirectorySyncUserEnabled", message: dirsync_msg(DirectorySyncEventType::UserEnabled { user: user.clone() }), @@ -1718,6 +1729,68 @@ fn test_ldap_sync_events_use_system_context() { } } +#[test] +fn test_gateway_connection_events_map_to_system_activity_events() { + for (event, expected_type, description) in [ + ( + GatewayConnectionEvent::Connected { + gateway_id: 7, + gateway_name: "edge-a".to_owned(), + }, + EventType::GatewayConnected, + "Gateway edge-a connected", + ), + ( + GatewayConnectionEvent::Disconnected { + gateway_id: 7, + gateway_name: "edge-a".to_owned(), + }, + EventType::GatewayDisconnected, + "Gateway edge-a disconnected", + ), + ] { + let message = EventLoggerMessage::from_gateway_connection_event(event); + assert_eq!(message.context.user_id, None); + assert_eq!(message.context.username, "system:gateway"); + + let result = map_to_activity_log_event(message); + assert_eq!(result.event, expected_type); + assert_eq!(result.module, ActivityLogModule::Defguard); + assert_eq!(result.description.as_deref(), Some(description)); + } +} + +#[test] +fn test_proxy_connection_events_map_to_system_activity_events() { + for (event, expected_type, description) in [ + ( + ProxyConnectionEvent::Connected { + proxy_id: 7, + proxy_name: "proxy-a".to_owned(), + }, + EventType::ProxyConnected, + "Proxy proxy-a connected", + ), + ( + ProxyConnectionEvent::Disconnected { + proxy_id: 7, + proxy_name: "proxy-a".to_owned(), + }, + EventType::ProxyDisconnected, + "Proxy proxy-a disconnected", + ), + ] { + let message = EventLoggerMessage::from_proxy_connection_event(event); + assert_eq!(message.context.user_id, None); + assert_eq!(message.context.username, "system:edge"); + + let result = map_to_activity_log_event(message); + assert_eq!(result.event, expected_type); + assert_eq!(result.module, ActivityLogModule::Defguard); + assert_eq!(result.description.as_deref(), Some(description)); + } +} + #[test] fn test_ldap_sync_event_module_follows_uses_ad() { // The event types are shared between Active Directory and plain LDAP; only the diff --git a/crates/defguard_gateway_manager/src/handler.rs b/crates/defguard_gateway_manager/src/handler.rs index 60070c80f6..32e2123d40 100644 --- a/crates/defguard_gateway_manager/src/handler.rs +++ b/crates/defguard_gateway_manager/src/handler.rs @@ -28,6 +28,7 @@ use defguard_common::{ }; use defguard_core::{ enterprise::firewall::try_get_location_firewall_config, + events::GatewayConnectionEvent, handlers::mail::{send_gateway_disconnected_email, send_gateway_reconnected_email}, location_management::allowed_peers::get_location_allowed_peers, }; @@ -90,6 +91,7 @@ pub(crate) struct GatewayHandler { message_id: AtomicU64, pool: PgPool, events_tx: Sender, + connection_events_tx: UnboundedSender, peer_stats_tx: UnboundedSender, certs_rx: watch::Receiver>>, updates_handler_handle: Option>, @@ -104,6 +106,7 @@ impl GatewayHandler { gateway: Gateway, pool: PgPool, events_tx: Sender, + connection_events_tx: UnboundedSender, peer_stats_tx: UnboundedSender, certs_rx: watch::Receiver>>, ) -> Result { @@ -120,6 +123,7 @@ impl GatewayHandler { message_id: AtomicU64::new(0), pool, events_tx, + connection_events_tx, peer_stats_tx, certs_rx, updates_handler_handle: None, @@ -340,24 +344,35 @@ impl GatewayHandler { }); } - async fn mark_disconnected(&mut self) { + async fn mark_disconnected(&mut self) -> bool { if let Err(err) = self.gateway.touch_disconnected(&self.pool).await { error!( "Failed to update disconnection time for {} in the database: {err}", self.gateway ); + return false; } + true } async fn handle_disconnection_error(&mut self) { + let was_connected = self.gateway.is_connected(); if self.gateway.is_connected() { self.send_disconnect_notification().await; } - self.mark_disconnected().await; + if was_connected && self.mark_disconnected().await { + let _ = self + .connection_events_tx + .send(GatewayConnectionEvent::Disconnected { + gateway_id: self.gateway.id, + gateway_name: self.gateway.name.clone(), + }); + } } async fn mark_connected_and_maybe_notify(&mut self, network_name: &str) { + let was_connected = self.gateway.is_connected(); if let Err(err) = self.gateway.touch_connected(&self.pool).await { error!( "Failed to update connection time for {} in the database: {err}", @@ -366,6 +381,15 @@ impl GatewayHandler { return; } + if !was_connected { + let _ = self + .connection_events_tx + .send(GatewayConnectionEvent::Connected { + gateway_id: self.gateway.id, + gateway_name: self.gateway.name.clone(), + }); + } + self.send_reconnect_notification(network_name.to_owned()); } @@ -581,11 +605,19 @@ impl GatewayHandler { gateway: Gateway, pool: PgPool, events_tx: Sender, + connection_events_tx: UnboundedSender, peer_stats_tx: UnboundedSender, certs_rx: watch::Receiver>>, socket_path: PathBuf, ) -> Result { - let mut handler = Self::new(gateway, pool, events_tx, peer_stats_tx, certs_rx)?; + let mut handler = Self::new( + gateway, + pool, + events_tx, + connection_events_tx, + peer_stats_tx, + certs_rx, + )?; handler.test_transport = GatewayTestTransport::with_socket_path(socket_path); Ok(handler) } @@ -1482,10 +1514,18 @@ mod tests { .await .unwrap(); let (events_tx, _events_rx) = broadcast::channel::(1); + let (connection_events_tx, _connection_events_rx) = unbounded_channel(); let (peer_stats_tx, _peer_stats_rx) = unbounded_channel(); let (_certs_tx, certs_rx) = watch::channel(Arc::new(HashMap::::new())); - let handler = - GatewayHandler::new(gateway, pool.clone(), events_tx, peer_stats_tx, certs_rx).unwrap(); + let handler = GatewayHandler::new( + gateway, + pool.clone(), + events_tx, + connection_events_tx, + peer_stats_tx, + certs_rx, + ) + .unwrap(); let (tx, mut rx) = unbounded_channel(); handler.send_configuration(&tx).await.unwrap(); diff --git a/crates/defguard_gateway_manager/src/lib.rs b/crates/defguard_gateway_manager/src/lib.rs index 61243ce832..0ea94a548a 100644 --- a/crates/defguard_gateway_manager/src/lib.rs +++ b/crates/defguard_gateway_manager/src/lib.rs @@ -14,13 +14,18 @@ use defguard_common::{ gateway_event::GatewayCommand, messages::peer_stats_update::PeerStatsUpdate, }; +use defguard_core::events::GatewayConnectionEvent; use defguard_proto::gateway::gateway_client::GatewayClient; use defguard_version::client::ClientVersionInterceptor; use sqlx::{PgPool, postgres::PgListener}; #[cfg(test)] use tokio::sync::Notify; use tokio::{ - sync::{broadcast::Sender, mpsc::UnboundedSender, watch::Receiver}, + sync::{ + broadcast::Sender, + mpsc::{UnboundedSender, unbounded_channel}, + watch::Receiver, + }, task::{AbortHandle, JoinHandle, JoinSet}, time::sleep, }; @@ -313,6 +318,7 @@ impl GatewayManager { gateway, self.pool.clone(), self.tx.events.clone(), + self.tx.connection_events.clone(), self.tx.peer_stats.clone(), certs_rx, socket_path, @@ -322,6 +328,7 @@ impl GatewayManager { gateway, self.pool.clone(), self.tx.events.clone(), + self.tx.connection_events.clone(), self.tx.peer_stats.clone(), certs_rx, )? @@ -336,6 +343,7 @@ impl GatewayManager { gateway, self.pool.clone(), self.tx.events.clone(), + self.tx.connection_events.clone(), self.tx.peer_stats.clone(), certs_rx, ) @@ -658,14 +666,29 @@ mod unit_tests { pub struct GatewayTxSet { events: Sender, peer_stats: UnboundedSender, + connection_events: UnboundedSender, } impl GatewayTxSet { #[must_use] - pub const fn new( + pub fn new( events: Sender, peer_stats: UnboundedSender, ) -> Self { - Self { events, peer_stats } + let (connection_events, _receiver) = unbounded_channel(); + Self { + events, + peer_stats, + connection_events, + } + } + + #[must_use] + pub fn with_connection_events( + mut self, + connection_events: UnboundedSender, + ) -> Self { + self.connection_events = connection_events; + self } } diff --git a/crates/defguard_gateway_manager/src/tests/common/mod.rs b/crates/defguard_gateway_manager/src/tests/common/mod.rs index 8c73750ae9..dd9436d7c8 100644 --- a/crates/defguard_gateway_manager/src/tests/common/mod.rs +++ b/crates/defguard_gateway_manager/src/tests/common/mod.rs @@ -22,6 +22,7 @@ use defguard_common::{ gateway_event::GatewayCommand, messages::peer_stats_update::PeerStatsUpdate, }; +use defguard_core::events::GatewayConnectionEvent; use defguard_proto::gateway::{CoreRequest, CoreResponse, PeerStats, core_request, gateway_server}; use prost_types::Timestamp; use sqlx::{PgPool, postgres::PgConnectOptions}; @@ -476,6 +477,7 @@ pub(crate) struct HandlerTestContext { pub(crate) network: WireguardNetwork, pub(crate) gateway: Gateway, pub(crate) peer_stats_rx: UnboundedReceiver, + pub(crate) connection_events_rx: UnboundedReceiver, events_tx: Option>, pub(crate) mock_gateway: Option, handler_task: Option>>, @@ -498,12 +500,14 @@ impl HandlerTestContext { let network = create_network(&pool).await; let gateway = create_gateway(&pool, network.id).await; let (peer_stats_tx, peer_stats_rx) = mpsc::unbounded_channel(); + let (connection_events_tx, connection_events_rx) = mpsc::unbounded_channel(); let (_, certs_rx) = watch::channel(Arc::new(HashMap::new())); let mut mock_gateway = MockGatewayHarness::start().await; let mut handler = GatewayHandler::new_with_test_socket( gateway.clone(), pool.clone(), events_tx.clone(), + connection_events_tx, peer_stats_tx, certs_rx, mock_gateway.socket_path(), @@ -518,6 +522,7 @@ impl HandlerTestContext { network, gateway, peer_stats_rx, + connection_events_rx, events_tx: Some(events_tx), mock_gateway: Some(mock_gateway), handler_task: Some(handler_task), @@ -530,6 +535,13 @@ impl HandlerTestContext { .expect("events sender already taken from context") } + pub(crate) fn take_connection_events_rx( + &mut self, + ) -> UnboundedReceiver { + let (_, receiver) = mpsc::unbounded_channel(); + std::mem::replace(&mut self.connection_events_rx, receiver) + } + pub(crate) fn mock_gateway(&self) -> &MockGatewayHarness { self.mock_gateway .as_ref() diff --git a/crates/defguard_gateway_manager/src/tests/gateway_manager/handler.rs b/crates/defguard_gateway_manager/src/tests/gateway_manager/handler.rs index 4c77e1fdd9..85e705797d 100644 --- a/crates/defguard_gateway_manager/src/tests/gateway_manager/handler.rs +++ b/crates/defguard_gateway_manager/src/tests/gateway_manager/handler.rs @@ -5,6 +5,7 @@ use defguard_common::{ db::models::device::{DeviceInfo, WireguardNetworkDevice}, gateway_event::GatewayCommand, }; +use defguard_core::events::GatewayConnectionEvent; use defguard_proto::gateway::{UpdateType, core_response}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tonic::Status; diff --git a/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/lifecycle.rs b/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/lifecycle.rs index 8c86a03789..d50bdb3d78 100644 --- a/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/lifecycle.rs +++ b/crates/defguard_gateway_manager/src/tests/gateway_manager/handler/lifecycle.rs @@ -11,6 +11,16 @@ async fn test_gateway_is_marked_connected_after_successful_config_handshake( let gateway_after = context.complete_config_handshake().await; assert!(gateway_after.is_connected()); assert!(gateway_after.connected_at.is_some()); + let gateway_id = context.gateway.id; + let gateway_name = context.gateway.name.clone(); + let mut connection_events_rx = context.take_connection_events_rx(); + assert_eq!( + connection_events_rx.recv().await, + Some(GatewayConnectionEvent::Connected { + gateway_id, + gateway_name, + }) + ); context.finish().await.expect_server_finished().await; } @@ -24,13 +34,29 @@ async fn test_gateway_is_marked_disconnected_when_stream_closes( let connected_gateway = context.complete_config_handshake().await; assert!(connected_gateway.is_connected()); + let gateway_id = context.gateway.id; + let gateway_name = context.gateway.name.clone(); + let mut connection_events_rx = context.take_connection_events_rx(); let pool = context.pool.clone(); - let gateway_id = context.gateway.id; let mock_gateway = context.finish().await; let disconnected_gateway = reload_gateway(&pool, gateway_id).await; assert!(!disconnected_gateway.is_connected()); assert!(disconnected_gateway.disconnected_at.is_some()); + assert_eq!( + connection_events_rx.recv().await, + Some(GatewayConnectionEvent::Connected { + gateway_id, + gateway_name: gateway_name.clone(), + }) + ); + assert_eq!( + connection_events_rx.recv().await, + Some(GatewayConnectionEvent::Disconnected { + gateway_id, + gateway_name, + }) + ); mock_gateway.expect_server_finished().await; } diff --git a/crates/defguard_proxy_manager/Cargo.toml b/crates/defguard_proxy_manager/Cargo.toml index e7df66903d..e4922d0d59 100644 --- a/crates/defguard_proxy_manager/Cargo.toml +++ b/crates/defguard_proxy_manager/Cargo.toml @@ -30,6 +30,8 @@ tonic.workspace = true tracing.workspace = true [dev-dependencies] +# Enable the shared test-only helpers (mock SMTP server) for the test build. +defguard_common = { workspace = true, features = ["test-support"] } base32.workspace = true base64.workspace = true hyper-util = "0.1" diff --git a/crates/defguard_proxy_manager/src/handler.rs b/crates/defguard_proxy_manager/src/handler.rs index 26e4ec0f33..327c35059e 100644 --- a/crates/defguard_proxy_manager/src/handler.rs +++ b/crates/defguard_proxy_manager/src/handler.rs @@ -32,7 +32,7 @@ use defguard_core::{ ldap::utils::ldap_update_user_state, }, error::WebError, - events::{ApiEvent, DirectorySyncEvent, LdapSyncEventType}, + events::{ApiEvent, DirectorySyncEvent, LdapSyncEventType, ProxyConnectionEvent}, grpc::{ GatewayCommand, proxy::client_mfa::{ @@ -122,6 +122,7 @@ pub(super) struct ProxyHandler { /// Shared map used to register this handler's active stream sender so the manager /// can push messages to a specific proxy. handler_tx_map: HandlerTxMap, + connection_events_tx: UnboundedSender, #[cfg(test)] test_transport: ProxyTestTransport, #[cfg(test)] @@ -153,6 +154,7 @@ impl ProxyHandler { proxy_cookie_key, client: None, handler_tx_map, + connection_events_tx: tx.connection_events.clone(), #[cfg(test)] test_transport: ProxyTestTransport::default(), #[cfg(test)] @@ -188,9 +190,18 @@ impl ProxyHandler { async fn mark_connected(&self, version: &Version) -> Result<(), ProxyError> { if let Some(mut proxy) = Proxy::find_by_id(&self.pool, self.proxy_id).await? { + let was_connected = proxy.is_connected(); proxy .mark_connected(&self.pool, version.to_string()) .await?; + if !was_connected { + let _ = self + .connection_events_tx + .send(ProxyConnectionEvent::Connected { + proxy_id: proxy.id, + proxy_name: proxy.name, + }); + } } else { warn!("Couldn't find Proxy by ID for URL: {}", self.url); } @@ -213,6 +224,12 @@ impl ProxyHandler { if should_mark { proxy.mark_disconnected(&self.pool).await?; + let _ = self + .connection_events_tx + .send(ProxyConnectionEvent::Disconnected { + proxy_id: proxy.id, + proxy_name: proxy.name, + }); } Ok(()) @@ -364,7 +381,6 @@ impl ProxyHandler { // Check proxy version and continue if it's not supported. let (version, info) = get_tracing_variables(&maybe_info); let proxy_is_supported = is_proxy_version_supported(Some(&version)); - self.mark_connected(&version).await?; let span = tracing::info_span!("proxy_bidi", component = %DefguardComponent::Proxy, version = version.to_string(), info); @@ -390,6 +406,7 @@ impl ProxyHandler { } continue; } + self.mark_connected(&version).await?; IncompatibleComponents::remove_proxy(&incompatible_components); info!("Connected to proxy at {}", self.url); @@ -1039,7 +1056,12 @@ impl ProxyHandler { None } Some(core_request::Payload::DevicePostureCheck(request)) => { - match self.services.client_mfa.handle_posture_check(request).await { + match self + .services + .client_mfa + .handle_posture_check(request, received.device_info) + .await + { Ok(PostureCheckOutcome::Approved { preshared_key }) => { Some(core_response::Payload::DevicePostureCheck( DevicePostureCheckResponse { preshared_key }, @@ -1185,7 +1207,6 @@ impl ProxyHandler { let (version, info) = get_tracing_variables(&maybe_info); let proxy_is_supported = is_proxy_version_supported(Some(&version)); - self.mark_connected(&version).await?; let span = tracing::info_span!("proxy_bidi", component = %DefguardComponent::Proxy, version = version.to_string(), info); @@ -1201,6 +1222,7 @@ impl ProxyHandler { self.mark_disconnected().await?; return Ok(()); } + self.mark_connected(&version).await?; IncompatibleComponents::remove_proxy(&incompatible_components); info!("Connected to proxy at {} (test)", self.url); diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index 8d2f6a96ea..dd013627db 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -13,7 +13,9 @@ use defguard_common::{ types::proxy::ProxyControlMessage, }; use defguard_core::{ - events::{ApiEvent, BidiStreamEvent, DirectorySyncEvent, LdapSyncEventType}, + events::{ + ApiEvent, BidiStreamEvent, DirectorySyncEvent, LdapSyncEventType, ProxyConnectionEvent, + }, grpc::proxy::client_mfa::ClientLoginSession, version::IncompatibleComponents, }; @@ -26,7 +28,7 @@ use tokio::{ sync::{ Mutex, broadcast::Sender, - mpsc::{Receiver, UnboundedSender}, + mpsc::{Receiver, UnboundedSender, unbounded_channel}, oneshot, watch, }, task::JoinSet, @@ -428,6 +430,7 @@ pub struct ProxyTxSet { pub(crate) ldap: UnboundedSender, pub(crate) dirsync: UnboundedSender, pub(crate) event_tx: UnboundedSender, + pub(crate) connection_events: UnboundedSender, } impl ProxyTxSet { @@ -439,12 +442,23 @@ impl ProxyTxSet { dirsync: UnboundedSender, event_tx: UnboundedSender, ) -> Self { + let (connection_events, _receiver) = unbounded_channel(); Self { wireguard, bidi_events, ldap, dirsync, event_tx, + connection_events, } } + + #[must_use] + pub fn with_connection_events( + mut self, + connection_events: UnboundedSender, + ) -> Self { + self.connection_events = connection_events; + self + } } diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index 495ff11421..506d5cd1de 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -217,16 +217,12 @@ impl EnrollmentServer { Status::internal(format!("unexpected error: {err}")) })?; let smtp_configured = settings.smtp_configured(); - let instance_info = InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .map_err(|err| { - error!("Failed to create instance info: {err}"); - Status::internal("unexpected error") - })?; + let instance_info = InstanceInfo::build(&self.pool, &settings, &user, openid_provider) + .await + .map_err(|err| { + error!("Failed to create instance info: {err}"); + Status::internal("unexpected error") + })?; debug!("Instance info {instance_info:?}"); debug!( @@ -234,7 +230,7 @@ impl EnrollmentServer { user.username, user.id ); let (username, user_id) = (user.username.clone(), user.id); - let user_info = initial_info_from_user(&self.pool, user) + let user_info = initial_info_from_user(&self.pool, &user) .await .map_err(|err| { error!( @@ -274,13 +270,22 @@ impl EnrollmentServer { admin_device_management: enterprise_settings.admin_device_management, mfa_required: instance_has_internal_mfa, }; + let settings = Settings::get_current_settings(); + let final_page_content = if settings.enrollment_display_welcome_message { + enrollment + .get_welcome_page_content(&mut transaction) + .await? + } else { + debug!( + "Skipping enrollment welcome page content because it is disabled in settings" + ); + String::new() + }; let response = defguard_proto::client_types::EnrollmentStartResponse { admin: admin_info, user: Some(user_info), deadline_timestamp: session_deadline.and_utc().timestamp(), - final_page_content: enrollment - .get_welcome_page_content(&mut transaction) - .await?, + final_page_content, instance: Some(instance_info.into()), settings: Some(enrollment_settings), }; @@ -479,7 +484,9 @@ impl EnrollmentServer { // update user info!("Update user details and set a new password."); - user.phone = request.phone_number; + if request.phone_number.is_some() { + user.phone = request.phone_number; + } if let Some(password) = &request.password { user.set_password(password); } @@ -954,16 +961,12 @@ impl EnrollmentServer { Status::internal(format!("unexpected error: {err}")) })?; - let instance_info = InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .map_err(|err| { - error!("Failed to create instance info: {err}"); - Status::internal("unexpected error") - })?; + let instance_info = InstanceInfo::build(&self.pool, &settings, &user, openid_provider) + .await + .map_err(|err| { + error!("Failed to create instance info: {err}"); + Status::internal("unexpected error") + })?; let response = DeviceConfigResponse { device: Some(device.clone().into()), @@ -1043,15 +1046,14 @@ impl EnrollmentServer { error!("Unable to start email MFA setup; SMTP is not configured"); return Err(Status::internal("SMTP not configured".to_owned())); } - if user.email_mfa_enabled { - return Err(Status::invalid_argument( - "Method already enabled".to_owned(), - )); - } user.new_email_secret(&self.pool).await.map_err(|_| { error!("Failed to create email secret"); Status::internal("Failed to setup email mfa".to_owned()) })?; + user.clear_recovery_codes(&self.pool).await.map_err(|e| { + error!("Failed to clear recovery codes: {e}"); + Status::internal("Failed to clear recovery codes".to_owned()) + })?; info!("Created email secret for {}", &user.username); let mut transaction = self.pool.begin().await.map_err(|err| { error!("Failed to begin database transaction\nReason:{err}"); @@ -1061,24 +1063,30 @@ impl EnrollmentServer { error!("Failed to generate MFA code for {user}\nReason:{err}"); Status::internal("Failed to generate MFA code".to_owned()) })?; - mfa_activation_mail(&user.email, &mut transaction, &user.first_name, &code, None) - .await - .map_err(|err| { - error!("Failed to send MFA activation email\nReason:{err}"); - Status::internal("Failed to send activation email".to_owned()) - })?; + mfa_activation_mail( + &user.email, + &mut transaction, + &user.first_name, + &code, + None, + true, + ) + .await + .map_err(|err| { + error!("Failed to send MFA activation email\nReason:{err}"); + Status::internal("Failed to send activation email".to_owned()) + })?; Ok(CodeMfaSetupStartResponse { totp_secret: None }) } MfaMethod::Totp => { - if user.totp_enabled { - return Err(Status::invalid_argument( - "Method already enabled".to_owned(), - )); - } let secret = user.new_totp_secret(&self.pool).await.map_err(|_| { error!("Failed to make new TOTP secret"); Status::internal("Failed to make new TOTP secret".to_owned()) })?; + user.clear_recovery_codes(&self.pool).await.map_err(|e| { + error!("Failed to clear recovery codes: {e}"); + Status::internal("Failed to clear recovery codes".to_owned()) + })?; info!("New TOTP secret created for {}", &user.username); Ok(CodeMfaSetupStartResponse { totp_secret: Some(secret), @@ -1101,11 +1109,6 @@ impl EnrollmentServer { return Err(Status::invalid_argument("Method not supported")); } let mut user = enrollment.fetch_user(&self.pool).await?; - if user.mfa_enabled { - return Err(Status::invalid_argument( - "Mfa already enabled on the account".to_owned(), - )); - } // available only for unenrolled users if user.is_enrolled() { return Err(Status::permission_denied("User is already enrolled")); @@ -1164,7 +1167,7 @@ impl EnrollmentServer { async fn initial_info_from_user( pool: &PgPool, - user: User, + user: &User, ) -> Result { let enrolled = user.is_enrolled(); let devices = user.user_devices(pool).await?; @@ -1176,11 +1179,11 @@ async fn initial_info_from_user( let password_management_disabled = user.password_management_disabled(is_admin, &settings, oidc_disable_password_management); Ok(InitialUserInfo { - first_name: user.first_name, - last_name: user.last_name, - login: user.username, - email: user.email, - phone_number: user.phone, + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + login: user.username.clone(), + email: user.email.clone(), + phone_number: user.phone.clone(), is_active: user.is_active, device_names, enrolled, @@ -1238,6 +1241,7 @@ mod test { setup_pool, }; use defguard_core::db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token}; + use defguard_proto::{client_types::EnrollmentStartRequest, proxy::DeviceInfo}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::sync::{broadcast, mpsc::unbounded_channel}; @@ -1289,4 +1293,72 @@ mod test { assert!(result.is_ok()); } + + #[sqlx::test] + async fn test_display_welcome_message_if_disabled_returns_empty( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "test_user_disabled_display", + None, + "Test", + "User", + "user-disabled-display@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let token = Token::new( + user.id, + None, + Some(user.email.clone()), + 3600, + Some(ENROLLMENT_TOKEN_TYPE.to_owned()), + ); + token.save(&pool).await.unwrap(); + + Settings::initialize_runtime_defaults(&pool).await.unwrap(); + initialize_current_settings(&pool).await.unwrap(); + + let mut settings = Settings::get_current_settings(); + assert!( + settings + .enrollment_welcome_message + .as_deref() + .is_some_and(|msg| !msg.is_empty()), + "welcome message template must be non-empty for this test to be meaningful" + ); + settings.enrollment_display_welcome_message = false; + update_current_settings(&pool, settings).await.unwrap(); + + let (gateway_tx, _gateway_rx) = broadcast::channel(1); + let (bidi_event_tx, _bidi_events_rx) = unbounded_channel(); + let (ldap_tx, _ldap_rx) = unbounded_channel(); + let server = EnrollmentServer::new(pool.clone(), gateway_tx, bidi_event_tx, ldap_tx); + + let request = EnrollmentStartRequest { + token: token.id.clone(), + }; + let device_info = DeviceInfo { + ip_address: "127.0.0.1".to_owned(), + user_agent: None, + version: None, + platform: None, + }; + let response = server + .start_enrollment(request, Some(device_info)) + .await + .expect("start_enrollment should succeed"); + + assert!( + response.final_page_content.is_empty(), + "final_page_content should be empty when display is disabled, got: {}", + response.final_page_content + ); + } } diff --git a/crates/defguard_proxy_manager/src/servers/password_reset.rs b/crates/defguard_proxy_manager/src/servers/password_reset.rs index 7fa36fff7d..1e0f415745 100644 --- a/crates/defguard_proxy_manager/src/servers/password_reset.rs +++ b/crates/defguard_proxy_manager/src/servers/password_reset.rs @@ -142,8 +142,13 @@ impl PasswordResetServer { return Ok(()); } - // Externally-managed users get a clear feedback email; - // other passwordless users (e.g. half-enrolled) stay silent. + // Handle passwordless users. There are three cases: + // - externally-managed (their IdP disables password management): send a + // clear feedback email explaining they cannot set a local password; + // - linked to an external IdP that still allows local passwords + // (OIDC or LDAP): allowed to set their first local password, so they + // fall through to the normal reset flow below; + // - any other passwordless user (e.g. half-enrolled): stay silent. if !user.has_password() { let is_admin = user.is_admin(&self.pool).await.map_err(|err| { error!("Failed to check if user is admin: {err}"); @@ -180,13 +185,21 @@ impl PasswordResetServer { { error!("Failed to send password reset disabled email: {err}"); } + return Ok(()); + } else if user.openid_sub.is_some() || user.from_ldap { + // IdP-linked user allowed to hold a local password: let them set + // their first one through the normal reset flow below. + debug!( + "Issuing password reset for passwordless IdP-linked user {} ({email})", + user.username + ); } else { debug!( "Password reset skipped for passwordless user {} ({email})", user.username ); + return Ok(()); } - return Ok(()); } let mut transaction = self.pool.begin().await.map_err(|_| { @@ -272,14 +285,15 @@ impl PasswordResetServer { let user = enrollment.fetch_user(&self.pool).await?; - if !user.has_password() || !user.is_active { + // A passwordless user may be setting their first local password (e.g. an + // OIDC/LDAP user), so only reject disabled users here. A token is only + // ever issued to users allowed to reset (see `request_password_reset`). + if !user.is_active { error!( - "Can't start password reset for a disabled or not enrolled user {}.", + "Can't start password reset for a disabled user {}.", user.username ); - return Err(Status::permission_denied( - "user disabled or not yet enrolled", - )); + return Err(Status::permission_denied("user disabled")); } let mut transaction = self.pool.begin().await.map_err(|_| { diff --git a/crates/defguard_proxy_manager/src/tests/common/mod.rs b/crates/defguard_proxy_manager/src/tests/common/mod.rs index 17555f3d46..476b405eed 100644 --- a/crates/defguard_proxy_manager/src/tests/common/mod.rs +++ b/crates/defguard_proxy_manager/src/tests/common/mod.rs @@ -27,7 +27,7 @@ use defguard_common::{ }, gateway_event::GatewayCommand, }; -use defguard_core::events::{ApiEvent, BidiStreamEvent}; +use defguard_core::events::{ApiEvent, BidiStreamEvent, ProxyConnectionEvent}; use defguard_proto::proxy::{ AcmeChallenge, AcmeIssueEvent, CoreRequest, CoreResponse, InitialInfo, core_response, proxy_server, @@ -401,6 +401,7 @@ pub(crate) struct HandlerTestContext { pub(crate) gateway_tx: broadcast::Sender, pub(crate) bidi_events_rx: UnboundedReceiver, pub(crate) event_rx: UnboundedReceiver, + pub(crate) connection_events_rx: UnboundedReceiver, pub(crate) mock_proxy: Option, handler_task: Option>>, /// Keep-alive handle: holds the sender so the handler's shutdown receiver @@ -429,13 +430,15 @@ impl HandlerTestContext { let (ldap_tx, _ldap_rx) = mpsc::unbounded_channel(); let (dirsync_tx, _dirsync_rx) = mpsc::unbounded_channel(); let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (connection_events_tx, connection_events_rx) = mpsc::unbounded_channel(); let tx_set = ProxyTxSet::new( gateway_tx.clone(), bidi_events_tx, ldap_tx, dirsync_tx, event_tx, - ); + ) + .with_connection_events(connection_events_tx); let (_, certs_rx) = watch::channel(Arc::new(HashMap::new())); let incompatible_components = Arc::new(std::sync::RwLock::new( @@ -480,6 +483,7 @@ impl HandlerTestContext { gateway_tx, bidi_events_rx, event_rx, + connection_events_rx, mock_proxy: Some(mock_proxy), handler_task: Some(handler_task), _shutdown_tx: Some(shutdown_tx), @@ -524,6 +528,11 @@ impl HandlerTestContext { mock_proxy } + pub(crate) fn take_connection_events_rx(&mut self) -> UnboundedReceiver { + let (_, receiver) = mpsc::unbounded_channel(); + std::mem::replace(&mut self.connection_events_rx, receiver) + } + pub(crate) async fn finish_after_error(mut self) -> MockProxyHarness { let mock_proxy = assert_some!( self.mock_proxy.take(), diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs index 3192c1fa60..655808b7bc 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs @@ -30,7 +30,8 @@ use super::support::{ create_enrollment_token, create_network, create_polling_token, create_user, create_user_with_device, insert_acl_rule_for_network, make_device_info, send_activate_user, send_activate_user_without_password, send_code_mfa_setup_finish, send_code_mfa_setup_start, - set_test_license_enterprise, start_enrollment_session, totp_code_from_base32_secret, + set_test_license_enterprise, setup_user_totp_mfa, start_enrollment_session, + totp_code_from_base32_secret, }; use crate::tests::common::{HandlerTestContext, TEST_TIMEOUT}; @@ -193,6 +194,45 @@ async fn test_activate_user_happy_path(_: PgPoolOptions, options: PgConnectOptio context.finish().await.expect_server_finished().await; } +/// Activating without a phone number in the request must not clear an +/// existing phone number set on the user before enrollment. +#[sqlx::test] +async fn test_activate_user_without_phone_keeps_existing_phone( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + + let mut user = create_user(&context.pool).await; + user.phone = Some("123123123".to_owned()); + user.save(&context.pool) + .await + .expect("failed to save user phone number"); + + let token = create_enrollment_token(&context.pool, user.id, Some(user.id)).await; + start_enrollment_session(&mut context, &token.id).await; + + let response = send_activate_user(&mut context, &token.id, STRONG_PASSWORD, None).await; + + match &response.payload { + Some(core_response::Payload::Empty(())) => {} + _ => panic!("expected Empty response"), + } + + let updated = User::find_by_username(&context.pool, &user.username) + .await + .expect("db query failed") + .expect("user not found"); + assert_eq!( + updated.phone, + Some("123123123".to_owned()), + "existing phone number must be preserved when activation request omits it" + ); + + context.finish().await.expect_server_finished().await; +} + /// A weak password (too short, missing required character classes) must be /// rejected with `InvalidArgument`. #[sqlx::test] @@ -606,6 +646,74 @@ async fn test_code_mfa_setup_finish_totp_returns_recovery_codes( context.finish().await.expect_server_finished().await; } +#[sqlx::test] +async fn test_code_mfa_setup_finish_totp_recreates_recovery_codes_for_reenrollment( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + + let mut user = create_user(&context.pool).await; + user.set_password("OldPassw0rd!"); + setup_user_totp_mfa(&context.pool, &mut user).await; + user.enable_mfa(&context.pool).await.expect("enable_mfa"); + let old_recovery_codes = user + .get_recovery_codes(&context.pool) + .await + .expect("get_recovery_codes") + .expect("recovery codes should be created"); + user.enrollment_pending = true; + user.save(&context.pool) + .await + .expect("failed to save re-enrolling user"); + + let token = create_enrollment_token(&context.pool, user.id, Some(user.id)).await; + start_enrollment_session(&mut context, &token.id).await; + + let start_resp = send_code_mfa_setup_start(&mut context, &token.id, MfaMethod::Totp).await; + let totp_secret_b32 = match &start_resp.payload { + Some(core_response::Payload::CodeMfaSetupStartResponse(r)) => r + .totp_secret + .clone() + .expect("TOTP start must include a secret"), + other => panic!( + "expected CodeMfaSetupStartResponse, got: {:?}", + other.as_ref().map(std::mem::discriminant) + ), + }; + let code = totp_code_from_base32_secret(&totp_secret_b32); + + let finish_resp = + send_code_mfa_setup_finish(&mut context, &token.id, MfaMethod::Totp, &code).await; + let new_recovery_codes = match &finish_resp.payload { + Some(core_response::Payload::CodeMfaSetupFinishResponse(r)) => r.recovery_codes.clone(), + Some(core_response::Payload::CoreError(e)) => { + panic!( + "expected CodeMfaSetupFinishResponse, got CoreError: {}", + e.message + ) + } + other => panic!( + "expected CodeMfaSetupFinishResponse, got: {:?}", + other.as_ref().map(std::mem::discriminant) + ), + }; + + assert!(!new_recovery_codes.is_empty()); + assert_ne!(old_recovery_codes, new_recovery_codes); + + let updated = User::find_by_username(&context.pool, &user.username) + .await + .expect("db query failed") + .expect("user not found"); + assert!(updated.totp_enabled); + assert!(updated.mfa_enabled); + assert_eq!(updated.recovery_codes, new_recovery_codes); + + context.finish().await.expect_server_finished().await; +} + /// `CodeMfaSetupStart` with `MfaMethod::Email` must return a response with no /// TOTP secret (email flow does not expose a secret to the client). /// diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/lifecycle.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/lifecycle.rs index 1e00661cf1..2148410424 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/lifecycle.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/lifecycle.rs @@ -1,3 +1,4 @@ +use defguard_core::events::ProxyConnectionEvent; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::support::complete_proxy_handshake; @@ -15,6 +16,7 @@ async fn test_proxy_marked_connected_after_handshake(_: PgPoolOptions, options: ); complete_proxy_handshake(&mut context).await; + let mut connection_events_rx = context.take_connection_events_rx(); let proxy_after = context.reload_proxy().await; assert!( @@ -25,6 +27,13 @@ async fn test_proxy_marked_connected_after_handshake(_: PgPoolOptions, options: proxy_after.connected_at.is_some(), "connected_at should be set" ); + assert_eq!( + connection_events_rx.recv().await, + Some(ProxyConnectionEvent::Connected { + proxy_id: context.proxy.id, + proxy_name: context.proxy.name.clone(), + }) + ); context.finish().await.expect_server_finished().await; } @@ -39,6 +48,8 @@ async fn test_proxy_marked_disconnected_when_stream_closes( complete_proxy_handshake(&mut context).await; let proxy_id = context.proxy.id; + let proxy_name = context.proxy.name.clone(); + let mut connection_events_rx = context.take_connection_events_rx(); let pool = context.pool.clone(); let mock_proxy = context.finish().await; @@ -51,6 +62,20 @@ async fn test_proxy_marked_disconnected_when_stream_closes( proxy_after.disconnected_at.is_some(), "disconnected_at should be set after stream close" ); + assert_eq!( + connection_events_rx.recv().await, + Some(ProxyConnectionEvent::Connected { + proxy_id, + proxy_name: proxy_name.clone(), + }) + ); + assert_eq!( + connection_events_rx.recv().await, + Some(ProxyConnectionEvent::Disconnected { + proxy_id, + proxy_name, + }) + ); mock_proxy.expect_server_finished().await; } diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs index 838fe82a8f..98b1fd7e25 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs @@ -1,10 +1,20 @@ -use defguard_common::db::models::{ - User, - settings::{Settings, update_current_settings}, +use defguard_common::{ + db::{ + Id, + models::{ + User, + settings::{Settings, update_current_settings}, + }, + }, + testing::smtp::MockSmtpServer, }; use defguard_core::events::{BidiStreamEventType, PasswordResetEvent}; use defguard_proto::proxy::core_response; -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, + query_scalar, +}; use tokio::time::timeout; use super::support::{ @@ -101,13 +111,13 @@ async fn test_password_reset_completes_successfully(_: PgPoolOptions, options: P // Start the session (consumes the PasswordResetStarted event). let start_response = send_password_reset_start(&mut context, &token.id).await; - assert!( - matches!( - start_response.payload, - Some(core_response::Payload::PasswordResetStart(_)) + match &start_response.payload { + Some(core_response::Payload::PasswordResetStart(_)) => {} + _ => panic!( + "expected PasswordResetStart response, got: {:?}", + start_response.payload.as_ref().map(std::mem::discriminant) ), - "start must succeed" - ); + } let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; // Reset the password. @@ -173,13 +183,13 @@ async fn test_password_reset_weak_password_returns_error( // Start the session. let start_response = send_password_reset_start(&mut context, &token.id).await; - assert!( - matches!( - start_response.payload, - Some(core_response::Payload::PasswordResetStart(_)) + match &start_response.payload { + Some(core_response::Payload::PasswordResetStart(_)) => {} + _ => panic!( + "expected PasswordResetStart response, got: {:?}", + start_response.payload.as_ref().map(std::mem::discriminant) ), - "start must succeed" - ); + } let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; // Submit a weak password. @@ -232,7 +242,12 @@ async fn test_password_reset_init_disabled_user_no_token( let mut context = HandlerTestContext::new(options).await; complete_proxy_handshake(&mut context).await; - // Enable LDAP password management disabling in settings. + // Capture outgoing mail so we can assert the "disabled" notification is sent. + let smtp = MockSmtpServer::start().await; + smtp.configure(&context.pool).await; + + // Enable LDAP password management disabling (preserving the SMTP config just + // written to settings). let mut settings = Settings::get_current_settings(); settings.ldap_disable_password_management = true; update_current_settings(&context.pool, settings) @@ -257,19 +272,20 @@ async fn test_password_reset_init_disabled_user_no_token( ), } - // No PASSWORD_RESET token must have been created. - let count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM token WHERE user_id = $1 AND token_type = 'PASSWORD_RESET'", - ) - .bind(user.id) - .fetch_one(&context.pool) - .await - .expect("failed to query token count"); + // No PASSWORD_RESET token must have been created ... assert_eq!( - count.0, 0, + count_password_reset_tokens(&context.pool, user.id).await, + 0, "no password reset token should be created for disabled user" ); + // ... but the user must receive a "password reset disabled" notification. + let mail = smtp.wait_for(|m| m.sent_to(&user.email)).await; + assert!( + mail.body_contains("Password reset disabled"), + "externally-managed user must receive the password-reset-disabled email" + ); + context.finish().await.expect_server_finished().await; } @@ -298,17 +314,234 @@ async fn test_password_reset_init_passwordless_user_silent_no_token( } // No PASSWORD_RESET token must have been created. - let count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM token WHERE user_id = $1 AND token_type = 'PASSWORD_RESET'", - ) - .bind(user.id) - .fetch_one(&context.pool) - .await - .expect("failed to query token count"); assert_eq!( - count.0, 0, + count_password_reset_tokens(&context.pool, user.id).await, + 0, "no password reset token should be created for passwordless user" ); context.finish().await.expect_server_finished().await; } + +/// Configure a valid public proxy URL so the reset-mail step in +/// `request_password_reset` can build its enrollment link. The mail send itself +/// is fire-and-forget and a no-op without SMTP, so this only needs to parse. +async fn set_public_proxy_url(pool: &PgPool) { + let mut settings = Settings::get_current_settings(); + settings.public_proxy_url = "https://proxy.example.com".to_owned(); + update_current_settings(pool, settings) + .await + .expect("failed to set public_proxy_url"); +} + +/// Fetch the `id` of the single PASSWORD_RESET token for a user, asserting +/// exactly one exists. +async fn fetch_single_password_reset_token_id(pool: &PgPool, user_id: Id) -> String { + let ids: Vec = + query_scalar("SELECT id FROM token WHERE user_id = $1 AND token_type = 'PASSWORD_RESET'") + .bind(user_id) + .fetch_all(pool) + .await + .expect("failed to query password reset tokens"); + assert_eq!( + ids.len(), + 1, + "expected exactly one PASSWORD_RESET token, found {}", + ids.len() + ); + ids.into_iter().next().unwrap() +} + +/// Count the PASSWORD_RESET tokens for a user. +async fn count_password_reset_tokens(pool: &PgPool, user_id: Id) -> i64 { + query_scalar("SELECT COUNT(*) FROM token WHERE user_id = $1 AND token_type = 'PASSWORD_RESET'") + .bind(user_id) + .fetch_one(pool) + .await + .expect("failed to query password reset token count") +} + +/// Regression test for issue #3388: a passwordless user linked to an external +/// OIDC provider whose IdP does NOT disable password management must be able to +/// obtain a password reset. `PasswordResetInit` must create a PASSWORD_RESET +/// token and emit a `PasswordResetRequested` event (the reset email is sent via +/// the fire-and-forget mailer, which is not observable in tests). +/// +/// Before the fix this user falls into the silent branch and no token is +/// created, so this test fails (red). +#[sqlx::test] +async fn test_password_reset_init_oidc_user_creates_token( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + set_public_proxy_url(&context.pool).await; + + // Passwordless user linked to an external OIDC provider. Password management + // is NOT disabled (the default), so they may hold a local password. + let mut user = create_user(&context.pool).await; + user.openid_sub = Some("oidc-sub-123".to_owned()); + user.save(&context.pool) + .await + .expect("failed to save OIDC user"); + + let response = send_password_reset_init(&mut context, &user.email).await; + + match &response.payload { + Some(core_response::Payload::Empty(())) => {} + _ => panic!( + "expected Empty response for OIDC user, got: {:?}", + response.payload.as_ref().map(std::mem::discriminant) + ), + } + + // A PASSWORD_RESET token must have been created so the user can set a password. + assert_eq!( + count_password_reset_tokens(&context.pool, user.id).await, + 1, + "a password reset token must be created for a passwordless OIDC user" + ); + + // A BidiStreamEvent::PasswordReset(PasswordResetRequested) must have been emitted. + let event = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()) + .await + .expect("timed out waiting for BidiStreamEvent") + .expect("bidi_events_rx closed"); + match event.event { + BidiStreamEventType::PasswordReset(e) => match *e { + PasswordResetEvent::PasswordResetRequested => {} + other => panic!("expected PasswordResetRequested event, got: {other:?}"), + }, + other => panic!("expected BidiStreamEventType::PasswordReset, got: {other:?}"), + } + + context.finish().await.expect_server_finished().await; +} + +/// Regression test for issue #3388: a passwordless user synced from LDAP whose +/// IdP does NOT disable password management (the default) must be able to obtain +/// a password reset. `PasswordResetInit` must create a PASSWORD_RESET token. +/// +/// Before the fix this user falls into the silent branch and no token is +/// created, so this test fails (red). +#[sqlx::test] +async fn test_password_reset_init_ldap_user_creates_token( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + set_public_proxy_url(&context.pool).await; + + // Passwordless LDAP-sourced user. `ldap_disable_password_management` is left + // at its default (false), so they may hold a local password. + let mut user = create_user(&context.pool).await; + user.from_ldap = true; + user.save(&context.pool) + .await + .expect("failed to save LDAP user"); + + let response = send_password_reset_init(&mut context, &user.email).await; + + match &response.payload { + Some(core_response::Payload::Empty(())) => {} + _ => panic!( + "expected Empty response for LDAP user, got: {:?}", + response.payload.as_ref().map(std::mem::discriminant) + ), + } + + assert_eq!( + count_password_reset_tokens(&context.pool, user.id).await, + 1, + "a password reset token must be created for a passwordless LDAP user" + ); + + context.finish().await.expect_server_finished().await; +} + +/// Regression test for issue #3388, full flow: a passwordless OIDC user must be +/// able to complete a password reset end to end - init creates a token, start +/// succeeds, and reset sets their first local password. +/// +/// Before the fix, `start_password_reset` rejects the user with +/// `PermissionDenied` because of its `!user.has_password()` guard, so this test +/// fails (red) at the start step. +#[sqlx::test] +async fn test_password_reset_completes_for_oidc_user(_: PgPoolOptions, options: PgConnectOptions) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + set_public_proxy_url(&context.pool).await; + + let mut user = create_user(&context.pool).await; + user.openid_sub = Some("oidc-sub-456".to_owned()); + user.save(&context.pool) + .await + .expect("failed to save OIDC user"); + assert!( + !user.has_password(), + "precondition: OIDC user starts without a local password" + ); + + // Init: must create a reset token and emit PasswordResetRequested. + let init_response = send_password_reset_init(&mut context, &user.email).await; + match &init_response.payload { + Some(core_response::Payload::Empty(())) => {} + _ => panic!( + "expected Empty response on init, got: {:?}", + init_response.payload.as_ref().map(std::mem::discriminant) + ), + } + let token_id = fetch_single_password_reset_token_id(&context.pool, user.id).await; + // Drain the PasswordResetRequested event. + let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; + + // Start: must succeed even though the user has no password yet. + let start_response = send_password_reset_start(&mut context, &token_id).await; + match &start_response.payload { + Some(core_response::Payload::PasswordResetStart(_)) => {} + _ => panic!( + "start must succeed for a passwordless OIDC user, got: {:?}", + start_response.payload.as_ref().map(std::mem::discriminant) + ), + } + // Drain the PasswordResetStarted event. + let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; + + // Reset: sets the user's first local password. + const NEW_PASSWORD: &str = "NewPass2!"; + let reset_response = send_password_reset(&mut context, &token_id, NEW_PASSWORD).await; + match &reset_response.payload { + Some(core_response::Payload::Empty(())) => {} + _ => panic!( + "expected Empty on successful password reset, got: {:?}", + reset_response.payload.as_ref().map(std::mem::discriminant) + ), + } + + // The user must now have a local password set in the DB. + let updated = User::find_by_username(&context.pool, &user.username) + .await + .expect("db query failed") + .expect("user not found"); + assert!( + updated.has_password(), + "OIDC user must have a local password after completing the reset" + ); + + // A PasswordResetCompleted event must have been emitted. + let event = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()) + .await + .expect("timed out waiting for BidiStreamEvent") + .expect("bidi_events_rx closed"); + match event.event { + BidiStreamEventType::PasswordReset(e) => match *e { + PasswordResetEvent::PasswordResetCompleted => {} + other => panic!("expected PasswordResetCompleted event, got: {other:?}"), + }, + other => panic!("expected BidiStreamEventType::PasswordReset, got: {other:?}"), + } + + context.finish().await.expect_server_finished().await; +} diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs index ca9bc9cd4b..8de15dd370 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs @@ -1,9 +1,20 @@ -use defguard_core::device_access::join_device_to_all_networks; +use defguard_common::db::models::group::Group; +use defguard_core::{ + device_access::join_device_to_all_networks, + enterprise::db::models::{ + enterprise_settings::ClientTrafficPolicy, + group_client_traffic_policy::GroupClientTrafficPolicy, + }, + grpc::utils::build_device_config_response, +}; use defguard_proto::{ - client_types::InstanceInfoRequest, + client_types, proxy::{CoreRequest, core_request, core_response}, }; -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; use super::support::{ assert_error_response, clear_test_license, complete_proxy_handshake, create_device_for_user, @@ -12,6 +23,121 @@ use super::support::{ }; use crate::tests::common::HandlerTestContext; +async fn poll_client_traffic_policy(context: &mut HandlerTestContext, token: &str) -> i32 { + context.mock_proxy().send_request(CoreRequest { + id: 20, + device_info: None, + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token.to_owned(), + }, + )), + }); + + let response = context.mock_proxy_mut().recv_outbound().await; + match response.payload { + Some(core_response::Payload::InstanceInfo(info)) => info + .device_config + .and_then(|config| config.instance) + .and_then(|instance| instance.client_traffic_policy) + .expect("InstanceInfo should contain a client traffic policy"), + other => panic!( + "expected InstanceInfo response, got: {:?}", + other.as_ref().map(std::mem::discriminant) + ), + } +} + +async fn set_global_client_traffic_policy(pool: &PgPool, policy: &str) { + sqlx::query( + "UPDATE \"enterprisesettings\" SET client_traffic_policy = $1::client_traffic_policy WHERE id = 1", + ) + .bind(policy) + .execute(pool) + .await + .expect("failed to update global client traffic policy"); +} + +#[sqlx::test] +async fn test_client_traffic_policy_is_resolved_for_instance_info( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + set_test_license_business(); + + let _network = create_network(&context.pool).await; + let (user, device) = create_user_with_device(&context.pool).await; + let token = create_polling_token(&context.pool, device.id).await; + let force_group = Group::new("force-policy") + .save(&context.pool) + .await + .unwrap(); + let disable_group = Group::new("disable-policy") + .save(&context.pool) + .await + .unwrap(); + + set_global_client_traffic_policy(&context.pool, "disable_all_traffic").await; + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::DisableAllTraffic as i32 + ); + + user.add_to_group(&context.pool, &force_group) + .await + .unwrap(); + GroupClientTrafficPolicy::upsert( + &context.pool, + force_group.id, + ClientTrafficPolicy::ForceAllTraffic, + ) + .await + .unwrap(); + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::ForceAllTraffic as i32 + ); + + GroupClientTrafficPolicy::upsert(&context.pool, force_group.id, ClientTrafficPolicy::None) + .await + .unwrap(); + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::None as i32 + ); + + user.add_to_group(&context.pool, &disable_group) + .await + .unwrap(); + GroupClientTrafficPolicy::upsert( + &context.pool, + disable_group.id, + ClientTrafficPolicy::DisableAllTraffic, + ) + .await + .unwrap(); + assert_eq!( + poll_client_traffic_policy(&mut context, &token).await, + client_types::ClientTrafficPolicy::DisableAllTraffic as i32 + ); + + clear_test_license(); + let response = build_device_config_response(&context.pool, device, None, None) + .await + .expect("failed to build device config without a license"); + assert_eq!( + response + .instance + .expect("device config should contain instance info") + .client_traffic_policy, + Some(client_types::ClientTrafficPolicy::None as i32) + ); + + context.finish().await.expect_server_finished().await; +} + #[sqlx::test] async fn test_polling_returns_updated_device_config(_: PgPoolOptions, options: PgConnectOptions) { let mut context = HandlerTestContext::new(options).await; @@ -27,9 +153,11 @@ async fn test_polling_returns_updated_device_config(_: PgPoolOptions, options: P context.mock_proxy().send_request(CoreRequest { id: 10, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str.clone(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token_str.clone(), + }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -64,9 +192,9 @@ async fn test_polling_requires_business_license(_: PgPoolOptions, options: PgCon context.mock_proxy().send_request(CoreRequest { id: 11, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str, - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { token: token_str }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -90,9 +218,11 @@ async fn test_polling_invalid_token_returns_error(_: PgPoolOptions, options: PgC context.mock_proxy().send_request(CoreRequest { id: 12, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: "this-token-does-not-exist-00000000".to_owned(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: "this-token-does-not-exist-00000000".to_owned(), + }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -127,9 +257,9 @@ async fn test_polling_inactive_user_returns_error(_: PgPoolOptions, options: PgC context.mock_proxy().send_request(CoreRequest { id: 13, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str, - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { token: token_str }, + )), }); let response = context.mock_proxy_mut().recv_outbound().await; @@ -159,9 +289,11 @@ async fn test_polling_reflects_network_changes(_: PgPoolOptions, options: PgConn context.mock_proxy().send_request(CoreRequest { id: 14, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str.clone(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token_str.clone(), + }, + )), }); let first_response = context.mock_proxy_mut().recv_outbound().await; let first_info = match &first_response.payload { @@ -182,9 +314,11 @@ async fn test_polling_reflects_network_changes(_: PgPoolOptions, options: PgConn context.mock_proxy().send_request(CoreRequest { id: 15, device_info: None, - payload: Some(core_request::Payload::InstanceInfo(InstanceInfoRequest { - token: token_str.clone(), - })), + payload: Some(core_request::Payload::InstanceInfo( + client_types::InstanceInfoRequest { + token: token_str.clone(), + }, + )), }); let second_response = context.mock_proxy_mut().recv_outbound().await; let second_info = match &second_response.payload { 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 e4b6961b23..fbfc93d5dd 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 @@ -12,11 +12,13 @@ use defguard_common::{ Device, DeviceType, User, WireguardNetwork, polling_token::PollingToken, settings::{Settings, update_current_settings}, + user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, vpn_client_session::VpnClientSession, wireguard::{LocationMfaMode, ServiceLocationMode}, }, }, secret::SecretStringWrapper, + testing::smtp::configure_working_smtp, }; use defguard_core::{ db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, PASSWORD_RESET_TOKEN_TYPE, Token}, @@ -46,13 +48,9 @@ use defguard_proto::{ }; use ipnetwork::IpNetwork; use sqlx::PgPool; -use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - net::TcpListener, - sync::mpsc::UnboundedReceiver, - time::timeout, -}; +use tokio::{sync::mpsc::UnboundedReceiver, time::timeout}; use tonic::Code; +use totp_lite::{Sha1, totp_custom}; use crate::tests::common::{HandlerTestContext, MockOidcProvider, RECEIVE_TIMEOUT}; @@ -512,8 +510,6 @@ pub(crate) async fn setup_user_totp_mfa(pool: &PgPool, user: &mut User) { /// Mirrors the logic in `User::verify_totp_code`. Call this immediately before /// `send_mfa_finish` so the code is within the current 30-second window. pub(crate) fn generate_totp_code(user: &User) -> String { - use defguard_common::db::models::user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}; - use totp_lite::{Sha1, totp_custom}; let secret = user .totp_secret .as_ref() @@ -528,8 +524,6 @@ pub(crate) fn generate_totp_code(user: &User) -> String { /// Generate a TOTP code from a **base32-encoded** secret string (as returned /// by `CodeMfaSetupStartResponse.totp_secret`). pub(crate) fn totp_code_from_base32_secret(base32_secret: &str) -> String { - use defguard_common::db::models::user::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}; - use totp_lite::{Sha1, totp_custom}; let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, base32_secret) .expect("invalid base32 TOTP secret from CodeMfaSetupStartResponse"); let ts = SystemTime::now() @@ -945,83 +939,6 @@ pub(crate) fn configure_smtp(settings: &mut Settings) { settings.smtp.sender = Some("noreply@example.com".into()); } -/// Spawn a minimal in-process SMTP server that accepts any message and replies -/// with success codes, then point `pool`'s current settings at it. -/// -/// MFA emails (`mfa_code_mail`/`mfa_activation_mail`) are actually sent -/// (awaited) rather than fired-and-forgotten, so tests exercising the email -/// MFA method need a real, reachable SMTP endpoint or the send fails with -/// `SmtpNotConfigured`/`MailSendFailed`. -pub(crate) async fn configure_working_smtp(pool: &PgPool) { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("failed to bind fake SMTP listener"); - let addr = listener.local_addr().expect("failed to get local addr"); - - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - return; - }; - tokio::spawn(async move { - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - let _ = writer.write_all(b"220 localhost ESMTP\r\n").await; - let mut line = String::new(); - loop { - line.clear(); - match reader.read_line(&mut line).await { - Ok(0) => return, - Ok(_) => {} - Err(_) => return, - } - let upper = line.trim_end().to_ascii_uppercase(); - if upper.starts_with("EHLO") || upper.starts_with("HELO") { - let _ = writer.write_all(b"250 localhost\r\n").await; - } else if upper.starts_with("MAIL FROM") - || upper.starts_with("RCPT TO") - || upper.starts_with("RSET") - { - let _ = writer.write_all(b"250 OK\r\n").await; - } else if upper.starts_with("DATA") { - let _ = writer - .write_all(b"354 End data with .\r\n") - .await; - loop { - line.clear(); - match reader.read_line(&mut line).await { - Ok(0) => return, - Ok(_) => {} - Err(_) => return, - } - if line == ".\r\n" || line == ".\n" { - break; - } - } - let _ = writer.write_all(b"250 OK message queued\r\n").await; - } else if upper.starts_with("QUIT") { - let _ = writer.write_all(b"221 Bye\r\n").await; - return; - } else { - let _ = writer.write_all(b"250 OK\r\n").await; - } - } - }); - } - }); - - let mut settings = Settings::get_current_settings(); - settings.smtp.server = Some(addr.ip().to_string()); - settings.smtp.port = Some(i32::from(addr.port())); - settings.smtp.sender = Some("noreply@example.com".into()); - settings.smtp.encryption = defguard_common::db::models::settings::smtp::SmtpEncryption::None; - settings.smtp.authentication = - defguard_common::db::models::settings::smtp::SmtpAuthentication::None; - update_current_settings(pool, settings) - .await - .expect("failed to persist fake SMTP settings"); -} - /// Set minimal LDAP fields on a [`Settings`] so that `ldap_configured()` returns `true`. pub(crate) fn configure_ldap(settings: &mut Settings) { settings.ldap_url = Some("ldap://localhost".into()); diff --git a/flake.lock b/flake.lock index cdddb4ff2d..90c4cf4257 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1783224372, - "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "lastModified": 1785090369, + "narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "rev": "624af665418d3c65d544145b4d34ad696439570e", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1783404876, - "narHash": "sha256-DAh1CfiRVwr8Szkj5PiHmsqh10NHPb8SKPx7w/B+l9E=", + "lastModified": 1785302874, + "narHash": "sha256-fpKEww3TJoo1ANHO2q918ei+ayOrp0YEQAO1DuBLOB4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "3c161f20193bd91a73913b417e5b06d41860336d", + "rev": "b99d48435bc3e34309d2c7ae6f7d45e77a156c38", "type": "github" }, "original": { diff --git a/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql new file mode 100644 index 0000000000..f5219e6850 --- /dev/null +++ b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.down.sql @@ -0,0 +1 @@ +DROP TABLE group_client_traffic_policy; diff --git a/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql new file mode 100644 index 0000000000..9a648e6b70 --- /dev/null +++ b/migrations/20260723000000_[2.1.0]_group_client_traffic_policy.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE group_client_traffic_policy ( + group_id bigint PRIMARY KEY REFERENCES "group"(id) ON DELETE CASCADE, + client_traffic_policy client_traffic_policy NOT NULL +); diff --git a/migrations/20260724000000_[2.1.0]_disable_tunnels.down.sql b/migrations/20260724000000_[2.1.0]_disable_tunnels.down.sql new file mode 100644 index 0000000000..3e9456839a --- /dev/null +++ b/migrations/20260724000000_[2.1.0]_disable_tunnels.down.sql @@ -0,0 +1 @@ +ALTER TABLE enterprisesettings DROP COLUMN disable_tunnels; diff --git a/migrations/20260724000000_[2.1.0]_disable_tunnels.up.sql b/migrations/20260724000000_[2.1.0]_disable_tunnels.up.sql new file mode 100644 index 0000000000..50ea77c1ef --- /dev/null +++ b/migrations/20260724000000_[2.1.0]_disable_tunnels.up.sql @@ -0,0 +1 @@ +ALTER TABLE enterprisesettings ADD COLUMN disable_tunnels BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/20260729000000_[2.1.0]_enrollment_display_welcome_message.down.sql b/migrations/20260729000000_[2.1.0]_enrollment_display_welcome_message.down.sql new file mode 100644 index 0000000000..8cff5a793d --- /dev/null +++ b/migrations/20260729000000_[2.1.0]_enrollment_display_welcome_message.down.sql @@ -0,0 +1 @@ +ALTER TABLE settings DROP COLUMN enrollment_display_welcome_message; diff --git a/migrations/20260729000000_[2.1.0]_enrollment_display_welcome_message.up.sql b/migrations/20260729000000_[2.1.0]_enrollment_display_welcome_message.up.sql new file mode 100644 index 0000000000..fe3f38f9eb --- /dev/null +++ b/migrations/20260729000000_[2.1.0]_enrollment_display_welcome_message.up.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN enrollment_display_welcome_message BOOLEAN NOT NULL DEFAULT true; diff --git a/migrations/20260729120000_[2.0.3]_fix_smtp_auth_backfill.down.sql b/migrations/20260729120000_[2.0.3]_fix_smtp_auth_backfill.down.sql new file mode 100644 index 0000000000..bbd9fe599b --- /dev/null +++ b/migrations/20260729120000_[2.0.3]_fix_smtp_auth_backfill.down.sql @@ -0,0 +1,3 @@ +-- No-op: this migration repairs data and cannot be safely reversed. Rows that +-- were originally unauthenticated are indistinguishable from repaired ones, so +-- there is nothing to restore. diff --git a/migrations/20260729120000_[2.0.3]_fix_smtp_auth_backfill.up.sql b/migrations/20260729120000_[2.0.3]_fix_smtp_auth_backfill.up.sql new file mode 100644 index 0000000000..d6339128d3 --- /dev/null +++ b/migrations/20260729120000_[2.0.3]_fix_smtp_auth_backfill.up.sql @@ -0,0 +1,11 @@ +-- Repair rows mis-classified by the [2.0.2] smtp_xoauth2 back-fill. +-- That migration set smtp_authentication = 'login' whenever smtp_user and +-- smtp_password were merely NOT NULL, but empty-string credentials (valid and +-- common under 2.0.1, where SMTP authentication was optional) then fail the +-- stricter SmtpSettings::is_configured() check. That in turn makes settings +-- validation reject "gateway disconnect notifications" at startup and aborts +-- the core process. Fall back to 'none' for those rows, restoring 2.0.1 behavior. +UPDATE settings SET smtp_authentication = 'none' + WHERE smtp_authentication = 'login' + AND (smtp_user IS NULL OR smtp_user = '' + OR smtp_password IS NULL OR smtp_password = ''); diff --git a/migrations/20260731091804_[2.1.0]_user_directory_identity.down.sql b/migrations/20260731091804_[2.1.0]_user_directory_identity.down.sql new file mode 100644 index 0000000000..e3b8e12f31 --- /dev/null +++ b/migrations/20260731091804_[2.1.0]_user_directory_identity.down.sql @@ -0,0 +1 @@ +DROP TABLE user_directory_identity; diff --git a/migrations/20260731091804_[2.1.0]_user_directory_identity.up.sql b/migrations/20260731091804_[2.1.0]_user_directory_identity.up.sql new file mode 100644 index 0000000000..82787e16c2 --- /dev/null +++ b/migrations/20260731091804_[2.1.0]_user_directory_identity.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE user_directory_identity ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + provider_id bigint NOT NULL REFERENCES openidprovider(id) ON DELETE CASCADE, + external_id text NOT NULL, + created timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_directory_identity_provider_external_id_unique UNIQUE (provider_id, external_id), + CONSTRAINT user_directory_identity_user_provider_unique UNIQUE (user_id, provider_id) +); diff --git a/proto b/proto index 053e83dc45..7e1c6a5ed1 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 053e83dc45f0effe19e4f7faad6ccba2c458c241 +Subproject commit 7e1c6a5ed1336522bff0610edf1e216f7dcde444 diff --git a/web/messages/en/activity.json b/web/messages/en/activity.json index 64314b0a3e..b4c87c55dd 100644 --- a/web/messages/en/activity.json +++ b/web/messages/en/activity.json @@ -84,6 +84,10 @@ "activity_event_proxy_deleted": "Edge component deleted", "activity_event_gateway_modified": "Gateway modified", "activity_event_gateway_deleted": "Gateway deleted", + "activity_event_gateway_connected": "Gateway connected", + "activity_event_gateway_disconnected": "Gateway disconnected", + "activity_event_proxy_connected": "Proxy connected", + "activity_event_proxy_disconnected": "Proxy disconnected", "activity_event_device_posture_created": "Posture check created", "activity_event_device_posture_updated": "Posture check updated", "activity_event_device_posture_deleted": "Posture check deleted", @@ -109,6 +113,7 @@ "activity_event_ldap_sync_outbound_group_member_removed": "Defguard removed LDAP group member", "activity_event_oidc_directory_sync_user_created": "Directory sync created user", "activity_event_oidc_directory_sync_user_deleted": "Directory sync deleted user", + "activity_event_oidc_directory_sync_user_modified": "Directory sync modified user", "activity_event_oidc_directory_sync_user_enabled": "Directory sync enabled user", "activity_event_oidc_directory_sync_user_disabled": "Directory sync disabled user", "activity_event_oidc_directory_sync_group_created": "Directory sync created group", @@ -125,5 +130,8 @@ "activity_log_col_module": "Module", "activity_log_col_description": "Description", "activity_log_missing_ip": "No IP recorded", - "activity_log_missing_location": "No location recorded" + "activity_log_missing_location": "No location recorded", + "activity_log_date_range_placeholder": "Select range", + "activity_log_date_range_start": "Start", + "activity_log_date_range_end": "End" } diff --git a/web/messages/en/form.json b/web/messages/en/form.json index d5ffc350f2..eee1b19f8c 100644 --- a/web/messages/en/form.json +++ b/web/messages/en/form.json @@ -6,6 +6,7 @@ "form_error_file_contents": "File content is not valid", "form_error_ip_or_domain": "Only valid IP or domain is allowed", "form_error_port_max": "Port exceeds maximum value", + "form_error_keepalive_min": "Keep alive interval must be at least 1 second", "form_error_len": "Required length {length}", "form_error_name_reserved": "Name already taken", "form_error_email": "Enter valid email", diff --git a/web/messages/en/gateway.json b/web/messages/en/gateway.json index 199b6bd063..804324c94a 100644 --- a/web/messages/en/gateway.json +++ b/web/messages/en/gateway.json @@ -15,7 +15,7 @@ "gateway_edit_failed": "Failed to update gateway", "gateway_status_all_connected": "All gateways connected", "gateway_status_connected_count": "{count} gateways connected", - "gateway_status_none_connected": "No gateways connected", + "gateway_status_none_connected": "None connected", "gateway_status_connected": "Connected", "gateway_status_not_connected": "Not connected", "gateway_status_disconnected": "Disconnected", diff --git a/web/messages/en/groups.json b/web/messages/en/groups.json index b89bf6757d..4c638fa95e 100644 --- a/web/messages/en/groups.json +++ b/web/messages/en/groups.json @@ -7,6 +7,10 @@ "groups_col_name": "Group name", "groups_col_users_count": "Added users", "groups_col_type": "Type", + "groups_col_traffic_policy": "Traffic policy", + "groups_traffic_policy_none": "No limitations", + "groups_traffic_policy_disable_all": "Disable all traffic", + "groups_traffic_policy_force_all": "Force all traffic", "groups_col_locations": "Used in locations", "groups_type_admin": "Admin", "groups_type_user": "User" diff --git a/web/messages/en/location.json b/web/messages/en/location.json index 5f988a0526..08a0de2288 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -98,10 +98,8 @@ "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_posture_service_location_warning": "Postures can't be enabled for service locations. To enforce posture checks, 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.", - "location_service_mode_postures_warning": "Locations with assigned posture checks can't be set as service locations. Remove posture checks to use a service location type.", "location_access_section_label": "Location Access", "location_access_selected_group_count_one": "+{count} group", "location_access_selected_group_count_other": "+{count} groups", diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index 1a2620dcd0..d72348cb33 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -1,5 +1,11 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "modal_license_reactivated_eyebrow": "Welcome back to your plan", + "modal_license_reactivated_title": "Your license has been reactivated", + "modal_license_reactivated_lead": "All paid features are available again.", + "modal_license_reactivated_body": "All Business/Enterprise features have been restored, and your previous configuration has been automatically recovered. Everything has been returned to the way it was before your license expired.", + "modal_license_reactivated_note": "You can review your configuration if you wish, but no further action is required.", + "modal_license_reactivated_submit": "Got it", "modal_change_password_title": "Change password", "modal_change_password_submit": "Change password", "modal_delete_authorized_app_title": "Delete authorized app", @@ -215,9 +221,15 @@ "modal_assign_posture_check_locations_error": "Failed to assign locations to the posture check", "modal_delete_posture_check_title": "Delete posture check", "modal_delete_posture_check_content_empty": "Are you sure you want to delete this check?", - "modal_delete_posture_check_content": "Are you sure you want to delete this check? It’s currently used in {locations}. Removing it may change access criteria for users in these locations.", + "modal_delete_posture_check_content": "Are you sure you want to delete this check? It’s currently used in {locations}. Removing it may change access criteria for users in these locations and disconnect active VPN client sessions there.", "modal_delete_posture_check_success": "Posture check deleted", "modal_delete_posture_check_error": "Failed to delete posture check", + "modal_posture_assignment_warning_title": "Confirm posture check changes", + "modal_posture_assignment_warning_body_location": "These changes may disconnect active VPN client sessions for this location.\n\n{changes}", + "modal_posture_assignment_warning_body_postures": "These changes may disconnect active VPN client sessions for the affected locations.\n\n{changes}", + "modal_posture_assignment_warning_added": "Added:", + "modal_posture_assignment_warning_removed": "Removed:", + "modal_posture_rules_warning_body": "These rules are enforced the next time each device connects. Devices connected now are unaffected until they reconnect.", "modal_assign_user_device_ip_title": "Device IP settings", "modal_assign_user_device_ip_card_title": "{deviceName} IP settings", "modal_assign_user_device_ip_assignment_description": "You can change the IP address for this device separately in each location/network one-by-one.", diff --git a/web/messages/en/openid.json b/web/messages/en/openid.json index 6af1014ad7..b5605ed9ca 100644 --- a/web/messages/en/openid.json +++ b/web/messages/en/openid.json @@ -52,6 +52,7 @@ "settings_openid_provider_disable_password_management_content": "When enabled, users sourced from this provider without a local password cannot change or reset their password through Defguard.", "settings_openid_provider_label_sync_users_from_groups": "Synchronize users only from specified groups", "settings_openid_provider_helper_sync_users_from_groups": "Only people who belong to these groups will have account created in Defguard. Leave it empty to allow all users. If you also set \"Sync only matching memberships\" above, list the same groups there too, otherwise their members won't be recognized. Separate group names with commas.", + "settings_openid_provider_helper_sync_users_from_groups_google": "Only people who belong to these groups will have account created in Defguard. Leave it empty to allow all users. Separate group names with commas.", "settings_openid_provider_label_okta_directory_sync_client_id": "Directory sync client ID", "settings_openid_provider_helper_okta_directory_sync_client_id": "", "settings_openid_provider_label_okta_directory_sync_client_private_key": "Directory sync client private key", diff --git a/web/messages/en/postures.json b/web/messages/en/postures.json index 7f143002af..248d02cd7c 100644 --- a/web/messages/en/postures.json +++ b/web/messages/en/postures.json @@ -57,8 +57,6 @@ "posture_checks_edit_defguard": "Defguard", "posture_checks_edit_locations": "Assigned locations", "posture_checks_edit_defguard_note": "\u201cDefguard versions\u201d includes major releases as well as all subsequent patch updates with fixes and improvements.", - "posture_checks_edit_delete_title": "Delete posture check", - "posture_checks_edit_delete_body": "Are you sure you want to delete posture check **{name}**? This action cannot be undone.", "posture_checks_edit_save_success": "Posture check saved", "posture_checks_edit_save_failed": "Failed to save posture check", "posture_checks_edit_delete_success": "Posture check deleted", diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 87705d1c8f..cfb79baefe 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -130,6 +130,8 @@ "settings_enrollment_template_help_markdown_bold": "Bold text.", "settings_enrollment_template_help_markdown_lists": "Unordered list.", "settings_enrollment_template_help_markdown_link": "Link.", + "settings_enrollment_template_help_markdown_tables": "You can also create tables using", + "settings_enrollment_template_help_markdown_tables_link": "GFM table syntax", "settings_enrollment_section_duration_title": "Enrollment session duration", "settings_enrollment_section_duration_description": "Configure the expiration time of the unique token sent to a newly added user. This token is used to activate the account, and in this section administrators can control how long the token remains valid.", "settings_enrollment_section_edge_ui_title": "Edge UI Settings", @@ -355,15 +357,25 @@ "settings_client_device_management_content": "When this option is on, only Admins can manage devices in user profiles.", "settings_client_wireguard_configuration_title": "WireGuard configuration for users", "settings_client_wireguard_configuration_content": "When this option is on, users can't view or download manual WireGuard configurations. Only Defguard desktop client setup will be available.", + "settings_client_disable_tunnels_title": "Disable WireGuard tunnels in client", + "settings_client_disable_tunnels_content": "When this option is on, users can't access or create manual WireGuard tunnels in the client. Existing tunnels are hidden across all configured instances.", "settings_client_section_traffic_policy_title": "Client traffic policy", "settings_client_traffic_policy_description_title": "Client traffic rules", "settings_client_traffic_policy_description": "Specify the conditions that determine how traffic should behave in the application.", - "settings_client_traffic_policy_none_title": "None", + "settings_client_traffic_policy_none_title": "No limitation", "settings_client_traffic_policy_none_content": "When this option is enabled, users will be able to select all routing options.", "settings_client_traffic_policy_disable_all_title": "Disable all traffic", "settings_client_traffic_policy_disable_all_content": "When this option is enabled, users will not be able to route all traffic through the VPN.", "settings_client_traffic_policy_force_all_title": "Force all traffic", "settings_client_traffic_policy_force_all_content": "When this option is enabled, the users will always route all traffic through the VPN.", + "settings_client_traffic_policy_group_title": "Group-based policies", + "settings_client_traffic_policy_group_description": "Define the groups that should use their own traffic rules instead of the global traffic policy. Any groups not included below will use the global traffic policy.", + "settings_client_traffic_policy_group_none_content": "When this option is enabled, users in groups will be able to select all routing options.", + "settings_client_traffic_policy_group_disable_all_title": "Disable all traffic", + "settings_client_traffic_policy_group_disable_all_content": "When this option is enabled, users in groups will not be able to route all traffic through the VPN.", + "settings_client_traffic_policy_group_force_all_title": "Force all traffic", + "settings_client_traffic_policy_group_force_all_content": "When this option is enabled, the users in groups will always route all traffic through the VPN.", + "settings_client_traffic_policy_edit_groups": "Edit groups", "settings_gateway_notifications_title": "Gateway notifications", "settings_gateway_notifications_subtitle": "Here you can manage email notifications.", "settings_notifications_gateway_card_content": "Configure admin email notifications for gateway disconnect and reconnect events, and set the inactivity threshold that triggers disconnect notifications.", diff --git a/web/src/app/day.ts b/web/src/app/day.ts index 0c864b2e77..0ac5b4d306 100644 --- a/web/src/app/day.ts +++ b/web/src/app/day.ts @@ -1,6 +1,11 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import 'dayjs/locale/en'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import isBetween from 'dayjs/plugin/isBetween'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import isToday from 'dayjs/plugin/isToday'; +import localeData from 'dayjs/plugin/localeData'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import utc from 'dayjs/plugin/utc'; import { getLocale } from '../paraglide/runtime'; @@ -8,4 +13,9 @@ import { getLocale } from '../paraglide/runtime'; dayjs.extend(relativeTime); dayjs.extend(utc); dayjs.extend(localizedFormat); +dayjs.extend(localeData); +dayjs.extend(isoWeek); +dayjs.extend(isToday); +dayjs.extend(isBetween); +dayjs.extend(customParseFormat); dayjs.locale(getLocale()); diff --git a/web/src/pages/ActivityLogPage/ActivityLogPage.tsx b/web/src/pages/ActivityLogPage/ActivityLogPage.tsx index c7ee826630..fbc10b9ac6 100644 --- a/web/src/pages/ActivityLogPage/ActivityLogPage.tsx +++ b/web/src/pages/ActivityLogPage/ActivityLogPage.tsx @@ -9,6 +9,7 @@ import type { import api from '../../shared/api/api'; import type { ActivityLogSortKey } from '../../shared/api/types'; import { Page } from '../../shared/components/Page/Page'; +import type { DateRange } from '../../shared/defguard-ui/components/DateInput/types'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; @@ -32,6 +33,7 @@ const mapColumnFiltersToApiParams = ( export const ActivityLogPage = () => { const [search, setSearch] = useState(''); + const [dateRange, setDateRange] = useState(null); const [sortingState, setSortingState] = useState([ { id: 'timestamp', desc: true }, ]); @@ -60,12 +62,14 @@ export const ActivityLogPage = () => { const activeSorting = sortingState[0]; const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ - queryKey: ['activity-log', { search, sortingState, columnFilters }], + queryKey: ['activity-log', { search, dateRange, sortingState, columnFilters }], initialPageParam: 1, queryFn: ({ pageParam }) => api.getActivityLog({ page: pageParam, search: search.length > 0 ? search : undefined, + from: dateRange?.start.toISOString(), + until: dateRange?.end.toISOString(), sort_by: activeSorting?.id as ActivityLogSortKey, sort_order: activeSorting ? (activeSorting.desc ? 'desc' : 'asc') : undefined, event: eventFilter, @@ -110,6 +114,8 @@ export const ActivityLogPage = () => { columnFilters={columnFilters} onColumnFiltersChange={setColumnFilters} locationFilterOptions={locationFilterOptions} + dateRange={dateRange} + onDateRangeChange={setDateRange} /> )} diff --git a/web/src/pages/ActivityLogPage/ActivityLogTable.tsx b/web/src/pages/ActivityLogPage/ActivityLogTable.tsx index 60a18b2ddb..075b0ea930 100644 --- a/web/src/pages/ActivityLogPage/ActivityLogTable.tsx +++ b/web/src/pages/ActivityLogPage/ActivityLogTable.tsx @@ -17,6 +17,8 @@ import { } from '../../shared/api/activity-log-types'; import type { ActivityLogEvent } from '../../shared/api/types'; import type { SelectionOption } from '../../shared/components/SelectionSection/type'; +import { DateInput } from '../../shared/defguard-ui/components/DateInput/DateInput'; +import type { DateRange } from '../../shared/defguard-ui/components/DateInput/types'; import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { Search } from '../../shared/defguard-ui/components/Search/Search'; import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody'; @@ -72,6 +74,8 @@ interface Props { columnFilters: ColumnFiltersState; onColumnFiltersChange: OnChangeFn; locationFilterOptions: SelectionOption[]; + dateRange: DateRange | null; + onDateRangeChange: (value: DateRange | null) => void; } export const ActivityLogTable = ({ @@ -86,6 +90,8 @@ export const ActivityLogTable = ({ columnFilters, onColumnFiltersChange, locationFilterOptions, + dateRange, + onDateRangeChange, }: Props) => { const tableFilterMessages = useMemo( () => ({ @@ -259,6 +265,19 @@ export const ActivityLogTable = ({ initialValue={search} onChange={onSearchChange} /> + { storeValues.google_service_account_key, storeValues.google_service_account_email, ), + directory_sync_user_groups: joinCsv(storeValues.directory_sync_user_groups), }), [storeValues], ); @@ -62,6 +64,7 @@ export const GoogleProviderForm = ({ onSubmit }: ProviderFormProps) => { ...value, google_service_account_email: fileData?.client_email ?? '', google_service_account_key: fileData?.private_key ?? '', + directory_sync_user_groups: value.directory_sync_user_groups ?? '', }); } else { formApi.setErrorMap({ @@ -155,6 +158,15 @@ export const GoogleProviderForm = ({ onSubmit }: ProviderFormProps) => { + + {(field) => ( + + )} + + {(field) => ( { directory_sync_target: providerState.directory_sync_target, directory_sync_user_behavior: providerState.directory_sync_user_behavior, jumpcloud_api_key: providerState.jumpcloud_api_key ?? '', + directory_sync_user_groups: joinCsv(providerState.directory_sync_user_groups), }), [providerState], ); @@ -48,7 +50,10 @@ export const JumpcloudProviderForm = ({ onSubmit }: ProviderFormProps) => { onChange: jumpcloudProviderSyncSchema, }, onSubmit: async ({ value }) => { - await onSubmit(value); + await onSubmit({ + ...value, + directory_sync_user_groups: value.directory_sync_user_groups ?? '', + }); }, }); @@ -120,14 +125,31 @@ export const JumpcloudProviderForm = ({ onSubmit }: ProviderFormProps) => { /> )} + + + {(field) => ( + + )} + { - back(form.state.values); + back({ + ...form.state.values, + directory_sync_user_groups: + form.state.values.directory_sync_user_groups ?? '', + }); }} onNext={() => { - mutate(form.state.values); + mutate({ + ...form.state.values, + directory_sync_user_groups: + form.state.values.directory_sync_user_groups ?? '', + }); }} /> diff --git a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/OktaProviderForm.tsx b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/OktaProviderForm.tsx index 41de4d06d7..e64a8a6fca 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/OktaProviderForm.tsx +++ b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/OktaProviderForm.tsx @@ -8,6 +8,7 @@ import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/ import { ThemeSpacing } from '../../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../../shared/form'; import { formChangeLogic } from '../../../../../shared/formLogic'; +import { joinCsv } from '../../../../../shared/utils/csv'; import { directorySyncBehaviorOptions, directorySyncTargetOptions, @@ -36,6 +37,7 @@ export const OktaProviderForm = ({ onSubmit }: ProviderFormProps) => { directory_sync_user_behavior: providerState.directory_sync_user_behavior, okta_dirsync_client_id: providerState.okta_dirsync_client_id ?? '', okta_private_jwk: providerState.okta_private_jwk ?? '', + directory_sync_user_groups: joinCsv(providerState.directory_sync_user_groups), }), [providerState], ); @@ -48,7 +50,10 @@ export const OktaProviderForm = ({ onSubmit }: ProviderFormProps) => { onChange: oktaProviderSyncSchema, }, onSubmit: async ({ value }) => { - await onSubmit(value); + await onSubmit({ + ...value, + directory_sync_user_groups: value.directory_sync_user_groups ?? '', + }); }, }); @@ -131,14 +136,31 @@ export const OktaProviderForm = ({ onSubmit }: ProviderFormProps) => { )} + + + {(field) => ( + + )} + { - back(form.state.values); + back({ + ...form.state.values, + directory_sync_user_groups: + form.state.values.directory_sync_user_groups ?? '', + }); }} onNext={() => { - mutate(form.state.values); + mutate({ + ...form.state.values, + directory_sync_user_groups: + form.state.values.directory_sync_user_groups ?? '', + }); }} /> diff --git a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts index 970c42895d..43a348cb50 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts +++ b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts @@ -30,6 +30,7 @@ export const googleProviderSyncSchema = baseExternalProviderSyncSchema.extend({ .file(m.form_error_required()) .mime('application/json', m.form_error_file_format()) .nullable(), + directory_sync_user_groups: z.string().trim().nullable(), }); export const microsoftProviderSyncSchema = baseExternalProviderSyncSchema.extend({ @@ -47,6 +48,7 @@ export const oktaProviderSyncSchema = baseExternalProviderSyncSchema.extend({ .string(m.form_error_required()) .trim() .min(1, m.form_error_required()), + directory_sync_user_groups: z.string().trim().nullable(), }); export const jumpcloudProviderSyncSchema = baseExternalProviderSyncSchema.extend({ @@ -54,6 +56,7 @@ export const jumpcloudProviderSyncSchema = baseExternalProviderSyncSchema.extend .string(m.form_error_required()) .trim() .min(1, m.form_error_required()), + directory_sync_user_groups: z.string().trim().nullable(), }); const fileSchema = z.object({ diff --git a/web/src/pages/AddLocationPage/AddLocationPage.tsx b/web/src/pages/AddLocationPage/AddLocationPage.tsx index d253f4b1ee..5ef5dd36d0 100644 --- a/web/src/pages/AddLocationPage/AddLocationPage.tsx +++ b/web/src/pages/AddLocationPage/AddLocationPage.tsx @@ -91,7 +91,6 @@ export const AddLocationPage = () => { id: AddLocationPageStep.PostureCheck, order: 6, label: m.add_location_step_posture_check_label(), - hidden: locationType === 'service', description: m.add_location_step_posture_check_description(), }, firewall: { diff --git a/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx index be5807ebb9..28c138bc6a 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationAccessStep.tsx @@ -95,10 +95,7 @@ export const AddLocationAccessStep = () => { } saveChanges(selected, allowAllGroups); useAddLocationStore.setState({ - activeStep: - locationType === 'service' - ? AddLocationPageStep.Firewall - : AddLocationPageStep.PostureCheck, + activeStep: AddLocationPageStep.PostureCheck, }); }} /> diff --git a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx index a164e4bba7..41fc65c022 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx @@ -26,7 +26,6 @@ import { useAddLocationStore } from '../useAddLocationStore'; type Choice = 'disable' | 'enabled-allowed' | 'enabled-denied'; export const AddLocationFirewallStep = () => { - const locationType = useAddLocationStore((s) => s.locationType); const [state, setState] = useState('disable'); const [showGateway, setShowGateway] = useState(true); const navigate = useNavigate(); @@ -150,10 +149,7 @@ export const AddLocationFirewallStep = () => { onClick={() => { saveChanges(state); useAddLocationStore.setState({ - activeStep: - locationType === 'service' - ? AddLocationPageStep.AccessControl - : AddLocationPageStep.PostureCheck, + activeStep: AddLocationPageStep.PostureCheck, }); }} /> diff --git a/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx index a551041a45..a7824d1675 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationNetworkStep.tsx @@ -14,6 +14,8 @@ import { useAddLocationStore } from '../useAddLocationStore'; const formSchema = z.object({ keepalive_interval: z .number(m.form_error_required()) + // Keepalive is mandatory to prevent idle service locations from disconnecting + .min(1, m.form_error_keepalive_min()) .max(65535, m.form_error_port_max()), mtu: z.number(m.form_error_required()).min(72).max(0xffffffff), fwmark: z.number(m.form_error_required()).min(0).max(0xffffffff), diff --git a/web/src/pages/EditLocationPage/EditLocationPage.tsx b/web/src/pages/EditLocationPage/EditLocationPage.tsx index 556bec6133..32ba718f95 100644 --- a/web/src/pages/EditLocationPage/EditLocationPage.tsx +++ b/web/src/pages/EditLocationPage/EditLocationPage.tsx @@ -43,6 +43,7 @@ import { canUseEnterpriseFeature, } from '../../shared/utils/license'; import { smallestNetworkCapacity } from '../../shared/utils/network'; +import { confirmLocationPostureChange } from '../../shared/utils/postureWarning'; import { Validate } from '../../shared/validate'; import postureCheckShield from './assets/posture_check_shield.png'; import { getPostureChecksSectionState } from './postureChecksSection'; @@ -154,6 +155,8 @@ const formSchema = z peer_disconnect_threshold: z.number().nullable(), keepalive_interval: z .number(m.form_error_required()) + // Keepalive is mandatory to prevent idle service locations from disconnecting + .min(1, m.form_error_keepalive_min()) .max(65535, m.form_error_port_max()), mtu: z.number(m.form_error_required()).min(72).max(0xffffffff), fwmark: z.number(m.form_error_required()).min(0).max(0xffffffff), @@ -371,11 +374,11 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { const postureChecksSectionState = useMemo( () => getPostureChecksSectionState({ - assignedPostureChecksCount: location.posture_checks.length, + assignedPostureChecksCount: location.posture_checks?.length ?? 0, canUseEnterprise: canUseDevicePosture, postureChecksCount: postureChecks.length, }), - [canUseDevicePosture, location.posture_checks.length, postureChecks.length], + [canUseDevicePosture, location.posture_checks?.length, postureChecks.length], ); const firewallLocked = isPresent(canUseBusiness) && !canUseBusiness; @@ -391,17 +394,6 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { [postureChecks], ); - const assignedPostureChecks = useMemo(() => { - const labelsById = new Map( - postureChecks.map((postureCheck) => [postureCheck.id, postureCheck.name]), - ); - - return location.posture_checks.map((id) => ({ - id, - label: labelsById.get(id) ?? String(id), - })); - }, [location.posture_checks, postureChecks]); - const serviceLocationLabelContent = useMemo(() => { if (!serviceLocationLocked) return undefined; return ( @@ -472,7 +464,7 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { }, }); - const { mutate: setLocationPostures, isPending: isUpdatingLocationPostures } = + const { mutateAsync: setLocationPosturesAsync, isPending: isUpdatingLocationPostures } = useMutation({ mutationFn: (data: { postures: number[] }) => api.devicePosture.setLocationPostures(location.id, data), @@ -484,6 +476,16 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { }, }); + const handlePostureSelection = (values: (string | number)[]) => { + const next = values.filter((value): value is number => typeof value === 'number'); + confirmLocationPostureChange({ + current: location.posture_checks ?? [], + next, + options: postureCheckOptions, + actionPromise: () => setLocationPosturesAsync({ postures: next }), + }); + }; + const openPostureChecksSelection = () => { useSelectionModal.setState({ isOpen: true, @@ -499,11 +501,7 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { searchPlaceholder: m.controls_search(), selected: new Set(location.posture_checks), visibleItemsLimit: 4, - onSubmit: (values) => { - setLocationPostures({ - postures: values.filter((value): value is number => typeof value === 'number'), - }); - }, + onSubmit: handlePostureSelection, }); }; @@ -812,13 +810,6 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { text={m.location_service_mode_mfa_warning()} /> )} - {postureChecksSectionState.hasAssignedPostureChecks && ( - - )} { @@ -945,98 +924,63 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { )} - - s.values.service_location_mode !== LocationServiceMode.Disabled - } + - {(isServiceLocation) => ( - <> - {isServiceLocation && ( - - )} - - {postureChecksSectionState.showEmptyState && ( -
- -

- {m.location_posture_checks_empty_state_before_link()}{' '} - - {m.cmp_nav_item_posture_checks()} - {' '} - {m.location_posture_checks_empty_state_after_link()} -

-
- )} - {postureChecksSectionState.showAssignedPostureChecks && ( -
- postureCheck.id), - ) - } - modalTitle={m.location_posture_checks_select()} - editText={m.location_posture_checks_edit()} - editIcon={IconKind.Edit} - toggleValue={false} - counterText={() => ''} - disabled={isServiceLocation} - onSelectionChange={(values) => { - setLocationPostures({ - postures: values.filter( - (value): value is number => typeof value === 'number', - ), - }); - }} - onToggleChange={() => {}} - selectionCustomItemRender={renderPostureCheckSelectionItem} - selectionModalProps={{ - contentClassName: 'posture-check-assignment-modal', - enableDividers: true, - itemGap: 12, - searchPlaceholder: m.controls_search(), - visibleItemsLimit: 6, - }} - /> -
- )} - {postureChecksSectionState.showAssignButton && ( - + )} + + +); + const Content = () => { const { data: licenseInfo } = useSuspenseQuery(getLicenseInfoQueryOptions); const { data: settings } = useSuspenseQuery(getEnterpriseSettingsQueryOptions); + const { data: groups } = useSuspenseQuery(getGroupsInfoQueryOptions); const noLicense = !isPresent(licenseInfo); + const canUseTrafficPolicies = canUseBusinessFeature(licenseInfo).result; + const groupClientTrafficPolicies = canUseTrafficPolicies + ? (settings.group_client_traffic_policies ?? emptyGroupClientTrafficPolicies) + : emptyGroupClientTrafficPolicies; const { mutateAsync: patchSettings } = useMutation({ mutationFn: api.settings.patchEnterpriseSettings, @@ -97,14 +194,26 @@ const Content = () => { return { admin_device_management: settings.admin_device_management, only_client_activation: settings.only_client_activation, - client_traffic_policy: settings.client_traffic_policy, + disable_tunnels: settings.disable_tunnels, + client_traffic_policy: canUseTrafficPolicies + ? settings.client_traffic_policy + : ClientTrafficPolicy.None, + group_client_traffic_policies: groupClientTrafficPolicies, }; }, [ settings.admin_device_management, settings.client_traffic_policy, settings.only_client_activation, + settings.disable_tunnels, + canUseTrafficPolicies, + groupClientTrafficPolicies, ]); + const groupOptions = groups.map>((group) => ({ + id: group.id, + label: group.name, + })); + const form = useAppForm({ defaultValues, validationLogic: formChangeLogic, @@ -113,17 +222,13 @@ const Content = () => { onChange: formSchema, }, onSubmit: async ({ value }) => { - if (!licenseInfo) return; - // only expire error is possible here - const { result } = canUseBusinessFeature(licenseInfo); - if (result) { - await patchSettings(value); - form.reset(value); - } else { - openModal(ModalName.LicenseExpired, { - licenseTier: licenseInfo?.tier, - }); + const licenseCheck = canUseBusinessFeature(licenseInfo); + if (!licenseCheck.result) { + licenseActionCheck(licenseCheck, () => {}); + return; } + await patchSettings(value); + form.reset(value); }, }); @@ -162,6 +267,16 @@ const Content = () => { /> )} + + {(field) => ( + + )} + @@ -174,7 +289,7 @@ const Content = () => { {(field) => ( { {(field) => ( { {(field) => ( { )} + + +

{m.settings_client_traffic_policy_group_title()}

+

+ {m.settings_client_traffic_policy_group_description()} +

+ state.values.group_client_traffic_policies} + > + {(policies) => ( + <> + + form.setFieldValue('group_client_traffic_policies', { + ...policies, + none, + }) + } + options={getAvailableGroupOptions(groupOptions, 'none', policies)} + selected={policies.none} + onEditUnavailable={() => + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => {}) + } + /> + + form.setFieldValue('group_client_traffic_policies', { + ...policies, + disable_all_traffic, + }) + } + options={getAvailableGroupOptions( + groupOptions, + 'disable_all_traffic', + policies, + )} + selected={policies.disable_all_traffic} + onEditUnavailable={() => + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => {}) + } + /> + + form.setFieldValue('group_client_traffic_policies', { + ...policies, + force_all_traffic, + }) + } + options={getAvailableGroupOptions( + groupOptions, + 'force_all_traffic', + policies, + )} + selected={policies.force_all_traffic} + onEditUnavailable={() => + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => {}) + } + /> + + )} + +
({ isDefault: s.isDefaultValue || s.isPristine, diff --git a/web/src/pages/settings/SettingsClientPage/style.scss b/web/src/pages/settings/SettingsClientPage/style.scss index df76f2d176..3135ec7058 100644 --- a/web/src/pages/settings/SettingsClientPage/style.scss +++ b/web/src/pages/settings/SettingsClientPage/style.scss @@ -7,5 +7,45 @@ h3 { font: var(--t-body-primary-600); } + + .group-policy-description { + color: var(--fg-muted); + font: var(--t-body-sm-400); + } + + .group-policy-row { + display: flex; + align-items: flex-start; + column-gap: var(--spacing-md); + } + + .group-policy-row-content { + display: flex; + flex: 1; + flex-flow: column; + row-gap: var(--spacing-sm); + } + + .group-policy-title { + font: var(--t-body-primary-500); + } + + .group-policy-content { + color: var(--fg-neutral); + font: var(--t-body-sm-400); + } + + .select-multiple-edit { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: 0; + border: 0; + background-color: transparent; + color: var(--fg-action); + font: var(--t-body-sm-500); + cursor: pointer; + } } } diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditGoogleProviderForm.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditGoogleProviderForm.tsx index 14e0854f09..c07f74a577 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditGoogleProviderForm.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditGoogleProviderForm.tsx @@ -11,6 +11,7 @@ import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snac import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { joinCsv, toCsvArray } from '../../../../shared/utils/csv'; import { directorySyncBehaviorOptions, directorySyncTargetOptions, @@ -92,6 +93,9 @@ export const EditGoogleProviderForm = ({ directory_sync_user_behavior: provider.directory_sync_user_behavior, directory_sync_enabled: provider.directory_sync_enabled, google_service_account_file: keyFile, + directory_sync_user_groups: joinCsv( + toCsvArray(provider.directory_sync_user_groups), + ), }; }, [provider]); @@ -114,6 +118,7 @@ export const EditGoogleProviderForm = ({ ...omit(inner, ['google_service_account_file']), google_service_account_email: file.client_email, google_service_account_key: file.private_key, + directory_sync_user_groups: inner.directory_sync_user_groups ?? '', }); } else { await onSubmit(omit(value, ['google_service_account_file'])); @@ -245,6 +250,15 @@ export const EditGoogleProviderForm = ({ )} + + {(field) => ( + + )} + + {(field) => ( { await onSubmit({ ...value, + directory_sync_user_groups: value.directory_sync_user_groups ?? '', base_url: jumpcloudBaseUrls[value.jumpcloud_region], }); }, diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx index 0d9c872db6..254c289f24 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx @@ -9,7 +9,7 @@ import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/Siz import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; -import { joinCsv } from '../../../../shared/utils/csv'; +import { joinCsv, toCsvArray } from '../../../../shared/utils/csv'; import { directorySyncBehaviorOptions, directorySyncTargetOptions, @@ -82,18 +82,10 @@ export const EditMicrosoftProviderForm = ({ directory_sync_enabled: provider.directory_sync_enabled, prefetch_users: provider.prefetch_users ?? false, directory_sync_group_match: joinCsv( - Array.isArray(provider.directory_sync_group_match) - ? provider.directory_sync_group_match - : provider.directory_sync_group_match - ? [provider.directory_sync_group_match] - : null, + toCsvArray(provider.directory_sync_group_match), ), directory_sync_user_groups: joinCsv( - Array.isArray(provider.directory_sync_user_groups) - ? provider.directory_sync_user_groups - : provider.directory_sync_user_groups - ? [provider.directory_sync_user_groups] - : null, + toCsvArray(provider.directory_sync_user_groups), ), microsoftTenantId: tenantId, }; diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx index a1cc6a8aba..56d808b12b 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx @@ -9,6 +9,7 @@ import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/Siz import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { joinCsv, toCsvArray } from '../../../../shared/utils/csv'; import { directorySyncBehaviorOptions, directorySyncTargetOptions, @@ -23,6 +24,7 @@ import type { EditProviderFormProps } from '../types'; const basicSchema = z .object({ directory_sync_enabled: z.boolean(), + directory_sync_user_groups: z.string().trim().nullable(), }) .extend(baseExternalProviderConfigSchema.shape); @@ -85,6 +87,9 @@ export const EditOktaProviderForm = ({ directory_sync_target: provider.directory_sync_target, directory_sync_user_behavior: provider.directory_sync_user_behavior, directory_sync_enabled: provider.directory_sync_enabled, + directory_sync_user_groups: joinCsv( + toCsvArray(provider.directory_sync_user_groups), + ), }; }, [provider]); @@ -103,11 +108,18 @@ export const EditOktaProviderForm = ({ onChange: validationSchema, }, onSubmit: async ({ value }) => { - if ('okta_private_jwk' in value && value.okta_private_jwk.trim().length === 0) { - await onSubmit(omit(value, ['okta_private_jwk'])); + const normalized = { + ...value, + directory_sync_user_groups: value.directory_sync_user_groups ?? '', + }; + if ( + 'okta_private_jwk' in normalized && + normalized.okta_private_jwk.trim().length === 0 + ) { + await onSubmit(omit(normalized, ['okta_private_jwk'])); return; } - await onSubmit(value); + await onSubmit(normalized); }, }); @@ -270,6 +282,15 @@ export const EditOktaProviderForm = ({ /> )} + + + {(field) => ( + + )} + )} diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx index 2aef6c2f1c..81b0005bba 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx @@ -11,6 +11,7 @@ import { SectionSelect } from '../../../../shared/defguard-ui/components/Section import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { getLicenseInfoQueryOptions } from '../../../../shared/query'; +import { canUseBusinessFeature } from '../../../../shared/utils/license'; export const SettingsGeneralTab = () => { const navigate = useNavigate(); @@ -34,7 +35,11 @@ export const SettingsGeneralTab = () => { image="behavior" title={m.settings_breadcrumb_client_behavior()} content={m.settings_general_section_client_behavior_content()} - badgeProps={licenseInfo === null ? businessBadgeProps : undefined} + badgeProps={ + licenseInfo !== undefined && !canUseBusinessFeature(licenseInfo).result + ? businessBadgeProps + : undefined + } onClick={() => { navigate({ to: '/settings/client' }); }} diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/SettingsLicenseModal/SettingsLicenseModal.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/SettingsLicenseModal/SettingsLicenseModal.tsx index 830740b3af..dc4b0fa1fb 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/SettingsLicenseModal/SettingsLicenseModal.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsLicenseTab/modals/SettingsLicenseModal/SettingsLicenseModal.tsx @@ -5,9 +5,10 @@ import { useEffect, useMemo, useState } from 'react'; import z from 'zod'; import { m } from '../../../../../../../paraglide/messages'; import api from '../../../../../../../shared/api/api'; -import type { - ApiError, - LicenseCheckResponse, +import { + type ApiError, + ApiResponseCode, + type LicenseCheckResponse, } from '../../../../../../../shared/api/types'; import { CopyButton } from '../../../../../../../shared/components/CopyButton/CopyButton'; import { Modal } from '../../../../../../../shared/defguard-ui/components/Modal/Modal'; @@ -121,8 +122,11 @@ const ModalContent = ({ license: initialLicense }: ModalData) => { const { mutateAsync: patchSettings } = useMutation({ mutationFn: api.settings.patchSettings, - onSuccess: () => { + onSuccess: (response) => { closeModal(modalNameValue); + if (response.data?.code === ApiResponseCode.LicenseReactivated) { + openModal(ModalName.LicenseReactivated); + } }, meta: { invalidate: [['settings'], ['enterprise_info']], diff --git a/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx index 408f2b0f5f..19042ac730 100644 --- a/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx +++ b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx @@ -74,12 +74,17 @@ export const DryRunTable = ({ data }: { data: LdapDryRunUser[] }) => { const filtered = useMemo(() => { const query = search.trim().toLowerCase(); - if (!query) return data; - return data.filter( - (user) => - user.username.toLowerCase().includes(query) || - user.email.toLowerCase().includes(query), - ); + const filteredData = query + ? data.filter( + (user) => + user.username.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query), + ) + : data; + return [...filteredData].sort((a, b) => { + if (a.action === b.action) return 0; + return a.action === 'remove' ? -1 : 1; + }); }, [data, search]); const table = useReactTable({ diff --git a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx index 1a78ea8be7..9b004a685b 100644 --- a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx +++ b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx @@ -109,7 +109,7 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { variant: 'primary', testId: 'add-device', iconLeft: 'add-device', - disabled: !info.network_present || !canModifyDevices, + disabled: !info.network_present || !canModifyDevices || !user.is_active, onClick: () => { useAddUserDeviceModal.getState().open({ devices, diff --git a/web/src/routes/_authorized.tsx b/web/src/routes/_authorized.tsx index c512a26c30..63356a06db 100644 --- a/web/src/routes/_authorized.tsx +++ b/web/src/routes/_authorized.tsx @@ -4,6 +4,7 @@ import { AppUpdateModal } from '../shared/components/modals/AppUpdateModal/AppUp import { ConfirmActionModal } from '../shared/components/modals/ConfirmActionModal/ConfirmActionModal'; import { LicenseExpiredModal } from '../shared/components/modals/license/LicenseExpiredModal/LicenseExpiredModal'; import { LicenseLimitConflictModal } from '../shared/components/modals/license/LicenseLimitConflictModal/LicenseLimitConflictModal'; +import { LicenseReactivatedModal } from '../shared/components/modals/license/LicenseReactivatedModal/LicenseReactivatedModal'; import { LimitReachedModal } from '../shared/components/modals/license/LimitReachedModal/LimitReachedModal'; import { UpgradeBusinessModal } from '../shared/components/modals/license/UpgradeBusinessModal/UpgradeBusinessModal'; import { UpgradeEnterpriseModal } from '../shared/components/modals/license/UpgradeEnterpriseModal/UpgradeEnterpriseModal'; @@ -62,6 +63,7 @@ function RouteComponent() { + diff --git a/web/src/routes/auth/callback.tsx b/web/src/routes/auth/callback.tsx index e0defaf434..19ab090bcf 100644 --- a/web/src/routes/auth/callback.tsx +++ b/web/src/routes/auth/callback.tsx @@ -3,23 +3,46 @@ import type { AxiosError } from 'axios'; import z from 'zod'; import { LoginLoadingPage } from '../../pages/auth/LoginLoading/LoginLoadingPage'; import api from '../../shared/api/api'; -import { getApiErrorMessage } from '../../shared/api/apiErrorMessages'; import { type ApiError, WebErrorCode } from '../../shared/api/types'; -import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { useAuth } from '../../shared/hooks/useAuth'; -const searchSchema = z.object({ - code: z.string(), - state: z.string(), -}); +const getAuthErrorFromException = (e: unknown): WebErrorCode | undefined => { + const code = (e as AxiosError).response?.data?.code; + if ( + code === WebErrorCode.UserGroupsNotSynced || + code === WebErrorCode.LicenseLimitReached + ) { + return code; + } + return undefined; +}; + +const searchSchema = z.union([ + z.object({ + code: z.string(), + state: z.string(), + }), + z.object({ + error: z.string(), + error_description: z.string().optional(), + state: z.string().optional(), + }), +]); // This is used when someone wants to login through a provider export const Route = createFileRoute('/auth/callback')({ validateSearch: searchSchema, loaderDeps: ({ search }) => ({ search }), loader: async ({ deps, context }) => { + const search = deps.search; + if ('error' in search) { + throw redirect({ + to: '/auth/login', + replace: true, + search: { authError: search.error_description ?? search.error }, + }); + } try { - const search = deps.search; const response = await api.openid.callback(search); setTimeout(() => { void context.queryClient.invalidateQueries({ @@ -31,16 +54,11 @@ export const Route = createFileRoute('/auth/callback')({ useAuth.getState().authSubject.next(response.data); }, 1000); } catch (e) { - const code = (e as AxiosError).response?.data?.code; - if ( - code === WebErrorCode.UserGroupsNotSynced || - code === WebErrorCode.LicenseLimitReached - ) { - setTimeout(() => { - Snackbar.error(getApiErrorMessage(code)); - }, 1000); - } - throw redirect({ to: '/auth/login', replace: true }); + throw redirect({ + to: '/auth/login', + replace: true, + search: { authError: getAuthErrorFromException(e) }, + }); } }, component: LoginLoadingPage, diff --git a/web/src/routes/auth/login.tsx b/web/src/routes/auth/login.tsx index 54d103c9d7..bcb6c14184 100644 --- a/web/src/routes/auth/login.tsx +++ b/web/src/routes/auth/login.tsx @@ -1,6 +1,12 @@ import { createFileRoute } from '@tanstack/react-router'; +import z from 'zod'; import { LoginMainPage } from '../../pages/auth/LoginMain/LoginMainPage'; +const searchSchema = z.object({ + authError: z.string().optional(), +}); + export const Route = createFileRoute('/auth/login')({ + validateSearch: searchSchema, component: LoginMainPage, }); diff --git a/web/src/shared/api/activity-log-types.ts b/web/src/shared/api/activity-log-types.ts index 3d38eed8c2..e1ed9f5835 100644 --- a/web/src/shared/api/activity-log-types.ts +++ b/web/src/shared/api/activity-log-types.ts @@ -119,6 +119,10 @@ export const ActivityLogEventType = { GatewayModified: 'gateway_modified', GatewayDeleted: 'gateway_deleted', + GatewayConnected: 'gateway_connected', + GatewayDisconnected: 'gateway_disconnected', + ProxyConnected: 'proxy_connected', + ProxyDisconnected: 'proxy_disconnected', DevicePostureCreated: 'device_posture_created', DevicePostureUpdated: 'device_posture_updated', @@ -147,6 +151,7 @@ export const ActivityLogEventType = { OidcDirectorySyncUserCreated: 'oidc_directory_sync_user_created', OidcDirectorySyncUserDeleted: 'oidc_directory_sync_user_deleted', + OidcDirectorySyncUserModified: 'oidc_directory_sync_user_modified', OidcDirectorySyncUserEnabled: 'oidc_directory_sync_user_enabled', OidcDirectorySyncUserDisabled: 'oidc_directory_sync_user_disabled', OidcDirectorySyncGroupCreated: 'oidc_directory_sync_group_created', diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index e49d03c530..87ce6c5faf 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -310,6 +310,7 @@ export interface User { authorized_apps?: OAuth2AuthorizedApps[]; devices: Device[]; has_non_mfa_location_access: boolean; + has_non_posture_location_access: boolean; } export interface LoginRequest { @@ -426,6 +427,12 @@ export interface ApiError { code?: WebErrorCode; } +export const ApiResponseCode = { + LicenseReactivated: 'license_reactivated', +} as const; + +export type ApiResponseCode = (typeof ApiResponseCode)[keyof typeof ApiResponseCode]; + export interface AppInfoExceededLimits { user: boolean; wireguard_network: boolean; @@ -809,7 +816,7 @@ export interface NetworkLocation { location_mfa_mode: LocationMfaModeValue; service_location_mode: LocationServiceModeValue; has_devices: boolean; - posture_checks: number[]; + posture_checks?: number[]; } export interface EditNetworkLocation @@ -946,12 +953,20 @@ export const ClientTrafficPolicy = { export type ClientTrafficPolicyValue = (typeof ClientTrafficPolicy)[keyof typeof ClientTrafficPolicy]; +export interface GroupClientTrafficPolicies { + none: number[]; + disable_all_traffic: number[]; + force_all_traffic: number[]; +} + export interface SettingsEnterprise { admin_device_management: boolean; client_traffic_policy: ClientTrafficPolicyValue; only_client_activation: boolean; display_download_step: boolean; display_password_reset: boolean; + disable_tunnels: boolean; + group_client_traffic_policies: GroupClientTrafficPolicies; } export type ApiDevicePostureOsRule = @@ -1151,6 +1166,7 @@ export interface SettingsEnrollment { enrollment_welcome_email_subject: string; enrollment_use_welcome_message_as_email: boolean; enrollment_send_welcome_email: boolean; + enrollment_display_welcome_message: boolean; } export interface SettingsModules { diff --git a/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx b/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx index be4b1bf86f..2db966bd29 100644 --- a/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx +++ b/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx @@ -10,13 +10,14 @@ import { useFloating, useInteractions, } from '@floating-ui/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import clsx from 'clsx'; import { type HTMLProps, useMemo, useState } from 'react'; import { useGatewayWizardStore } from '../../../pages/GatewaySetupPage/useGatewayWizardStore'; import { m } from '../../../paraglide/messages'; import api from '../../api/api'; -import type { GatewayInfo } from '../../api/types'; +import { type GatewayInfo, LicenseFeature } from '../../api/types'; import { Badge } from '../../defguard-ui/components/Badge/Badge'; import type { BadgeVariantValue } from '../../defguard-ui/components/Badge/types'; import { Button } from '../../defguard-ui/components/Button/Button'; @@ -29,6 +30,8 @@ import { Snackbar } from '../../defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../defguard-ui/types'; import { openModal } from '../../hooks/modalControls/modalsSubjects'; import { ModalName } from '../../hooks/modalControls/modalTypes'; +import { getLicenseInfoQueryOptions } from '../../query'; +import { canUseEnterpriseFeature, licenseActionCheck } from '../../utils/license'; import './style.scss'; type Status = 'all' | 'none' | 'some'; @@ -134,6 +137,7 @@ export const GatewaysStatusBadge = ({ data, showDetails = false }: Props) => { setOpen(false)} style={{ ...floatingStyles }} {...getFloatingProps()} /> @@ -145,13 +149,15 @@ export const GatewaysStatusBadge = ({ data, showDetails = false }: Props) => { const FloatingMenu = ({ status, + onClose, className, ...rest -}: { status: GatewayInfo[] } & HTMLProps) => { +}: { status: GatewayInfo[]; onClose: () => void } & HTMLProps) => { const locationId = status[0].location_id as number; const connected = useMemo(() => status.filter((gw) => gw.connected), [status]); const disconnected = useMemo(() => status.filter((gw) => !gw.connected), [status]); const navigate = useNavigate(); + const { data: license } = useSuspenseQuery(getLicenseInfoQueryOptions); return (
@@ -230,8 +236,14 @@ const FloatingMenu = ({ variant="outlined" text={m.gateway_add_more()} onClick={() => { - useGatewayWizardStore.getState().start({ network_id: locationId }); - navigate({ to: '/setup-gateway', replace: true }); + onClose(); + licenseActionCheck( + canUseEnterpriseFeature(license, LicenseFeature.ComponentHa), + () => { + useGatewayWizardStore.getState().start({ network_id: locationId }); + navigate({ to: '/setup-gateway', replace: true }); + }, + ); }} />
diff --git a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx index 53f1e091ab..6dafb7a7c9 100644 --- a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx +++ b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx @@ -35,7 +35,10 @@ export const AddDeviceModalStartStep = () => { if (!user) return null; - const showManualSetup = user.has_non_mfa_location_access && !hideManualConfiguration; + const showManualSetup = + user.has_non_mfa_location_access && + user.has_non_posture_location_access && + !hideManualConfiguration; return (
diff --git a/web/src/shared/components/modals/license/LicenseModalSideImage/LicenseModalSideImage.tsx b/web/src/shared/components/modals/license/LicenseModalSideImage/LicenseModalSideImage.tsx index 515364c555..029a0edbad 100644 --- a/web/src/shared/components/modals/license/LicenseModalSideImage/LicenseModalSideImage.tsx +++ b/web/src/shared/components/modals/license/LicenseModalSideImage/LicenseModalSideImage.tsx @@ -2,6 +2,7 @@ import businessImage from './assets/business.png'; import enterpriseImage from './assets/enterprise.png'; import expiredImage from './assets/expired.png'; import limitImage from './assets/limit.png'; +import reactivatedImage from './assets/reactivated.png'; import type { LicenseModalSideImageVariantValue } from './types'; export const LicenseModalSideImage = ({ @@ -62,5 +63,18 @@ export const LicenseModalSideImage = ({ }} /> ); + case 'reactivated': + return ( + + ); } }; diff --git a/web/src/shared/components/modals/license/LicenseModalSideImage/assets/reactivated.png b/web/src/shared/components/modals/license/LicenseModalSideImage/assets/reactivated.png new file mode 100644 index 0000000000..92dc22521b Binary files /dev/null and b/web/src/shared/components/modals/license/LicenseModalSideImage/assets/reactivated.png differ diff --git a/web/src/shared/components/modals/license/LicenseModalSideImage/types.ts b/web/src/shared/components/modals/license/LicenseModalSideImage/types.ts index f0116b7e63..583b71295c 100644 --- a/web/src/shared/components/modals/license/LicenseModalSideImage/types.ts +++ b/web/src/shared/components/modals/license/LicenseModalSideImage/types.ts @@ -3,6 +3,7 @@ export const LicenseModalSideImageVariant = { Limit: 'limit', Business: 'business', Enterprise: 'enterprise', + Reactivated: 'reactivated', } as const; export type LicenseModalSideImageVariantValue = diff --git a/web/src/shared/components/modals/license/LicenseReactivatedModal/LicenseReactivatedModal.tsx b/web/src/shared/components/modals/license/LicenseReactivatedModal/LicenseReactivatedModal.tsx new file mode 100644 index 0000000000..c1ffd3887c --- /dev/null +++ b/web/src/shared/components/modals/license/LicenseReactivatedModal/LicenseReactivatedModal.tsx @@ -0,0 +1,81 @@ +import './style.scss'; +import { useEffect, useState } from 'react'; +import { m } from '../../../../../paraglide/messages'; +import { AppText } from '../../../../defguard-ui/components/AppText/AppText'; +import { Button } from '../../../../defguard-ui/components/Button/Button'; +import { Divider } from '../../../../defguard-ui/components/Divider/Divider'; +import { SizedBox } from '../../../../defguard-ui/components/SizedBox/SizedBox'; +import { TextStyle, ThemeSpacing, ThemeVariable } from '../../../../defguard-ui/types'; +import { + closeModal, + subscribeCloseModal, + subscribeOpenModal, +} from '../../../../hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../../hooks/modalControls/modalTypes'; +import { Controls } from '../../../Controls/Controls'; +import { LicenseModal } from '../../LicenseModal/LicenseModal'; +import { LicenseModalSideImage } from '../LicenseModalSideImage/LicenseModalSideImage'; + +const modalNameKey = ModalName.LicenseReactivated; + +export const LicenseReactivatedModal = () => { + const [isOpen, setOpen] = useState(false); + + useEffect(() => { + const openSub = subscribeOpenModal(modalNameKey, () => setOpen(true)); + const closeSub = subscribeCloseModal(modalNameKey, () => setOpen(false)); + return () => { + openSub.unsubscribe(); + closeSub.unsubscribe(); + }; + }, []); + + return ( + setOpen(false)} + image={} + lines + > + + + ); +}; + +const ModalContent = () => { + return ( + <> + + {m.modal_license_reactivated_eyebrow()} + + + + {m.modal_license_reactivated_title()} + + + + {m.modal_license_reactivated_lead()} + + + + {m.modal_license_reactivated_body()} + + + + {m.modal_license_reactivated_note()} + + + +
+
+
+ + ); +}; diff --git a/web/src/shared/components/modals/license/LicenseReactivatedModal/style.scss b/web/src/shared/components/modals/license/LicenseReactivatedModal/style.scss new file mode 100644 index 0000000000..d50a847e48 --- /dev/null +++ b/web/src/shared/components/modals/license/LicenseReactivatedModal/style.scss @@ -0,0 +1,3 @@ +#license-reactivated-modal .content-track { + min-height: 369px; +} diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 493d13646e..b73914b9b3 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 493d13646e9817a197a2d88f2b5bb1931b421ed5 +Subproject commit b73914b9b32ded9a4e248def32a5b7fbe87708c3 diff --git a/web/src/shared/form.tsx b/web/src/shared/form.tsx index 3e6d048fff..8eaf04c382 100644 --- a/web/src/shared/form.tsx +++ b/web/src/shared/form.tsx @@ -3,6 +3,7 @@ import { FormSelectMultiple } from './components/FormSelectMultiple/FormSelectMu import { FormUploadField } from './components/FormUploadField/FormUploadField'; import { FormCheckbox } from './defguard-ui/components/form/FormCheckbox/FormCheckbox'; import { FormCheckboxGroup } from './defguard-ui/components/form/FormCheckboxGroup/FormCheckboxGroup'; +import { FormDateInput } from './defguard-ui/components/form/FormDateInput/FormDateInput'; import { FormInput } from './defguard-ui/components/form/FormInput/FormInput'; import { FormInteractiveBlock } from './defguard-ui/components/form/FormInteractiveBlock/FormInteractiveBlock'; import { FormRadio } from './defguard-ui/components/form/FormRadio/FormRadio'; @@ -30,6 +31,7 @@ export const { useAppForm, withFieldGroup, withForm } = createFormHook({ FormInteractiveBlock, FormUploadField, FormCheckboxGroup, + FormDateInput, }, formComponents: { FormSubmitButton, diff --git a/web/src/shared/hooks/modalControls/modalTypes.ts b/web/src/shared/hooks/modalControls/modalTypes.ts index 04012a971b..2871ebc31b 100644 --- a/web/src/shared/hooks/modalControls/modalTypes.ts +++ b/web/src/shared/hooks/modalControls/modalTypes.ts @@ -30,6 +30,7 @@ import type { export const ModalName = { LicenseExpired: 'licenseExpired', + LicenseReactivated: 'licenseReactivated', LicenseLimitConflict: 'licenseLimitConflict', UpgradeBusiness: 'upgradeBusiness', UpgradeEnterprise: 'upgradeEnterprise', @@ -202,6 +203,9 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [ name: z.literal(ModalName.LicenseExpired), data: z.custom(), }), + z.object({ + name: z.literal(ModalName.LicenseReactivated), + }), z.object({ name: z.literal(ModalName.AddNewDevice), data: z.custom(), diff --git a/web/src/shared/utils/csv.ts b/web/src/shared/utils/csv.ts index f36d27f7d9..6d1e02f662 100644 --- a/web/src/shared/utils/csv.ts +++ b/web/src/shared/utils/csv.ts @@ -5,6 +5,12 @@ export const joinCsv = (values?: string[] | string | null): string => { return values.join(', '); }; +export const toCsvArray = (value?: string[] | string | null): string[] | null => { + if (Array.isArray(value)) return value; + if (value) return [value]; + return null; +}; + export const splitCsv = (value: string): string[] => { return value .split(',') diff --git a/web/src/shared/utils/postureWarning.ts b/web/src/shared/utils/postureWarning.ts new file mode 100644 index 0000000000..c82f6178bf --- /dev/null +++ b/web/src/shared/utils/postureWarning.ts @@ -0,0 +1,123 @@ +import { m } from '../../paraglide/messages'; +import { openModal } from '../hooks/modalControls/modalsSubjects'; +import { ModalName } from '../hooks/modalControls/modalTypes'; + +type Option = { readonly id: number; readonly label: string }; + +type SelectionChangeArgs = { + current: Iterable; + next: Iterable; + options: readonly Option[]; + actionPromise: () => Promise; +}; + +type ConfirmSelectionChangeArgs = SelectionChangeArgs & { + deferredEnforcement?: boolean; + bodyMessage: (args: { changes: string }) => string; +}; + +/** + * One bold heading followed by the ids as label bullets, sorted by label, or + * `null` when there are no ids so the group is omitted along with its heading. + */ +const formatGroup = ( + heading: string, + ids: number[], + labelFor: (id: number) => string, +) => { + if (ids.length === 0) return null; + + const bullets = ids + .map(labelFor) + .sort((left, right) => left.localeCompare(right)) + .map((label) => `- ${label}`) + .join('\n'); + + return `**${heading}**\n\n${bullets}`; +}; + +/** + * Diffs id sets, resolves labels, composes the four states (diff only, + * deferred-enforcement only, both, neither) and opens the ConfirmAction + * modal. Returns `true` when a modal was opened. + */ +const confirmSelectionChange = ({ + current, + next, + options, + actionPromise, + deferredEnforcement, + bodyMessage, +}: ConfirmSelectionChangeArgs): boolean => { + const currentSet = new Set(current); + const nextSet = new Set(next); + + const addedIds = [...nextSet].filter((id) => !currentSet.has(id)); + const removedIds = [...currentSet].filter((id) => !nextSet.has(id)); + + const labelMap = new Map(options.map((option) => [option.id, option.label])); + const labelFor = (id: number) => labelMap.get(id) ?? String(id); + + const parts = [ + formatGroup(m.modal_posture_assignment_warning_added(), addedIds, labelFor), + formatGroup(m.modal_posture_assignment_warning_removed(), removedIds, labelFor), + ].filter((part): part is string => part !== null); + + const changes = parts.length > 0 ? parts.join('\n\n') : null; + const hasDiff = changes !== null; + + if (!hasDiff && !deferredEnforcement) return false; + + let contentMd: string; + if (hasDiff) { + contentMd = deferredEnforcement + ? `${bodyMessage({ changes })}\n\n${m.modal_posture_rules_warning_body()}` + : bodyMessage({ changes }); + } else { + contentMd = m.modal_posture_rules_warning_body(); + } + + openModal(ModalName.ConfirmAction, { + title: m.modal_posture_assignment_warning_title(), + contentMd, + actionPromise, + submitProps: { + text: m.controls_save_changes_anyway(), + variant: 'critical', + }, + }); + + return true; +}; + +/** + * Warn before changing which posture checks apply to a location. `current` and + * `next` hold posture-check ids; the body warns about active sessions on this + * location. + * + * Returns `true` when a modal was opened, which is not the same as the admin + * agreeing: the modal is fired and forgotten, and `actionPromise` runs only if + * they confirm. A `false` return means there was nothing to warn about, so the + * caller should proceed with its own save. + */ +export const confirmLocationPostureChange = (args: SelectionChangeArgs): boolean => + confirmSelectionChange({ + ...args, + bodyMessage: m.modal_posture_assignment_warning_body_location, + }); + +/** + * Warn before changing which locations a posture check applies to. `current` and + * `next` hold location ids. When `deferredEnforcement` is true, appends the + * rules-deferred paragraph to the warning body. + * + * Returns `true` when a modal was opened; see + * {@link confirmLocationPostureChange} for what that does and does not mean. + */ +export const confirmPostureLocationChange = ( + args: SelectionChangeArgs & { deferredEnforcement?: boolean }, +): boolean => + confirmSelectionChange({ + ...args, + bodyMessage: m.modal_posture_assignment_warning_body_postures, + }); diff --git a/web/tests/posture-check-delete.test.ts b/web/tests/posture-check-delete.test.ts index 982b718968..98fe094287 100644 --- a/web/tests/posture-check-delete.test.ts +++ b/web/tests/posture-check-delete.test.ts @@ -18,7 +18,7 @@ describe('posture check delete confirmation', () => { expect(modalData.title).toBe('Delete posture check'); expect(modalData.contentMd).toBe( - 'Are you sure you want to delete this check? It’s currently used in Warsaw and Berlin. Removing it may change access criteria for users in these locations.', + 'Are you sure you want to delete this check? It’s currently used in Warsaw and Berlin. Removing it may change access criteria for users in these locations and disconnect active VPN client sessions there.', ); expect(modalData.invalidateKeys).toEqual([ ['device-posture'], @@ -45,7 +45,7 @@ describe('posture check delete confirmation', () => { ); expect(modalData.contentMd).toBe( - 'Are you sure you want to delete this check? It’s currently used in Warsaw, Berlin, and Paris. Removing it may change access criteria for users in these locations.', + 'Are you sure you want to delete this check? It’s currently used in Warsaw, Berlin, and Paris. Removing it may change access criteria for users in these locations and disconnect active VPN client sessions there.', ); }); diff --git a/web/tests/posture-checks-page.test.ts b/web/tests/posture-checks-page.test.ts index cba9ecafbd..6c6991d053 100644 --- a/web/tests/posture-checks-page.test.ts +++ b/web/tests/posture-checks-page.test.ts @@ -271,7 +271,7 @@ describe('posture checks page helpers', () => { ), ).toEqual({ allowPrereleaseClient: true, - configuredOperatingSystems: ['windows', 'android'], + configuredOperatingSystems: ['android', 'windows'], description: 'Existing policy', locations: [3, 9], minimumDesktopClientVersion: '2.0', diff --git a/web/tests/posture-warning.test.ts b/web/tests/posture-warning.test.ts new file mode 100644 index 0000000000..f258ccc2b7 --- /dev/null +++ b/web/tests/posture-warning.test.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { openModal } from '../src/shared/hooks/modalControls/modalsSubjects'; + +vi.mock('../src/shared/hooks/modalControls/modalsSubjects', () => ({ + openModal: vi.fn(), +})); + +vi.mock('../src/shared/hooks/modalControls/modalTypes', () => ({ + ModalName: { ConfirmAction: 'confirmAction' }, +})); + +vi.mock('../src/paraglide/messages', () => ({ + m: { + modal_posture_assignment_warning_added: () => 'Added:', + modal_posture_assignment_warning_removed: () => 'Removed:', + modal_posture_assignment_warning_title: () => 'Confirm posture check changes', + modal_posture_assignment_warning_body_location: ({ changes }: { changes: string }) => + `These changes may disconnect active VPN client sessions for this location.\n\n${changes}`, + modal_posture_assignment_warning_body_postures: ({ changes }: { changes: string }) => + `These changes may disconnect active VPN client sessions for the affected locations.\n\n${changes}`, + modal_posture_rules_warning_body: () => + 'These rules are enforced the next time each device connects. Devices connected now are unaffected until they reconnect.', + controls_save_changes_anyway: () => 'Save changes anyway', + }, +})); + +import { + confirmLocationPostureChange, + confirmPostureLocationChange, +} from '../src/shared/utils/postureWarning'; + +type Option = { readonly id: number; readonly label: string }; + +const locOptions: Option[] = [ + { id: 1, label: 'Berlin' }, + { id: 2, label: 'Amsterdam' }, + { id: 3, label: 'Zurich' }, +]; + +const noop = async () => {}; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('confirmLocationPostureChange', () => { + it('returns false and opens nothing when the id sets are identical', () => { + const result = confirmLocationPostureChange({ + current: [1, 2], + next: [1, 2], + options: locOptions, + actionPromise: noop, + }); + + expect(result).toBe(false); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('returns false and opens nothing when both sets are empty', () => { + const result = confirmLocationPostureChange({ + current: [], + next: [], + options: locOptions, + actionPromise: noop, + }); + + expect(result).toBe(false); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('opens a modal with the Added group when items were added', () => { + confirmLocationPostureChange({ + current: [1], + next: [1, 2, 3], + options: locOptions, + actionPromise: noop, + }); + + expect(openModal).toHaveBeenCalledOnce(); + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + // Added: group sorted alphabetically + expect(contentMd).toContain('**Added:**'); + expect(contentMd).toContain('- Amsterdam'); + expect(contentMd).toContain('- Zurich'); + // Added: items appear in alphabetical order + expect(contentMd.indexOf('Amsterdam')).toBeLessThan(contentMd.indexOf('Zurich')); + expect(contentMd).not.toContain('Removed'); + }); + + it('opens a modal with the Removed group when items were removed', () => { + confirmLocationPostureChange({ + current: [1, 2, 3], + next: [1], + options: locOptions, + actionPromise: noop, + }); + + expect(openModal).toHaveBeenCalledOnce(); + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('**Removed:**'); + expect(contentMd).toContain('- Amsterdam'); + expect(contentMd).toContain('- Zurich'); + expect(contentMd.indexOf('Amsterdam')).toBeLessThan(contentMd.indexOf('Zurich')); + expect(contentMd).not.toContain('Added'); + }); + + it('opens a modal with both Added and Removed groups when items changed', () => { + confirmLocationPostureChange({ + current: [1], + next: [2, 3], + options: locOptions, + actionPromise: noop, + }); + + expect(openModal).toHaveBeenCalledOnce(); + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('**Added:**'); + expect(contentMd).toContain('**Removed:**'); + expect(contentMd.indexOf('Added:')).toBeLessThan(contentMd.indexOf('Removed:')); + }); + + it('includes the location-warning body message', () => { + confirmLocationPostureChange({ + current: [1], + next: [2], + options: locOptions, + actionPromise: noop, + }); + + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('for this location.'); + }); + + it('accepts Sets as well as arrays', () => { + const result = confirmLocationPostureChange({ + current: new Set([1, 2]), + next: new Set([2, 3]), + options: locOptions, + actionPromise: noop, + }); + + expect(result).toBe(true); + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('**Added:**'); + expect(contentMd).toContain('- Zurich'); + expect(contentMd).toContain('**Removed:**'); + expect(contentMd).toContain('- Berlin'); + }); +}); + +describe('confirmPostureLocationChange', () => { + it('returns false when no diff and no deferredEnforcement', () => { + const result = confirmPostureLocationChange({ + current: [1, 2], + next: [1, 2], + options: locOptions, + actionPromise: noop, + }); + + expect(result).toBe(false); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('includes the postures-warning body message', () => { + confirmPostureLocationChange({ + current: [1], + next: [2], + options: locOptions, + actionPromise: noop, + }); + + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('for the affected locations.'); + }); + + it('opens rules-only body when deferredEnforcement is true and no location diff', () => { + confirmPostureLocationChange({ + current: [1, 2], + next: [1, 2], + options: locOptions, + actionPromise: noop, + deferredEnforcement: true, + }); + + expect(openModal).toHaveBeenCalledOnce(); + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('These rules are enforced'); + expect(contentMd).not.toContain('Added'); + expect(contentMd).not.toContain('Removed'); + }); + + it('appends rules paragraph after locations diff when deferredEnforcement is true and diff exists', () => { + confirmPostureLocationChange({ + current: [1], + next: [2], + options: locOptions, + actionPromise: noop, + deferredEnforcement: true, + }); + + expect(openModal).toHaveBeenCalledOnce(); + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('for the affected locations.'); + expect(contentMd).toContain('These rules are enforced'); + // rules paragraph appears after the locations body + const locIndex = contentMd.indexOf('for the affected locations.'); + const rulesIndex = contentMd.indexOf('These rules are enforced'); + expect(locIndex).toBeLessThan(rulesIndex); + }); + + it('returns false when deferredEnforcement is false (the falsy default)', () => { + const result = confirmPostureLocationChange({ + current: [1], + next: [1], + options: locOptions, + actionPromise: noop, + deferredEnforcement: false, + }); + + expect(result).toBe(false); + }); +}); + +describe('labels are passed through verbatim', () => { + it('does not alter labels containing markdown syntax', () => { + const spikyOptions: Option[] = [ + { id: 1, label: 'plain' }, + { id: 2, label: '*bold*' }, + { id: 3, label: '[link](url)' }, + ]; + + confirmLocationPostureChange({ + current: [1], + next: [2, 3], + options: spikyOptions, + actionPromise: noop, + }); + + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('- *bold*'); + expect(contentMd).toContain('- [link](url)'); + }); +}); + +describe('unknown id fallback', () => { + it('uses String(id) when an id has no matching option', () => { + confirmLocationPostureChange({ + current: [], + next: [1, 99], + options: [{ id: 1, label: 'Known' }], + actionPromise: noop, + }); + + const contentMd = vi.mocked(openModal).mock.calls[0][1].contentMd; + expect(contentMd).toContain('- 99'); + expect(contentMd).toContain('- Known'); + }); +}); + +describe('modal structure', () => { + it('opens ConfirmAction with the shared title, critical variant, and actionPromise', () => { + const actionPromise = async () => 'saved'; + confirmLocationPostureChange({ + current: [1], + next: [2], + options: locOptions, + actionPromise, + }); + + const modalData = vi.mocked(openModal).mock.calls[0][1]; + expect(openModal).toHaveBeenCalledWith( + 'confirmAction', + expect.objectContaining({ + title: 'Confirm posture check changes', + actionPromise, + submitProps: { + text: 'Save changes anyway', + variant: 'critical', + }, + }), + ); + // No stale keys from the old helper + expect(modalData.invalidateKeys).toBeUndefined(); + expect(modalData.onSuccess).toBeUndefined(); + expect(modalData.onError).toBeUndefined(); + }); +});