From b64ce0cf4bbb3058f1604d337197e298967a1dd0 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:03:02 +0100 Subject: [PATCH] Change directory sync UI, add option to specify what to sync (#901) * ui changes, sync targets * change stale time to infinity * cleanup * fix tests, cargo prepare --- ...510630fa89a072f81ccfe21b6e986f7b2552.json} | 16 +- ...05c748bd2b09683251c45728e227a320b4bc.json} | 21 +- ...2dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json} | 21 +- ...47357763cf9a1372f418c57ddc66ffab3697.json} | 21 +- ...77b3a2445b02bba1b6de399fa576acd82025.json} | 21 +- ...677e44dea7faf04115dbe8200041b662040c.json} | 16 +- ...13b3e829aad2a0cdbfa00b83a81158b9aa96.json} | 16 +- ...41211115639_directory_sync_target.down.sql | 1 + ...0241211115639_directory_sync_target.up.sql | 7 + src/enterprise/db/models/openid_provider.rs | 56 ++- src/enterprise/directory_sync/google.rs | 12 +- src/enterprise/directory_sync/mod.rs | 128 ++++- src/enterprise/handlers/openid_providers.rs | 2 + tests/openid_login.rs | 2 + web/src/components/AppLoader.tsx | 1 + web/src/i18n/en/index.ts | 23 +- web/src/i18n/i18n-types.ts | 92 +++- web/src/i18n/pl/index.ts | 20 +- .../OpenIdSettings/OpenIdSettings.tsx | 10 +- .../components/DirectorySyncSettings.tsx | 232 +++++++++ .../components/OpenIdProviderSettings.tsx | 175 +++++++ .../components/OpenIdSettingsForm.tsx | 470 ------------------ .../components/OpenIdSettingsRootForm.tsx | 218 ++++++++ .../OpenIdSettings/components/style.scss | 68 ++- web/src/shared/defguard-ui | 2 +- web/src/shared/types.ts | 1 + 26 files changed, 1120 insertions(+), 532 deletions(-) rename .sqlx/{query-358eb4ff38e630d85f51a67c8946a9e467968e630de46b1e73be973e2951acf9.json => query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json} (71%) rename .sqlx/{query-1642e9ce8d0d4e6970dd5a84ed301a6fb5fcd601fe172e383824958da5e30a2d.json => query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json} (81%) rename .sqlx/{query-24cbfef8c4c37a0b23ad36bc962c288e63b5257d2f8639972eee9d4ea705fc9d.json => query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json} (82%) rename .sqlx/{query-e18de0993cc5864bcd161dc946cf1d4dee151169476a188c2f4127bc3490ce33.json => query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json} (81%) rename .sqlx/{query-bc69e8b09f44d8264d380eebc6c777c83d999e45d78605b925cba4ad2874aafc.json => query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json} (80%) rename .sqlx/{query-6e9d906972ba9ce9c5a098aa040e0b0c005b8ba1227d436e9055bd5d990d2ef8.json => query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json} (72%) rename .sqlx/{query-f0deb21856e2eca2b294539c8bb0f64f48657091d9bc260aa5b4d00831e2c26b.json => query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json} (74%) create mode 100644 migrations/20241211115639_directory_sync_target.down.sql create mode 100644 migrations/20241211115639_directory_sync_target.up.sql create mode 100644 web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx create mode 100644 web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx delete mode 100644 web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx create mode 100644 web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx diff --git a/.sqlx/query-358eb4ff38e630d85f51a67c8946a9e467968e630de46b1e73be973e2951acf9.json b/.sqlx/query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json similarity index 71% rename from .sqlx/query-358eb4ff38e630d85f51a67c8946a9e467968e630de46b1e73be973e2951acf9.json rename to .sqlx/query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json index e778d55f9..1e37c06b1 100644 --- a/.sqlx/query-358eb4ff38e630d85f51a67c8946a9e467968e630de46b1e73be973e2951acf9.json +++ b/.sqlx/query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id", + "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id", "describe": { "columns": [ { @@ -44,6 +44,18 @@ ] } } + }, + { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } } ] }, @@ -51,5 +63,5 @@ false ] }, - "hash": "358eb4ff38e630d85f51a67c8946a9e467968e630de46b1e73be973e2951acf9" + "hash": "77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552" } diff --git a/.sqlx/query-1642e9ce8d0d4e6970dd5a84ed301a6fb5fcd601fe172e383824958da5e30a2d.json b/.sqlx/query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json similarity index 81% rename from .sqlx/query-1642e9ce8d0d4e6970dd5a84ed301a6fb5fcd601fe172e383824958da5e30a2d.json rename to .sqlx/query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json index ac9a95bb6..d7ef4b0fa 100644 --- a/.sqlx/query-1642e9ce8d0d4e6970dd5a84ed301a6fb5fcd601fe172e383824958da5e30a2d.json +++ b/.sqlx/query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\" FROM \"openidprovider\" WHERE id = $1", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\" FROM \"openidprovider\" WHERE id = $1", "describe": { "columns": [ { @@ -89,6 +89,22 @@ } } } + }, + { + "ordinal": 13, + "name": "directory_sync_target: _", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } } ], "parameters": { @@ -109,8 +125,9 @@ false, false, false, + false, false ] }, - "hash": "1642e9ce8d0d4e6970dd5a84ed301a6fb5fcd601fe172e383824958da5e30a2d" + "hash": "7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc" } diff --git a/.sqlx/query-24cbfef8c4c37a0b23ad36bc962c288e63b5257d2f8639972eee9d4ea705fc9d.json b/.sqlx/query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json similarity index 82% rename from .sqlx/query-24cbfef8c4c37a0b23ad36bc962c288e63b5257d2f8639972eee9d4ea705fc9d.json rename to .sqlx/query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json index 46b67e781..e4436fe45 100644 --- a/.sqlx/query-24cbfef8c4c37a0b23ad36bc962c288e63b5257d2f8639972eee9d4ea705fc9d.json +++ b/.sqlx/query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, \n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\" FROM openidprovider WHERE name = $1", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, \n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\" FROM openidprovider WHERE name = $1", "describe": { "columns": [ { @@ -89,6 +89,22 @@ } } } + }, + { + "ordinal": 13, + "name": "directory_sync_target: DirectorySyncTarget", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } } ], "parameters": { @@ -109,8 +125,9 @@ false, false, false, + false, false ] }, - "hash": "24cbfef8c4c37a0b23ad36bc962c288e63b5257d2f8639972eee9d4ea705fc9d" + "hash": "93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e" } diff --git a/.sqlx/query-e18de0993cc5864bcd161dc946cf1d4dee151169476a188c2f4127bc3490ce33.json b/.sqlx/query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json similarity index 81% rename from .sqlx/query-e18de0993cc5864bcd161dc946cf1d4dee151169476a188c2f4127bc3490ce33.json rename to .sqlx/query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json index 083aa73ee..093e10db2 100644 --- a/.sqlx/query-e18de0993cc5864bcd161dc946cf1d4dee151169476a188c2f4127bc3490ce33.json +++ b/.sqlx/query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\" FROM \"openidprovider\"", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\" FROM \"openidprovider\"", "describe": { "columns": [ { @@ -89,6 +89,22 @@ } } } + }, + { + "ordinal": 13, + "name": "directory_sync_target: _", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } } ], "parameters": { @@ -107,8 +123,9 @@ false, false, false, + false, false ] }, - "hash": "e18de0993cc5864bcd161dc946cf1d4dee151169476a188c2f4127bc3490ce33" + "hash": "9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697" } diff --git a/.sqlx/query-bc69e8b09f44d8264d380eebc6c777c83d999e45d78605b925cba4ad2874aafc.json b/.sqlx/query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json similarity index 80% rename from .sqlx/query-bc69e8b09f44d8264d380eebc6c777c83d999e45d78605b925cba4ad2874aafc.json rename to .sqlx/query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json index 560617529..3dd332649 100644 --- a/.sqlx/query-bc69e8b09f44d8264d380eebc6c777c83d999e45d78605b925cba4ad2874aafc.json +++ b/.sqlx/query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\" FROM openidprovider LIMIT 1", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\" FROM openidprovider LIMIT 1", "describe": { "columns": [ { @@ -89,6 +89,22 @@ } } } + }, + { + "ordinal": 13, + "name": "directory_sync_target: DirectorySyncTarget", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } } ], "parameters": { @@ -107,8 +123,9 @@ false, false, false, + false, false ] }, - "hash": "bc69e8b09f44d8264d380eebc6c777c83d999e45d78605b925cba4ad2874aafc" + "hash": "bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025" } diff --git a/.sqlx/query-6e9d906972ba9ce9c5a098aa040e0b0c005b8ba1227d436e9055bd5d990d2ef8.json b/.sqlx/query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json similarity index 72% rename from .sqlx/query-6e9d906972ba9ce9c5a098aa040e0b0c005b8ba1227d436e9055bd5d990d2ef8.json rename to .sqlx/query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json index 574af8c9a..b42f08b95 100644 --- a/.sqlx/query-6e9d906972ba9ce9c5a098aa040e0b0c005b8ba1227d436e9055bd5d990d2ef8.json +++ b/.sqlx/query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13 WHERE id = $1", + "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13,\"directory_sync_target\" = $14 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -39,10 +39,22 @@ ] } } + }, + { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } } ] }, "nullable": [] }, - "hash": "6e9d906972ba9ce9c5a098aa040e0b0c005b8ba1227d436e9055bd5d990d2ef8" + "hash": "c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c" } diff --git a/.sqlx/query-f0deb21856e2eca2b294539c8bb0f64f48657091d9bc260aa5b4d00831e2c26b.json b/.sqlx/query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json similarity index 74% rename from .sqlx/query-f0deb21856e2eca2b294539c8bb0f64f48657091d9bc260aa5b4d00831e2c26b.json rename to .sqlx/query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json index c738f5c89..7b82f6643 100644 --- a/.sqlx/query-f0deb21856e2eca2b294539c8bb0f64f48657091d9bc260aa5b4d00831e2c26b.json +++ b/.sqlx/query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12 WHERE id = $13", + "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, directory_sync_target = $13 WHERE id = $14", "describe": { "columns": [], "parameters": { @@ -39,10 +39,22 @@ } } }, + { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + }, "Int8" ] }, "nullable": [] }, - "hash": "f0deb21856e2eca2b294539c8bb0f64f48657091d9bc260aa5b4d00831e2c26b" + "hash": "ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96" } diff --git a/migrations/20241211115639_directory_sync_target.down.sql b/migrations/20241211115639_directory_sync_target.down.sql new file mode 100644 index 000000000..c50b53dfe --- /dev/null +++ b/migrations/20241211115639_directory_sync_target.down.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider DROP COLUMN directory_sync_target; diff --git a/migrations/20241211115639_directory_sync_target.up.sql b/migrations/20241211115639_directory_sync_target.up.sql new file mode 100644 index 000000000..e1afd0795 --- /dev/null +++ b/migrations/20241211115639_directory_sync_target.up.sql @@ -0,0 +1,7 @@ +CREATE TYPE dirsync_target AS ENUM ( + 'all', + 'users', + 'groups' +); + +ALTER TABLE openidprovider ADD COLUMN directory_sync_target dirsync_target DEFAULT 'all'::dirsync_target NOT NULL; diff --git a/src/enterprise/db/models/openid_provider.rs b/src/enterprise/db/models/openid_provider.rs index fc42a1046..ed6d09c20 100644 --- a/src/enterprise/db/models/openid_provider.rs +++ b/src/enterprise/db/models/openid_provider.rs @@ -45,6 +45,46 @@ impl From for DirectorySyncUserBehavior { } } +// What to sync from the directory +// All: Sync both users and groups +// Users: Sync only users and their state +// Groups: Sync only groups (members without their state) +#[derive(Clone, Deserialize, Serialize, PartialEq, Type, Debug)] +#[sqlx(type_name = "dirsync_target", rename_all = "snake_case")] +pub enum DirectorySyncTarget { + All, + Users, + Groups, +} + +impl fmt::Display for DirectorySyncTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + DirectorySyncTarget::All => "all", + DirectorySyncTarget::Users => "users", + DirectorySyncTarget::Groups => "groups", + } + ) + } +} + +impl From for DirectorySyncTarget { + fn from(s: String) -> Self { + match s.to_lowercase().as_str() { + "all" => DirectorySyncTarget::All, + "users" => DirectorySyncTarget::Users, + "groups" => DirectorySyncTarget::Groups, + _ => { + warn!("Unknown directory sync target passed: {}", s); + DirectorySyncTarget::All + } + } + } +} + #[derive(Deserialize, Model, Serialize)] pub struct OpenIdProvider { pub id: I, @@ -64,6 +104,8 @@ pub struct OpenIdProvider { pub directory_sync_user_behavior: DirectorySyncUserBehavior, #[model(enum)] pub directory_sync_admin_behavior: DirectorySyncUserBehavior, + #[model(enum)] + pub directory_sync_target: DirectorySyncTarget, } impl OpenIdProvider { @@ -81,6 +123,7 @@ impl OpenIdProvider { directory_sync_interval: i32, directory_sync_user_behavior: DirectorySyncUserBehavior, directory_sync_admin_behavior: DirectorySyncUserBehavior, + directory_sync_target: DirectorySyncTarget, ) -> Self { Self { id: NoId, @@ -96,6 +139,7 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior, directory_sync_admin_behavior, + directory_sync_target, } } @@ -105,8 +149,9 @@ impl OpenIdProvider { "UPDATE openidprovider SET name = $1, \ base_url = $2, client_id = $3, client_secret = $4, \ display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, \ - directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12 \ - WHERE id = $13", + directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, \ + directory_sync_target = $13 \ + WHERE id = $14", self.name, self.base_url, self.client_id, @@ -119,6 +164,7 @@ impl OpenIdProvider { self.directory_sync_interval, self.directory_sync_user_behavior as DirectorySyncUserBehavior, self.directory_sync_admin_behavior as DirectorySyncUserBehavior, + self.directory_sync_target as DirectorySyncTarget, provider.id, ) .execute(pool) @@ -138,7 +184,8 @@ impl OpenIdProvider { "SELECT id, name, base_url, client_id, client_secret, display_name, \ google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ - directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\" \ + directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ + directory_sync_target \"directory_sync_target: DirectorySyncTarget\" \ FROM openidprovider WHERE name = $1", name ) @@ -152,7 +199,8 @@ impl OpenIdProvider { "SELECT id, name, base_url, client_id, client_secret, display_name, \ google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, \ directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ - directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\" \ + directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ + directory_sync_target \"directory_sync_target: DirectorySyncTarget\" \ FROM openidprovider LIMIT 1" ) .fetch_optional(pool) diff --git a/src/enterprise/directory_sync/google.rs b/src/enterprise/directory_sync/google.rs index e5d9bae8d..6035aaff2 100644 --- a/src/enterprise/directory_sync/google.rs +++ b/src/enterprise/directory_sync/google.rs @@ -144,7 +144,7 @@ impl GoogleDirectorySync { url.query_pairs_mut() .append_pair("userKey", user_id) - .append_pair("max_results", "999"); + .append_pair("maxResults", "500"); let client = reqwest::Client::new(); let response = client @@ -174,7 +174,7 @@ impl GoogleDirectorySync { url.query_pairs_mut() .append_pair("customer", "my_customer") - .append_pair("max_results", "999"); + .append_pair("maxResults", "500"); let client = reqwest::Client::builder().build()?; let response = client @@ -206,7 +206,8 @@ impl GoogleDirectorySync { ); let mut url = Url::from_str(&url_str).unwrap(); url.query_pairs_mut() - .append_pair("includeDerivedMembership", "true"); + .append_pair("includeDerivedMembership", "true") + .append_pair("maxResults", "500"); let client = reqwest::Client::builder().build()?; let response = client .get(url) @@ -250,7 +251,10 @@ impl GoogleDirectorySync { .as_ref() .ok_or(DirectorySyncError::AccessTokenExpired)?; let mut url = Url::from_str(ALL_USERS_URL).unwrap(); - url.query_pairs_mut().append_pair("customer", "my_customer"); + url.query_pairs_mut() + .append_pair("customer", "my_customer") + .append_pair("maxResults", "500") + .append_pair("showDeleted", "false"); let client = reqwest::Client::builder().build()?; let response = client .get(url) diff --git a/src/enterprise/directory_sync/mod.rs b/src/enterprise/directory_sync/mod.rs index 20ac3894c..964830581 100644 --- a/src/enterprise/directory_sync/mod.rs +++ b/src/enterprise/directory_sync/mod.rs @@ -9,7 +9,10 @@ use crate::{ use sqlx::error::Error as SqlxError; use thiserror::Error; -use super::{db::models::openid_provider::OpenIdProvider, is_enterprise_enabled}; +use super::{ + db::models::openid_provider::{DirectorySyncTarget, OpenIdProvider}, + is_enterprise_enabled, +}; #[derive(Debug, Error)] pub enum DirectorySyncError { @@ -130,7 +133,8 @@ pub(crate) async fn sync_user_groups_if_configured( return Ok(()); } - if !is_directory_sync_enabled(pool).await? { + let provider = OpenIdProvider::get_current(pool).await?; + if !is_directory_sync_enabled(provider.as_ref()).await? { debug!("Directory sync is disabled, skipping syncing user groups"); return Ok(()); } @@ -184,7 +188,7 @@ async fn sync_all_users_groups( directory_sync: &T, pool: &PgPool, ) -> Result<(), DirectorySyncError> { - info!("Syncing all users' groups, this may take a while..."); + info!("Syncing all users' groups with the directory, this may take a while..."); let directory_groups = directory_sync.get_groups().await?; debug!("Found {} groups to sync", directory_groups.len()); @@ -295,9 +299,11 @@ async fn get_directory_sync_client( } } -async fn is_directory_sync_enabled(pool: &PgPool) -> Result { +async fn is_directory_sync_enabled( + provider: Option<&OpenIdProvider>, +) -> Result { debug!("Checking if directory sync is enabled"); - if let Some(provider_settings) = OpenIdProvider::get_current(pool).await? { + if let Some(provider_settings) = provider { debug!( "Directory sync enabled state: {}", provider_settings.directory_sync_enabled @@ -313,7 +319,7 @@ async fn sync_all_users_state( directory_sync: &T, pool: &PgPool, ) -> Result<(), DirectorySyncError> { - debug!("Syncing all users' state with the directory"); + info!("Syncing all users' state with the directory, this may take a while..."); let mut transaction = pool.begin().await?; let all_users = directory_sync.get_all_users().await?; let settings = OpenIdProvider::get_current(pool) @@ -462,7 +468,7 @@ async fn sync_all_users_state( } debug!("Done processing enabled users"); transaction.commit().await?; - debug!("Syncing all users' state with the directory done"); + info!("Syncing all users' state with the directory done"); Ok(()) } @@ -481,24 +487,42 @@ pub(crate) async fn get_directory_sync_interval(pool: &PgPool) -> u64 { } pub(crate) async fn do_directory_sync(pool: &PgPool) -> Result<(), DirectorySyncError> { + #[cfg(not(test))] if !is_enterprise_enabled() { debug!("Enterprise is not enabled, skipping performing directory sync"); return Ok(()); } - if !is_directory_sync_enabled(pool).await? { + // TODO: The settings are retrieved many times + let provider = OpenIdProvider::get_current(pool).await?; + + if !is_directory_sync_enabled(provider.as_ref()).await? { debug!("Directory sync is disabled, skipping performing directory sync"); return Ok(()); } + let sync_target = provider + .ok_or(DirectorySyncError::NotConfigured)? + .directory_sync_target; + match get_directory_sync_client(pool).await { Ok(mut dir_sync) => { // TODO: Directory sync's access token is dropped every time, find a way to preserve it // Same goes for Etags, those could be used to reduce the amount of data transferred. Some way // of preserving them should be implemented. dir_sync.prepare().await?; - sync_all_users_state(&dir_sync, pool).await?; - sync_all_users_groups(&dir_sync, pool).await?; + if matches!( + sync_target, + DirectorySyncTarget::All | DirectorySyncTarget::Users + ) { + sync_all_users_state(&dir_sync, pool).await?; + } + if matches!( + sync_target, + DirectorySyncTarget::All | DirectorySyncTarget::Groups + ) { + sync_all_users_groups(&dir_sync, pool).await?; + } } Err(err) => { error!("Failed to build directory sync client: {err}"); @@ -511,13 +535,17 @@ pub(crate) async fn do_directory_sync(pool: &PgPool) -> Result<(), DirectorySync #[cfg(test)] mod test { use super::*; - use crate::{config::DefGuardConfig, SERVER_CONFIG}; + use crate::{ + config::DefGuardConfig, enterprise::db::models::openid_provider::DirectorySyncTarget, + SERVER_CONFIG, + }; use secrecy::ExposeSecret; async fn make_test_provider( pool: &PgPool, user_behavior: DirectorySyncUserBehavior, admin_behavior: DirectorySyncUserBehavior, + target: DirectorySyncTarget, ) -> OpenIdProvider { let current = OpenIdProvider::get_current(pool).await.unwrap(); @@ -538,6 +566,7 @@ mod test { 60, user_behavior, admin_behavior, + target, ) .save(pool) .await @@ -576,6 +605,7 @@ mod test { &pool, DirectorySyncUserBehavior::Keep, DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -605,6 +635,7 @@ mod test { &pool, DirectorySyncUserBehavior::Delete, DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -638,6 +669,7 @@ mod test { &pool, DirectorySyncUserBehavior::Keep, DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -678,6 +710,7 @@ mod test { &pool, DirectorySyncUserBehavior::Delete, DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, ) .await; User::init_admin_user(&pool, config.default_admin_password.expose_secret()) @@ -720,6 +753,7 @@ mod test { &pool, DirectorySyncUserBehavior::Disable, DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -762,6 +796,7 @@ mod test { &pool, DirectorySyncUserBehavior::Keep, DirectorySyncUserBehavior::Disable, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -804,6 +839,7 @@ mod test { &pool, DirectorySyncUserBehavior::Delete, DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -853,6 +889,7 @@ mod test { &pool, DirectorySyncUserBehavior::Delete, DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, ) .await; let mut client = get_directory_sync_client(&pool).await.unwrap(); @@ -866,4 +903,73 @@ mod test { let group = Group::find_by_name(&pool, "group1").await.unwrap().unwrap(); assert_eq!(user_groups[0].id, group.id); } + + #[sqlx::test] + async fn test_sync_target_users(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Users, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + } + + #[sqlx::test] + async fn test_sync_target_all(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + make_test_user("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_none()); + } + + #[sqlx::test] + async fn test_sync_target_groups(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Groups, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + make_test_user("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_some()); + } } diff --git a/src/enterprise/handlers/openid_providers.rs b/src/enterprise/handlers/openid_providers.rs index 43fbb0256..a91093ec2 100644 --- a/src/enterprise/handlers/openid_providers.rs +++ b/src/enterprise/handlers/openid_providers.rs @@ -28,6 +28,7 @@ pub struct AddProviderData { pub directory_sync_interval: i32, pub directory_sync_user_behavior: String, pub directory_sync_admin_behavior: String, + pub directory_sync_target: String, } #[derive(Debug, Deserialize, Serialize)] @@ -86,6 +87,7 @@ pub async fn add_openid_provider( provider_data.directory_sync_interval, provider_data.directory_sync_user_behavior.into(), provider_data.directory_sync_admin_behavior.into(), + provider_data.directory_sync_target.into(), ) .upsert(&appstate.pool) .await?; diff --git a/tests/openid_login.rs b/tests/openid_login.rs index db940067f..07a321ef4 100644 --- a/tests/openid_login.rs +++ b/tests/openid_login.rs @@ -1,5 +1,6 @@ use chrono::{Duration, Utc}; use common::{exceed_enterprise_limits, make_test_client}; +use defguard::enterprise::db::models::openid_provider::DirectorySyncTarget; use defguard::enterprise::{ db::models::openid_provider::DirectorySyncUserBehavior, license::get_cached_license, }; @@ -50,6 +51,7 @@ async fn test_openid_providers() { directory_sync_interval: 100, directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_target: DirectorySyncTarget::All.to_string(), }; let response = client diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index 527703ae3..6fd339b04 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -151,6 +151,7 @@ export const AppLoader = () => { }, refetchOnWindowFocus: false, retry: false, + staleTime: Infinity, enabled: !isUndefined(currentUser) && isUserAdmin(currentUser), }); diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 1db374037..b12f63d5f 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -964,7 +964,7 @@ const en: BaseTranslation = { }, openIdSettings: { general: { - title: 'External OpenID Settings', + title: 'External OpenID general settings', helper: 'Here you can change general OpenID behavior in your Defguard instance.', createAccount: { label: @@ -983,9 +983,21 @@ const en: BaseTranslation = { directory_sync_settings: { title: 'Directory Sync Settings', helper: - 'Directory synchronization allows you to automatically synchronize users, groups, and their status from an external provider.', + "Directory synchronization allows you to automatically synchronize users' status and groups from an external provider.", notSupported: 'Directory sync is not supported for this provider.', }, + selects: { + synchronize: { + all: 'All', + users: 'Users', + groups: 'Groups', + }, + behavior: { + keep: 'Keep', + disable: 'Disable', + delete: 'Delete', + }, + }, labels: { provider: { label: 'Provider', @@ -1013,6 +1025,11 @@ const en: BaseTranslation = { enable_directory_sync: { label: 'Enable directory sync', }, + sync_target: { + label: 'Synchronize', + helper: + "What to synchronize from the external provider. You can choose between synchronizing both users' state and group memberships, or narrow it down to just one of these.", + }, sync_interval: { label: 'Synchronization interval', helper: 'Interval in seconds between directory synchronizations.', @@ -1033,7 +1050,7 @@ const en: BaseTranslation = { 'Email address of the account on which behalf the synchronization checks will be performed, e.g. the person who setup the Google service account. See our documentation for more details.', }, service_account_used: { - label: 'Service account used', + label: 'Service account in use', helper: 'The service account currently being used for synchronization. You can change it by uploading a new service account key file.', }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index d721a88a5..e222e898f 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2391,7 +2391,7 @@ type RootTranslation = { openIdSettings: { general: { /** - * E​x​t​e​r​n​a​l​ ​O​p​e​n​I​D​ ​S​e​t​t​i​n​g​s + * E​x​t​e​r​n​a​l​ ​O​p​e​n​I​D​ ​g​e​n​e​r​a​l​ ​s​e​t​t​i​n​g​s */ title: string /** @@ -2436,7 +2436,7 @@ type RootTranslation = { */ title: string /** - * D​i​r​e​c​t​o​r​y​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​ ​a​l​l​o​w​s​ ​y​o​u​ ​t​o​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​ ​s​y​n​c​h​r​o​n​i​z​e​ ​u​s​e​r​s​,​ ​g​r​o​u​p​s​,​ ​a​n​d​ ​t​h​e​i​r​ ​s​t​a​t​u​s​ ​f​r​o​m​ ​a​n​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​. + * D​i​r​e​c​t​o​r​y​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​ ​a​l​l​o​w​s​ ​y​o​u​ ​t​o​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​ ​s​y​n​c​h​r​o​n​i​z​e​ ​u​s​e​r​s​'​ ​s​t​a​t​u​s​ ​a​n​d​ ​g​r​o​u​p​s​ ​f​r​o​m​ ​a​n​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​. */ helper: string /** @@ -2444,6 +2444,36 @@ type RootTranslation = { */ notSupported: string } + selects: { + synchronize: { + /** + * A​l​l + */ + all: string + /** + * U​s​e​r​s + */ + users: string + /** + * G​r​o​u​p​s + */ + groups: string + } + behavior: { + /** + * K​e​e​p + */ + keep: string + /** + * D​i​s​a​b​l​e + */ + disable: string + /** + * D​e​l​e​t​e + */ + 'delete': string + } + } labels: { provider: { /** @@ -2501,6 +2531,16 @@ type RootTranslation = { */ label: string } + sync_target: { + /** + * S​y​n​c​h​r​o​n​i​z​e + */ + label: string + /** + * W​h​a​t​ ​t​o​ ​s​y​n​c​h​r​o​n​i​z​e​ ​f​r​o​m​ ​t​h​e​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​.​ ​Y​o​u​ ​c​a​n​ ​c​h​o​o​s​e​ ​b​e​t​w​e​e​n​ ​s​y​n​c​h​r​o​n​i​z​i​n​g​ ​b​o​t​h​ ​u​s​e​r​s​'​ ​s​t​a​t​e​ ​a​n​d​ ​g​r​o​u​p​ ​m​e​m​b​e​r​s​h​i​p​s​,​ ​o​r​ ​n​a​r​r​o​w​ ​i​t​ ​d​o​w​n​ ​t​o​ ​j​u​s​t​ ​o​n​e​ ​o​f​ ​t​h​e​s​e​. + */ + helper: string + } sync_interval: { /** * S​y​n​c​h​r​o​n​i​z​a​t​i​o​n​ ​i​n​t​e​r​v​a​l @@ -2543,7 +2583,7 @@ type RootTranslation = { } service_account_used: { /** - * S​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​u​s​e​d + * S​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​i​n​ ​u​s​e */ label: string /** @@ -6792,7 +6832,7 @@ export type TranslationFunctions = { openIdSettings: { general: { /** - * External OpenID Settings + * External OpenID general settings */ title: () => LocalizedString /** @@ -6837,7 +6877,7 @@ export type TranslationFunctions = { */ title: () => LocalizedString /** - * Directory synchronization allows you to automatically synchronize users, groups, and their status from an external provider. + * Directory synchronization allows you to automatically synchronize users' status and groups from an external provider. */ helper: () => LocalizedString /** @@ -6845,6 +6885,36 @@ export type TranslationFunctions = { */ notSupported: () => LocalizedString } + selects: { + synchronize: { + /** + * All + */ + all: () => LocalizedString + /** + * Users + */ + users: () => LocalizedString + /** + * Groups + */ + groups: () => LocalizedString + } + behavior: { + /** + * Keep + */ + keep: () => LocalizedString + /** + * Disable + */ + disable: () => LocalizedString + /** + * Delete + */ + 'delete': () => LocalizedString + } + } labels: { provider: { /** @@ -6902,6 +6972,16 @@ export type TranslationFunctions = { */ label: () => LocalizedString } + sync_target: { + /** + * Synchronize + */ + label: () => LocalizedString + /** + * What to synchronize from the external provider. You can choose between synchronizing both users' state and group memberships, or narrow it down to just one of these. + */ + helper: () => LocalizedString + } sync_interval: { /** * Synchronization interval @@ -6944,7 +7024,7 @@ export type TranslationFunctions = { } service_account_used: { /** - * Service account used + * Service account in use */ label: () => LocalizedString /** diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 1f239406e..e77c9f794 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -952,7 +952,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, openIdSettings: { general: { - title: 'Ustawienia zewnętrznego OpenID', + title: 'Ogólne ustawienia zewnętrznego OpenID', helper: 'Możesz tu zmienić ogólną mechanikę działania zewnętrznego OpenID w twojej instancji Defguarda.', createAccount: { @@ -976,6 +976,18 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe 'Synchronizacja katalogu pozwala na automatyczną synchronizację grup użytkowników i ich statusu na podstawie zewnętrznego dostawcy.', notSupported: 'Synchronizacja katalogu nie jest obsługiwana dla tego dostawcy.', }, + selects: { + synchronize: { + all: 'Wszystko', + users: 'Użytkownicy', + groups: 'Grupy', + }, + behavior: { + keep: 'Zachowaj', + disable: 'Dezaktywuj', + delete: 'Usuń', + }, + }, labels: { provider: { label: 'Dostawca', @@ -1003,11 +1015,15 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe enable_directory_sync: { label: 'Włącz synchronizację katalogu', }, + sync_target: { + label: 'Synchronizuj', + helper: + 'Co będzie synchronizowane z zewnętrznym dostawcą OpenID. Możesz wybrać pomiędzy synchronizacją statusu użytkowników, ich przynależności do grup lub synchronizacją obu.', + }, sync_interval: { label: 'Interwał synchronizacji', helper: 'Odstęp czasu w sekundach pomiędzy synchronizacjami katalogu.', }, - user_behavior: { label: 'Zachowanie kont użytkowników', helper: diff --git a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx index dc5c44bc1..ccf4a1e93 100644 --- a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx @@ -8,8 +8,7 @@ import { BigInfoBox } from '../../../../shared/defguard-ui/components/Layout/Big import { LoaderSpinner } from '../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; import useApi from '../../../../shared/hooks/useApi'; import { QueryKeys } from '../../../../shared/queries'; -import { OpenIdGeneralSettings } from './components/OpenIdGeneralSettings'; -import { OpenIdSettingsForm } from './components/OpenIdSettingsForm'; +import { OpenIdSettingsRootForm } from './components/OpenIdSettingsRootForm'; export const OpenIdSettings = () => { const { LL } = useI18nContext(); @@ -56,12 +55,7 @@ export const OpenIdSettings = () => { /> )} -
- -
-
- -
+ ); }; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx new file mode 100644 index 000000000..ffb5bd963 --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx @@ -0,0 +1,232 @@ +import './style.scss'; + +import parse from 'html-react-parser'; +import { useMemo, useState } from 'react'; +import { useController, UseFormReturn } from 'react-hook-form'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { FormCheckBox } from '../../../../../shared/defguard-ui/components/Form/FormCheckBox/FormCheckBox'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { FormSelect } from '../../../../../shared/defguard-ui/components/Form/FormSelect/FormSelect'; +import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import SvgIconDownload from '../../../../../shared/defguard-ui/components/svg/IconDownload'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import { OpenIdProvider } from '../../../../../shared/types'; +import { titleCase } from '../../../../../shared/utils/titleCase'; + +type FormFields = OpenIdProvider; + +const SUPPORTED_SYNC_PROVIDERS = ['Google']; + +export const DirsyncSettings = ({ + currentProvider, + formControl, +}: { + currentProvider: OpenIdProvider | null; + formControl: UseFormReturn; +}) => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const enterpriseEnabled = useAppStore((state) => state.enterprise_status?.enabled); + const [googleServiceAccountFileName, setGoogleServiceAccountFileName] = useState< + string | null + >(null); + + const { control, setValue } = formControl; + + const userBehaviorOptions = useMemo( + () => [ + { + value: 'keep', + label: localLL.form.selects.behavior.keep(), + key: 1, + }, + { + value: 'disable', + label: localLL.form.selects.behavior.disable(), + key: 2, + }, + { + value: 'delete', + label: localLL.form.selects.behavior.delete(), + key: 3, + }, + ], + [localLL.form.selects.behavior], + ); + + const syncTarget = useMemo( + () => [ + { + value: 'all', + label: localLL.form.selects.synchronize.all(), + key: 1, + }, + { + value: 'users', + label: localLL.form.selects.synchronize.users(), + key: 2, + }, + { + value: 'groups', + label: localLL.form.selects.synchronize.groups(), + key: 3, + }, + ], + [localLL.form.selects.synchronize], + ); + + const { + field: { value: enabled }, + } = useController({ control, name: 'directory_sync_enabled' }); + + return ( +
+
+

{localLL.form.directory_sync_settings.title()}

+ {localLL.form.directory_sync_settings.helper()} +
+
+ {SUPPORTED_SYNC_PROVIDERS.includes(currentProvider?.name ?? '') ? ( + currentProvider?.name === 'Google' ? ( + <> +
+ +
+ ({ + key: val, + displayValue: titleCase(val), + })} + labelExtras={ + {parse(localLL.form.labels.sync_target.helper())} + } + disabled={!enabled || !enterpriseEnabled} + /> + {parse(localLL.form.labels.sync_interval.helper())} + } + disabled={!enabled || !enterpriseEnabled} + /> + ({ + key: val, + displayValue: titleCase(val), + })} + labelExtras={ + {parse(localLL.form.labels.user_behavior.helper())} + } + disabled={!enabled || !enterpriseEnabled} + /> + ({ + key: val, + displayValue: titleCase(val), + })} + labelExtras={ + {parse(localLL.form.labels.admin_behavior.helper())} + } + disabled={!enabled || !enterpriseEnabled} + /> + {parse(localLL.form.labels.admin_email.helper())} + } + required={enabled} + /> +
+ +
+ + {parse(localLL.form.labels.service_account_used.helper())} + + } + disabled={!enabled || !enterpriseEnabled} + required={enabled} + /> +
+
+ + {localLL.form.labels.service_account_key_file.helper()} +
+
+ { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const key = JSON.parse(e.target?.result as string); + setValue('google_service_account_key', key.private_key); + setValue('google_service_account_email', key.client_email); + setGoogleServiceAccountFileName(file.name); + }; + reader.readAsText(file); + } + }} + disabled={!enabled || !enterpriseEnabled} + /> +
+ {' '} +

+ {googleServiceAccountFileName + ? `${localLL.form.labels.service_account_key_file.uploaded()}: ${googleServiceAccountFileName}` + : localLL.form.labels.service_account_key_file.uploadPrompt()} +

+
+
+
+ + ) : null + ) : ( +

+ {localLL.form.directory_sync_settings.notSupported()} +

+ )} +
+
+ ); +}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx new file mode 100644 index 000000000..14db2d563 --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx @@ -0,0 +1,175 @@ +import './style.scss'; + +import parse from 'html-react-parser'; +import { useCallback, useMemo } from 'react'; +import { UseFormReturn } from 'react-hook-form'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import { Select } from '../../../../../shared/defguard-ui/components/Layout/Select/Select'; +import { + SelectOption, + SelectSelectedValue, + SelectSizeVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Select/types'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import { OpenIdProvider } from '../../../../../shared/types'; + +export const OpenIdSettingsForm = ({ + setCurrentProvider, + currentProvider, + formControl, +}: { + setCurrentProvider: (provider: OpenIdProvider | null) => void; + currentProvider: OpenIdProvider | null; + formControl: UseFormReturn; +}) => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const enterpriseEnabled = useAppStore((state) => state.enterprise_status?.enabled); + const { control } = formControl; + + const options: SelectOption[] = useMemo( + () => [ + { + value: 'Google', + label: 'Google', + key: 1, + }, + { + value: 'Microsoft', + label: 'Microsoft', + key: 2, + }, + { + value: 'Custom', + label: localLL.form.custom(), + key: 3, + }, + ], + [localLL.form], + ); + + const renderSelected = useCallback( + (selected: string): SelectSelectedValue => { + const option = options.find((o) => o.value === selected); + + if (!option) throw Error("Selected value doesn't exist"); + + return { + key: option.key, + displayValue: option.label, + }; + }, + [options], + ); + + const getProviderUrl = useCallback(({ name }: { name: string }): string | null => { + switch (name) { + case 'Google': + return 'https://accounts.google.com'; + case 'Microsoft': + return `https://login.microsoftonline.com//v2.0`; + default: + return null; + } + }, []); + + const getProviderDisplayName = useCallback( + ({ name }: { name: string }): string | null => { + switch (name) { + case 'Google': + return 'Google'; + case 'Microsoft': + return 'Microsoft'; + default: + return null; + } + }, + [], + ); + + const handleChange = useCallback( + (val: string) => { + setCurrentProvider({ + ...currentProvider, + id: currentProvider?.id ?? 0, + name: val, + base_url: getProviderUrl({ name: val }) ?? '', + client_id: currentProvider?.client_id ?? '', + client_secret: currentProvider?.client_secret ?? '', + display_name: + getProviderDisplayName({ name: val }) ?? currentProvider?.display_name ?? '', + google_service_account_email: currentProvider?.google_service_account_email ?? '', + google_service_account_key: currentProvider?.google_service_account_key ?? '', + admin_email: currentProvider?.admin_email ?? '', + directory_sync_enabled: currentProvider?.directory_sync_enabled ?? false, + directory_sync_interval: currentProvider?.directory_sync_interval ?? 600, + directory_sync_user_behavior: + currentProvider?.directory_sync_user_behavior ?? 'keep', + directory_sync_admin_behavior: + currentProvider?.directory_sync_admin_behavior ?? 'keep', + directory_sync_target: currentProvider?.directory_sync_target ?? 'all', + }); + }, + [currentProvider, getProviderUrl, getProviderDisplayName, setCurrentProvider], + ); + + return ( +
+
+

{localLL.form.title()}

+ {parse(localLL.form.helper())} +
+ handleChange(res)} - label={localLL.form.labels.provider.label()} - labelExtras={{parse(localLL.form.labels.provider.helper())}} - disabled={!enterpriseEnabled} - /> - {parse(localLL.form.labels.base_url.helper())}} - disabled={currentProvider?.name === 'Google' || !enterpriseEnabled} - required - /> - {parse(localLL.form.labels.client_id.helper())}} - disabled={!enterpriseEnabled} - required - /> - {parse(localLL.form.labels.client_secret.helper())} - } - required - type="password" - disabled={!enterpriseEnabled} - /> - {parse(localLL.form.labels.display_name.helper())} - } - disabled={!enterpriseEnabled || currentProvider?.name !== 'Custom'} - /> -
-

{localLL.form.directory_sync_settings.title()}

- {localLL.form.directory_sync_settings.helper()} -
-
- {SUPPORTED_SYNC_PROVIDERS.includes(currentProvider?.name ?? '') ? ( - currentProvider?.name === 'Google' ? ( - <> -
- -
- {parse(localLL.form.labels.sync_interval.helper())} - } - /> - ({ - key: val, - displayValue: titleCase(val), - })} - labelExtras={ - {parse(localLL.form.labels.user_behavior.helper())} - } - /> - ({ - key: val, - displayValue: titleCase(val), - })} - labelExtras={ - {parse(localLL.form.labels.admin_behavior.helper())} - } - /> - {parse(localLL.form.labels.admin_email.helper())} - } - /> -
- -
- - {parse(localLL.form.labels.service_account_used.helper())} - - } - /> -
-
- - - {localLL.form.labels.service_account_key_file.helper()} - -
-
- { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const key = JSON.parse(e.target?.result as string); - setValue('google_service_account_key', key.private_key); - setValue('google_service_account_email', key.client_email); - setGoogleServiceAccountFileName(file.name); - }; - reader.readAsText(file); - } - }} - /> -
- {' '} -

- {googleServiceAccountFileName - ? `${localLL.form.labels.service_account_key_file.uploaded()}: ${googleServiceAccountFileName}` - : localLL.form.labels.service_account_key_file.uploadPrompt()} -

-
-
-
- - ) : null - ) : ( -

- {localLL.form.directory_sync_settings.notSupported()} -

- )} -
- - - {localLL.form.documentation()} - -
- ); -}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx new file mode 100644 index 000000000..3fd5fa8fc --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx @@ -0,0 +1,218 @@ +import './style.scss'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import IconCheckmarkWhite from '../../../../../shared/components/svg/IconCheckmarkWhite'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { LoaderSpinner } from '../../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; +import { OpenIdProvider } from '../../../../../shared/types'; +import { DirsyncSettings } from './DirectorySyncSettings'; +import { OpenIdGeneralSettings } from './OpenIdGeneralSettings'; +import { OpenIdSettingsForm } from './OpenIdProviderSettings'; + +type FormFields = OpenIdProvider; + +export const OpenIdSettingsRootForm = () => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const [currentProvider, setCurrentProvider] = useState(null); + const queryClient = useQueryClient(); + const enterpriseEnabled = useAppStore((state) => state.enterprise_status?.enabled); + + const { + settings: { fetchOpenIdProviders, addOpenIdProvider, deleteOpenIdProvider }, + } = useApi(); + + const { isLoading } = useQuery({ + queryFn: fetchOpenIdProviders, + queryKey: [QueryKeys.FETCH_OPENID_PROVIDERS], + refetchOnMount: true, + refetchOnWindowFocus: false, + onSuccess: (provider) => { + setCurrentProvider(provider); + }, + retry: false, + enabled: enterpriseEnabled, + }); + + const toaster = useToaster(); + + const { mutate } = useMutation({ + mutationFn: addOpenIdProvider, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + onError: (error) => { + toaster.error(LL.messages.error()); + console.error(error); + }, + }); + + const { mutate: deleteProvider } = useMutation({ + mutationFn: deleteOpenIdProvider, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + onError: (error) => { + toaster.error(LL.messages.error()); + console.error(error); + }, + }); + + const schema = useMemo( + () => + z.object({ + name: z.string().min(1, LL.form.error.required()), + base_url: z + .string() + .url(LL.form.error.invalid()) + .min(1, LL.form.error.required()), + client_id: z.string().min(1, LL.form.error.required()), + client_secret: z.string().min(1, LL.form.error.required()), + display_name: z.string(), + admin_email: z.string(), + google_service_account_email: z.string(), + google_service_account_key: z.string(), + directory_sync_enabled: z.boolean(), + directory_sync_interval: z.number().min(60, LL.form.error.invalid()), + directory_sync_user_behavior: z.string(), + directory_sync_admin_behavior: z.string(), + directory_sync_target: z.string(), + }), + [LL.form.error], + ); + + const defaultValues = useMemo( + (): FormFields => ({ + id: currentProvider?.id ?? 0, + name: currentProvider?.name ?? '', + base_url: currentProvider?.base_url ?? '', + client_id: currentProvider?.client_id ?? '', + client_secret: currentProvider?.client_secret ?? '', + display_name: currentProvider?.display_name ?? '', + admin_email: currentProvider?.admin_email ?? '', + google_service_account_email: currentProvider?.google_service_account_email ?? '', + google_service_account_key: currentProvider?.google_service_account_key ?? '', + directory_sync_enabled: currentProvider?.directory_sync_enabled ?? false, + directory_sync_interval: currentProvider?.directory_sync_interval ?? 600, + directory_sync_user_behavior: + currentProvider?.directory_sync_user_behavior ?? 'keep', + directory_sync_admin_behavior: + currentProvider?.directory_sync_admin_behavior ?? 'keep', + directory_sync_target: currentProvider?.directory_sync_target ?? 'all', + }), + [currentProvider], + ); + + const formControl = useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: 'all', + }); + + const { handleSubmit, reset } = formControl; + + // Make sure the form data is fresh + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const conditionallyRequired: (keyof OpenIdProvider)[] = [ + 'admin_email', + 'google_service_account_email', + ]; + + const handleValidSubmit: SubmitHandler = (data) => { + // Some fields are required only if directory sync is enabled. + // Check if the required fields are filled in. + const formValues = formControl.getValues(); + const dirsync_enabled = formValues.directory_sync_enabled; + if (dirsync_enabled) { + const missingRequiredFields = conditionallyRequired.filter( + (field) => + formValues[field]?.toString().length === 0 || formValues[field] === null, + ); + if (missingRequiredFields.length) { + for (const field of missingRequiredFields) { + formControl.setError(field, { + type: 'required', + message: LL.form.error.required(), + }); + } + return; + } + } + mutate(data); + }; + + const handleDeleteProvider = useCallback(() => { + if (currentProvider) { + deleteProvider(currentProvider.name); + setCurrentProvider(null); + } + }, [currentProvider, deleteProvider]); + + return ( +
+
+
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+ +
+
+ + +
+ + )} +
+ ); +}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/style.scss b/web/src/pages/settings/components/OpenIdSettings/components/style.scss index b77cd29e4..b03096eac 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/style.scss +++ b/web/src/pages/settings/components/OpenIdSettings/components/style.scss @@ -1,6 +1,7 @@ @use '@scssutils' as *; -#openid-settings { +#openid-settings, +#dirsync-settings { & > header { & > .btn { margin-left: auto; @@ -50,6 +51,10 @@ position: relative; padding: 20px; z-index: 0; + + .disabled { + cursor: not-allowed; + } } .file-upload-container.dragging { @@ -67,16 +72,16 @@ cursor: pointer; } - .select { - padding-bottom: 0; + .select-container { + margin-bottom: 0; } - #dirsync-header { - margin-bottom: 10px; - display: flex; - align-items: center; - gap: 10px; - } + // #dirsync-header { + // margin-bottom: 10px; + // display: flex; + // align-items: center; + // gap: 10px; + // } .upload-label { display: flex; @@ -100,3 +105,48 @@ margin-bottom: 25px; } } + +#root-form { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + grid-column: 1 / -1; + + & > .controls { + grid-column: 1 / -1; + display: flex; + gap: 10px; + justify-content: flex-end; + } + + & > .left { + grid-column: 1; + } + + & > .right { + grid-column: 2; + } + + & > .left, + & > .right { + width: 100%; + max-width: 750px; + display: flex; + flex-flow: column; + row-gap: 48px; + } +} + +.loader { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + grid-column: 1 / -1; + min-height: 300px; +} + +.disabled { + pointer-events: none; + opacity: 0.5; +} diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index ad1f34408..95acffb5b 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit ad1f34408f449f88e08c2fd8128d78b38ca9e46e +Subproject commit 95acffb5bd8e12e836f75f8e659b0c3c9a9bea69 diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 418f3716f..9ac096c0f 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -930,6 +930,7 @@ export interface OpenIdProvider { directory_sync_interval: number; directory_sync_user_behavior: 'keep' | 'disable' | 'delete'; directory_sync_admin_behavior: 'keep' | 'disable' | 'delete'; + directory_sync_target: 'all' | 'users' | 'groups'; } export interface EditOpenidClientRequest {