From 4219249e11845bb8869c26e1182fa1d38b1a162a Mon Sep 17 00:00:00 2001
From: BlackDex <black.dex@gmail.com>
Date: Fri, 2 Jun 2023 21:36:15 +0200
Subject: [PATCH 1/3] Add support for Organization token

This is a WIP for adding organization token login support.
It has basic token login and verification support, but that's about it.

This branch is a refresh of the previous version, and will contain code
from a PR based upon my previous branch.
---
 .../down.sql                                  |  0
 .../up.sql                                    |  8 ++
 .../down.sql                                  |  0
 .../up.sql                                    |  8 ++
 .../down.sql                                  |  0
 .../up.sql                                    |  9 +++
 src/api/core/organizations.rs                 | 58 +++++++++++++-
 src/api/identity.rs                           | 56 +++++++++++---
 src/auth.rs                                   | 30 ++++++++
 src/db/models/device.rs                       |  6 +-
 src/db/models/mod.rs                          |  2 +-
 src/db/models/organization.rs                 | 77 ++++++++++++++++++-
 src/db/schemas/mysql/schema.rs                | 12 +++
 src/db/schemas/postgresql/schema.rs           | 12 +++
 src/db/schemas/sqlite/schema.rs               | 12 +++
 15 files changed, 272 insertions(+), 18 deletions(-)
 create mode 100644 migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql
 create mode 100644 migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql
 create mode 100644 migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql
 create mode 100644 migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql
 create mode 100644 migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql
 create mode 100644 migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql

diff --git a/migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql
new file mode 100644
index 00000000..e9e0b739
--- /dev/null
+++ b/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE organization_api_key (
+	uuid			CHAR(36) NOT NULL,
+	org_uuid		CHAR(36) NOT NULL REFERENCES organizations(uuid),
+	atype			INTEGER NOT NULL,
+	api_key			VARCHAR(255) NOT NULL,
+	revision_date	DATETIME NOT NULL,
+	PRIMARY KEY(uuid, org_uuid)
+);
diff --git a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql
new file mode 100644
index 00000000..3c37bb5c
--- /dev/null
+++ b/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE organization_api_key (
+	uuid			CHAR(36) NOT NULL,
+	org_uuid		CHAR(36) NOT NULL REFERENCES organizations(uuid),
+	atype			INTEGER NOT NULL,
+	api_key			VARCHAR(255),
+	revision_date	TIMESTAMP NOT NULL,
+	PRIMARY KEY(uuid, org_uuid)
+);
diff --git a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql
new file mode 100644
index 00000000..986b00d9
--- /dev/null
+++ b/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE organization_api_key (
+	uuid            TEXT NOT NULL,
+    org_uuid	    TEXT NOT NULL,
+    atype           INTEGER NOT NULL,
+    api_key         TEXT NOT NULL,
+	revision_date   DATETIME NOT NULL,
+	PRIMARY KEY(uuid, org_uuid),
+	FOREIGN KEY(org_uuid) REFERENCES organizations(uuid)
+);
diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs
index 3899611c..a3aee5be 100644
--- a/src/api/core/organizations.rs
+++ b/src/api/core/organizations.rs
@@ -93,7 +93,9 @@ pub fn routes() -> Vec<Route> {
         put_reset_password_enrollment,
         get_reset_password_details,
         put_reset_password,
-        get_org_export
+        get_org_export,
+        api_key,
+        rotate_api_key,
     ]
 }
 
@@ -2891,3 +2893,57 @@ async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) -
         }))
     }
 }
+
+async fn _api_key(
+    org_id: String,
+    data: JsonUpcase<PasswordData>,
+    rotate: bool,
+    headers: AdminHeaders,
+    conn: DbConn,
+) -> JsonResult {
+    let data: PasswordData = data.into_inner().data;
+    let user = headers.user;
+
+    // Validate the admin users password
+    if !user.check_valid_password(&data.MasterPasswordHash) {
+        err!("Invalid password")
+    }
+
+    let org_api_key = match OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await {
+        Some(mut org_api_key) => {
+            if rotate {
+                org_api_key.api_key = crate::crypto::generate_api_key();
+                org_api_key.revision_date = chrono::Utc::now().naive_utc();
+                org_api_key.save(&conn).await.expect("Error rotating organization API Key");
+            }
+            org_api_key
+        }
+        None => {
+            let api_key = crate::crypto::generate_api_key();
+            let new_org_api_key = OrganizationApiKey::new(org_id, api_key);
+            new_org_api_key.save(&conn).await.expect("Error creating organization API Key");
+            new_org_api_key
+        }
+    };
+
+    Ok(Json(json!({
+      "ApiKey": org_api_key.api_key,
+      "RevisionDate": crate::util::format_date(&org_api_key.revision_date),
+      "Object": "apiKey",
+    })))
+}
+
+#[post("/organizations/<org_id>/api-key", data = "<data>")]
+async fn api_key(org_id: String, data: JsonUpcase<PasswordData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
+    _api_key(org_id, data, false, headers, conn).await
+}
+
+#[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
+async fn rotate_api_key(
+    org_id: String,
+    data: JsonUpcase<PasswordData>,
+    headers: AdminHeaders,
+    conn: DbConn,
+) -> JsonResult {
+    _api_key(org_id, data, true, headers, conn).await
+}
diff --git a/src/api/identity.rs b/src/api/identity.rs
index cefee692..048ac17d 100644
--- a/src/api/identity.rs
+++ b/src/api/identity.rs
@@ -14,7 +14,7 @@ use crate::{
         core::two_factor::{duo, email, email::EmailTokenData, yubikey},
         ApiResult, EmptyResult, JsonResult, JsonUpcase,
     },
-    auth::{ClientHeaders, ClientIp},
+    auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp},
     db::{models::*, DbConn},
     error::MapResult,
     mail, util, CONFIG,
@@ -276,16 +276,23 @@ async fn _api_key_login(
     conn: &mut DbConn,
     ip: &ClientIp,
 ) -> JsonResult {
-    // Validate scope
-    let scope = data.scope.as_ref().unwrap();
-    if scope != "api" {
-        err!("Scope not supported")
-    }
-    let scope_vec = vec!["api".into()];
-
     // Ratelimit the login
     crate::ratelimit::check_limit_login(&ip.ip)?;
 
+    // Validate scope
+    match data.scope.as_ref().unwrap().as_ref() {
+        "api" => _user_api_key_login(data, user_uuid, conn, ip).await,
+        "api.organization" => _organization_api_key_login(data, conn, ip).await,
+        _ => err!("Scope not supported"),
+    }
+}
+
+async fn _user_api_key_login(
+    data: ConnectData,
+    user_uuid: &mut Option<String>,
+    conn: &mut DbConn,
+    ip: &ClientIp,
+) -> JsonResult {
     // Get the user via the client_id
     let client_id = data.client_id.as_ref().unwrap();
     let client_user_uuid = match client_id.strip_prefix("user.") {
@@ -342,6 +349,7 @@ async fn _api_key_login(
     }
 
     // Common
+    let scope_vec = vec!["api".into()];
     let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
     let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec);
     device.save(conn).await?;
@@ -362,13 +370,43 @@ async fn _api_key_login(
         "KdfMemory": user.client_kdf_memory,
         "KdfParallelism": user.client_kdf_parallelism,
         "ResetMasterPassword": false, // TODO: Same as above
-        "scope": scope,
+        "scope": "api",
         "unofficialServer": true,
     });
 
     Ok(Json(result))
 }
 
+async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
+    // Get the org via the client_id
+    let client_id = data.client_id.as_ref().unwrap();
+    let org_uuid = match client_id.strip_prefix("organization.") {
+        Some(uuid) => uuid,
+        None => err!("Malformed client_id", format!("IP: {}.", ip.ip)),
+    };
+    let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, conn).await {
+        Some(org_api_key) => org_api_key,
+        None => err!("Invalid client_id", format!("IP: {}.", ip.ip)),
+    };
+
+    // Check API key.
+    let client_secret = data.client_secret.as_ref().unwrap();
+    if !org_api_key.check_valid_api_key(client_secret) {
+        err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid))
+    }
+
+    let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid);
+    let access_token = crate::auth::encode_jwt(&claim);
+
+    Ok(Json(json!({
+        "access_token": access_token,
+        "expires_in": 3600,
+        "token_type": "Bearer",
+        "scope": "api.organization",
+        "unofficialServer": true,
+    })))
+}
+
 /// Retrieves an existing device or creates a new device from ConnectData and the User
 async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) {
     // On iOS, device_type sends "iOS", on others it sends a number
diff --git a/src/auth.rs b/src/auth.rs
index 2a6f8a32..d96e98e1 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -23,6 +23,7 @@ static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFI
 static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
 static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
 static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
+static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
 
 static PRIVATE_RSA_KEY: Lazy<EncodingKey> = Lazy::new(|| {
     let key =
@@ -200,6 +201,35 @@ pub fn generate_emergency_access_invite_claims(
     }
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+pub struct OrgApiKeyLoginJwtClaims {
+    // Not before
+    pub nbf: i64,
+    // Expiration time
+    pub exp: i64,
+    // Issuer
+    pub iss: String,
+    // Subject
+    pub sub: String,
+
+    pub client_id: String,
+    pub client_sub: String,
+    pub scope: Vec<String>,
+}
+
+pub fn generate_organization_api_key_login_claims(uuid: String, org_id: String) -> OrgApiKeyLoginJwtClaims {
+    let time_now = Utc::now().naive_utc();
+    OrgApiKeyLoginJwtClaims {
+        nbf: time_now.timestamp(),
+        exp: (time_now + Duration::hours(1)).timestamp(),
+        iss: JWT_ORG_API_KEY_ISSUER.to_string(),
+        sub: uuid,
+        client_id: format!("organization.{org_id}"),
+        client_sub: org_id,
+        scope: vec!["api.organization".into()],
+    }
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub struct BasicJwtClaims {
     // Not before
diff --git a/src/db/models/device.rs b/src/db/models/device.rs
index a1f4aee9..81f12e18 100644
--- a/src/db/models/device.rs
+++ b/src/db/models/device.rs
@@ -1,6 +1,6 @@
 use chrono::{NaiveDateTime, Utc};
 
-use crate::CONFIG;
+use crate::{crypto, CONFIG};
 
 db_object! {
     #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -47,9 +47,7 @@ impl Device {
     }
 
     pub fn refresh_twofactor_remember(&mut self) -> String {
-        use crate::crypto;
         use data_encoding::BASE64;
-
         let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
         self.twofactor_remember = Some(twofactor_remember.clone());
 
@@ -68,9 +66,7 @@ impl Device {
     ) -> (String, i64) {
         // If there is no refresh token, we create one
         if self.refresh_token.is_empty() {
-            use crate::crypto;
             use data_encoding::BASE64URL;
-
             self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL);
         }
 
diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs
index 96dc27ce..6cbde05f 100644
--- a/src/db/models/mod.rs
+++ b/src/db/models/mod.rs
@@ -24,7 +24,7 @@ pub use self::favorite::Favorite;
 pub use self::folder::{Folder, FolderCipher};
 pub use self::group::{CollectionGroup, Group, GroupUser};
 pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType};
-pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization};
+pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization};
 pub use self::send::{Send, SendType};
 pub use self::two_factor::{TwoFactor, TwoFactorType};
 pub use self::two_factor_incomplete::TwoFactorIncomplete;
diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs
index e0b92239..47151a96 100644
--- a/src/db/models/organization.rs
+++ b/src/db/models/organization.rs
@@ -1,3 +1,4 @@
+use chrono::{NaiveDateTime, Utc};
 use num_traits::FromPrimitive;
 use serde_json::Value;
 use std::cmp::Ordering;
@@ -31,6 +32,17 @@ db_object! {
         pub atype: i32,
         pub reset_password_key: Option<String>,
     }
+
+    #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
+    #[diesel(table_name = organization_api_key)]
+    #[diesel(primary_key(uuid, org_uuid))]
+    pub struct OrganizationApiKey {
+        pub uuid: String,
+        pub org_uuid: String,
+        pub atype: i32,
+        pub api_key: String,
+        pub revision_date: NaiveDateTime,
+    }
 }
 
 // https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs
@@ -157,7 +169,7 @@ impl Organization {
             "UseSso": false, // Not supported
             // "UseKeyConnector": false, // Not supported
             "SelfHost": true,
-            "UseApi": false, // Not supported
+            "UseApi": true,
             "HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
             "UseResetPassword": CONFIG.mail_enabled(),
 
@@ -212,6 +224,23 @@ impl UserOrganization {
     }
 }
 
+impl OrganizationApiKey {
+    pub fn new(org_uuid: String, api_key: String) -> Self {
+        Self {
+            uuid: crate::util::get_uuid(),
+
+            org_uuid,
+            atype: 0, // Type 0 is the default and only type we support currently
+            api_key,
+            revision_date: Utc::now().naive_utc(),
+        }
+    }
+
+    pub fn check_valid_api_key(&self, api_key: &str) -> bool {
+        crate::crypto::ct_eq(&self.api_key, api_key)
+    }
+}
+
 use crate::db::DbConn;
 
 use crate::api::EmptyResult;
@@ -311,7 +340,7 @@ impl UserOrganization {
             "UseTotp": true,
             // "UseScim": false, // Not supported (Not AGPLv3 Licensed)
             "UsePolicies": true,
-            "UseApi": false, // Not supported
+            "UseApi": true,
             "SelfHost": true,
             "HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(),
             "ResetPasswordEnrolled": self.reset_password_key.is_some(),
@@ -750,6 +779,50 @@ impl UserOrganization {
     }
 }
 
+impl OrganizationApiKey {
+    pub async fn save(&self, conn: &DbConn) -> EmptyResult {
+        db_run! { conn:
+            sqlite, mysql {
+                match diesel::replace_into(organization_api_key::table)
+                    .values(OrganizationApiKeyDb::to_db(self))
+                    .execute(conn)
+                {
+                    Ok(_) => Ok(()),
+                    // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
+                    Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
+                        diesel::update(organization_api_key::table)
+                            .filter(organization_api_key::uuid.eq(&self.uuid))
+                            .set(OrganizationApiKeyDb::to_db(self))
+                            .execute(conn)
+                            .map_res("Error saving organization")
+                    }
+                    Err(e) => Err(e.into()),
+                }.map_res("Error saving organization")
+
+            }
+            postgresql {
+                let value = OrganizationApiKeyDb::to_db(self);
+                diesel::insert_into(organization_api_key::table)
+                    .values(&value)
+                    .on_conflict(organization_api_key::uuid)
+                    .do_update()
+                    .set(&value)
+                    .execute(conn)
+                    .map_res("Error saving organization")
+            }
+        }
+    }
+
+    pub async fn find_by_org_uuid(org_uuid: &str, conn: &DbConn) -> Option<Self> {
+        db_run! { conn: {
+            organization_api_key::table
+                .filter(organization_api_key::org_uuid.eq(org_uuid))
+                .first::<OrganizationApiKeyDb>(conn)
+                .ok().from_db()
+        }}
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs
index 315f4953..4d58f63f 100644
--- a/src/db/schemas/mysql/schema.rs
+++ b/src/db/schemas/mysql/schema.rs
@@ -229,6 +229,16 @@ table! {
     }
 }
 
+table! {
+    organization_api_key (uuid, org_uuid) {
+        uuid -> Text,
+        org_uuid -> Text,
+        atype -> Integer,
+        api_key -> Text,
+        revision_date -> Timestamp,
+    }
+}
+
 table! {
     emergency_access (uuid) {
         uuid -> Text,
@@ -292,6 +302,7 @@ joinable!(users_collections -> collections (collection_uuid));
 joinable!(users_collections -> users (user_uuid));
 joinable!(users_organizations -> organizations (org_uuid));
 joinable!(users_organizations -> users (user_uuid));
+joinable!(organization_api_key -> organizations (org_uuid));
 joinable!(emergency_access -> users (grantor_uuid));
 joinable!(groups -> organizations (organizations_uuid));
 joinable!(groups_users -> users_organizations (users_organizations_uuid));
@@ -316,6 +327,7 @@ allow_tables_to_appear_in_same_query!(
     users,
     users_collections,
     users_organizations,
+    organization_api_key,
     emergency_access,
     groups,
     groups_users,
diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs
index c42e8d18..941f51cb 100644
--- a/src/db/schemas/postgresql/schema.rs
+++ b/src/db/schemas/postgresql/schema.rs
@@ -229,6 +229,16 @@ table! {
     }
 }
 
+table! {
+    organization_api_key (uuid, org_uuid) {
+        uuid -> Text,
+        org_uuid -> Text,
+        atype -> Integer,
+        api_key -> Text,
+        revision_date -> Timestamp,
+    }
+}
+
 table! {
     emergency_access (uuid) {
         uuid -> Text,
@@ -292,6 +302,7 @@ joinable!(users_collections -> collections (collection_uuid));
 joinable!(users_collections -> users (user_uuid));
 joinable!(users_organizations -> organizations (org_uuid));
 joinable!(users_organizations -> users (user_uuid));
+joinable!(organization_api_key -> organizations (org_uuid));
 joinable!(emergency_access -> users (grantor_uuid));
 joinable!(groups -> organizations (organizations_uuid));
 joinable!(groups_users -> users_organizations (users_organizations_uuid));
@@ -316,6 +327,7 @@ allow_tables_to_appear_in_same_query!(
     users,
     users_collections,
     users_organizations,
+    organization_api_key,
     emergency_access,
     groups,
     groups_users,
diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs
index 402f0a22..ce9c7e33 100644
--- a/src/db/schemas/sqlite/schema.rs
+++ b/src/db/schemas/sqlite/schema.rs
@@ -229,6 +229,16 @@ table! {
     }
 }
 
+table! {
+    organization_api_key (uuid, org_uuid) {
+        uuid -> Text,
+        org_uuid -> Text,
+        atype -> Integer,
+        api_key -> Text,
+        revision_date -> Timestamp,
+    }
+}
+
 table! {
     emergency_access (uuid) {
         uuid -> Text,
@@ -293,6 +303,7 @@ joinable!(users_collections -> users (user_uuid));
 joinable!(users_organizations -> organizations (org_uuid));
 joinable!(users_organizations -> users (user_uuid));
 joinable!(users_organizations -> ciphers (org_uuid));
+joinable!(organization_api_key -> organizations (org_uuid));
 joinable!(emergency_access -> users (grantor_uuid));
 joinable!(groups -> organizations (organizations_uuid));
 joinable!(groups_users -> users_organizations (users_organizations_uuid));
@@ -317,6 +328,7 @@ allow_tables_to_appear_in_same_query!(
     users,
     users_collections,
     users_organizations,
+    organization_api_key,
     emergency_access,
     groups,
     groups_users,

From 8e34495e73f280e1c62f0f1b63e0219225f001d7 Mon Sep 17 00:00:00 2001
From: BlackDex <black.dex@gmail.com>
Date: Fri, 2 Jun 2023 22:28:30 +0200
Subject: [PATCH 2/3] Merge and modify PR from @Kurnihil

Merging a PR from @Kurnihil into the already rebased branch.
Made some small changes to make it work with newer changes.

Some finetuning is probably still needed.

Co-authored-by: Daniele Andrei <daniele.andrei@geo-satis.com>
Co-authored-by: Kurnihil
---
 .../down.sql                                  |   0
 .../up.sql                                    |   2 +
 .../down.sql                                  |   0
 .../up.sql                                    |   2 +
 .../down.sql                                  |   0
 .../up.sql                                    |   2 +
 src/api/core/mod.rs                           |   2 +
 src/api/core/organizations.rs                 |   2 +-
 src/api/core/public.rs                        | 231 ++++++++++++++++++
 src/auth.rs                                   |   4 +
 src/db/models/group.rs                        |  15 +-
 src/db/models/organization.rs                 |   2 +-
 src/db/models/user.rs                         |  24 ++
 src/db/schemas/mysql/schema.rs                |   1 +
 src/db/schemas/postgresql/schema.rs           |   1 +
 src/db/schemas/sqlite/schema.rs               |   1 +
 16 files changed, 282 insertions(+), 7 deletions(-)
 rename migrations/mysql/{2022-07-21-200424_create_organization_api_key => 2023-06-02-200424_create_organization_api_key}/down.sql (100%)
 rename migrations/mysql/{2022-07-21-200424_create_organization_api_key => 2023-06-02-200424_create_organization_api_key}/up.sql (83%)
 rename migrations/postgresql/{2022-07-21-200424_create_organization_api_key => 2023-06-02-200424_create_organization_api_key}/down.sql (100%)
 rename migrations/postgresql/{2022-07-21-200424_create_organization_api_key => 2023-06-02-200424_create_organization_api_key}/up.sql (83%)
 rename migrations/sqlite/{2022-07-21-200424_create_organization_api_key => 2023-06-02-200424_create_organization_api_key}/down.sql (100%)
 rename migrations/sqlite/{2022-07-21-200424_create_organization_api_key => 2023-06-02-200424_create_organization_api_key}/up.sql (86%)
 create mode 100644 src/api/core/public.rs

diff --git a/migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/mysql/2023-06-02-200424_create_organization_api_key/down.sql
similarity index 100%
rename from migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql
rename to migrations/mysql/2023-06-02-200424_create_organization_api_key/down.sql
diff --git a/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/mysql/2023-06-02-200424_create_organization_api_key/up.sql
similarity index 83%
rename from migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql
rename to migrations/mysql/2023-06-02-200424_create_organization_api_key/up.sql
index e9e0b739..6c4f5cb6 100644
--- a/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql
+++ b/migrations/mysql/2023-06-02-200424_create_organization_api_key/up.sql
@@ -6,3 +6,5 @@ CREATE TABLE organization_api_key (
 	revision_date	DATETIME NOT NULL,
 	PRIMARY KEY(uuid, org_uuid)
 );
+
+ALTER TABLE users ADD COLUMN external_id TEXT;
diff --git a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/postgresql/2023-06-02-200424_create_organization_api_key/down.sql
similarity index 100%
rename from migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql
rename to migrations/postgresql/2023-06-02-200424_create_organization_api_key/down.sql
diff --git a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/postgresql/2023-06-02-200424_create_organization_api_key/up.sql
similarity index 83%
rename from migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql
rename to migrations/postgresql/2023-06-02-200424_create_organization_api_key/up.sql
index 3c37bb5c..9c3ba41c 100644
--- a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql
+++ b/migrations/postgresql/2023-06-02-200424_create_organization_api_key/up.sql
@@ -6,3 +6,5 @@ CREATE TABLE organization_api_key (
 	revision_date	TIMESTAMP NOT NULL,
 	PRIMARY KEY(uuid, org_uuid)
 );
+
+ALTER TABLE users ADD COLUMN external_id TEXT;
diff --git a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/sqlite/2023-06-02-200424_create_organization_api_key/down.sql
similarity index 100%
rename from migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql
rename to migrations/sqlite/2023-06-02-200424_create_organization_api_key/down.sql
diff --git a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/sqlite/2023-06-02-200424_create_organization_api_key/up.sql
similarity index 86%
rename from migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql
rename to migrations/sqlite/2023-06-02-200424_create_organization_api_key/up.sql
index 986b00d9..2880bb22 100644
--- a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql
+++ b/migrations/sqlite/2023-06-02-200424_create_organization_api_key/up.sql
@@ -7,3 +7,5 @@ CREATE TABLE organization_api_key (
 	PRIMARY KEY(uuid, org_uuid),
 	FOREIGN KEY(org_uuid) REFERENCES organizations(uuid)
 );
+
+ALTER TABLE users ADD COLUMN external_id TEXT;
diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs
index 00cfcf85..f7e912cf 100644
--- a/src/api/core/mod.rs
+++ b/src/api/core/mod.rs
@@ -4,6 +4,7 @@ mod emergency_access;
 mod events;
 mod folders;
 mod organizations;
+mod public;
 mod sends;
 pub mod two_factor;
 
@@ -27,6 +28,7 @@ pub fn routes() -> Vec<Route> {
     routes.append(&mut organizations::routes());
     routes.append(&mut two_factor::routes());
     routes.append(&mut sends::routes());
+    routes.append(&mut public::routes());
     routes.append(&mut eq_domains_routes);
     routes.append(&mut hibp_routes);
     routes.append(&mut meta_routes);
diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs
index a3aee5be..a71eb641 100644
--- a/src/api/core/organizations.rs
+++ b/src/api/core/organizations.rs
@@ -2382,7 +2382,7 @@ async fn add_update_group(
         "OrganizationId": group.organizations_uuid,
         "Name": group.name,
         "AccessAll": group.access_all,
-        "ExternalId": group.get_external_id()
+        "ExternalId": group.external_id
     })))
 }
 
diff --git a/src/api/core/public.rs b/src/api/core/public.rs
new file mode 100644
index 00000000..c8689222
--- /dev/null
+++ b/src/api/core/public.rs
@@ -0,0 +1,231 @@
+use chrono::Utc;
+use rocket::{
+    request::{self, FromRequest, Outcome},
+    Request, Route,
+};
+
+use crate::{
+    api::{EmptyResult, JsonUpcase},
+    auth,
+    db::{models::*, DbConn},
+    mail, CONFIG,
+};
+
+pub fn routes() -> Vec<Route> {
+    routes![ldap_import]
+}
+
+#[derive(Deserialize, Debug)]
+#[allow(non_snake_case)]
+struct OrgImportGroupData {
+    Name: String,
+    ExternalId: String,
+    MemberExternalIds: Vec<String>,
+}
+
+#[derive(Deserialize, Debug)]
+#[allow(non_snake_case)]
+struct OrgImportUserData {
+    Email: String,
+    ExternalId: String,
+    Deleted: bool,
+}
+
+#[derive(Deserialize, Debug)]
+#[allow(non_snake_case)]
+struct OrgImportData {
+    Groups: Vec<OrgImportGroupData>,
+    Members: Vec<OrgImportUserData>,
+    OverwriteExisting: bool,
+    #[allow(dead_code)]
+    LargeImport: bool,
+}
+
+#[post("/public/organization/import", data = "<data>")]
+async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut conn: DbConn) -> EmptyResult {
+    let _ = &conn;
+    let org_id = token.0;
+    let data = data.into_inner().data;
+
+    for user_data in &data.Members {
+        if user_data.Deleted {
+            // If user is marked for deletion and it exists, revoke it
+            if let Some(mut user_org) =
+                UserOrganization::find_by_email_and_org(&user_data.Email, &org_id, &mut conn).await
+            {
+                user_org.revoke();
+                user_org.save(&mut conn).await?;
+            }
+
+        // If user is part of the organization, restore it
+        } else if let Some(mut user_org) =
+            UserOrganization::find_by_email_and_org(&user_data.Email, &org_id, &mut conn).await
+        {
+            if user_org.status < UserOrgStatus::Revoked as i32 {
+                user_org.restore();
+                user_org.save(&mut conn).await?;
+            }
+        } else {
+            // If user is not part of the organization
+            let user = match User::find_by_mail(&user_data.Email, &mut conn).await {
+                Some(user) => user, // exists in vaultwarden
+                None => {
+                    // doesn't exist in vaultwarden
+                    let mut new_user = User::new(user_data.Email.clone());
+                    new_user.set_external_id(Some(user_data.ExternalId.clone()));
+                    new_user.save(&mut conn).await?;
+
+                    if !CONFIG.mail_enabled() {
+                        let invitation = Invitation::new(&new_user.email);
+                        invitation.save(&mut conn).await?;
+                    }
+                    new_user
+                }
+            };
+            let user_org_status = if CONFIG.mail_enabled() {
+                UserOrgStatus::Invited as i32
+            } else {
+                UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
+            };
+
+            let mut new_org_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
+            new_org_user.access_all = false;
+            new_org_user.atype = UserOrgType::User as i32;
+            new_org_user.status = user_org_status;
+
+            new_org_user.save(&mut conn).await?;
+
+            if CONFIG.mail_enabled() {
+                let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
+                    Some(org) => (org.name, org.billing_email),
+                    None => err!("Error looking up organization"),
+                };
+
+                mail::send_invite(
+                    &user_data.Email,
+                    &user.uuid,
+                    Some(org_id.clone()),
+                    Some(new_org_user.uuid),
+                    &org_name,
+                    Some(org_email),
+                )
+                .await?;
+            }
+        }
+    }
+
+    for group_data in &data.Groups {
+        let group_uuid = match Group::find_by_external_id(&group_data.ExternalId, &mut conn).await {
+            Some(group) => group.uuid,
+            None => {
+                let mut group =
+                    Group::new(org_id.clone(), group_data.Name.clone(), false, Some(group_data.ExternalId.clone()));
+                group.save(&mut conn).await?;
+                group.uuid
+            }
+        };
+
+        GroupUser::delete_all_by_group(&group_uuid, &mut conn).await?;
+
+        for ext_id in &group_data.MemberExternalIds {
+            if let Some(user) = User::find_by_external_id(ext_id, &mut conn).await {
+                if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &mut conn).await {
+                    let mut group_user = GroupUser::new(group_uuid.clone(), user_org.uuid.clone());
+                    group_user.save(&mut conn).await?;
+                }
+            }
+        }
+    }
+
+    // If this flag is enabled, any user that isn't provided in the Users list will be removed (by default they will be kept unless they have Deleted == true)
+    if data.OverwriteExisting {
+        for user_org in UserOrganization::find_by_org(&org_id, &mut conn).await {
+            if let Some(user_external_id) =
+                User::find_by_uuid(&user_org.user_uuid, &mut conn).await.map(|u| u.external_id)
+            {
+                if user_external_id.is_some()
+                    && !data.Members.iter().any(|u| u.ExternalId == *user_external_id.as_ref().unwrap())
+                {
+                    if user_org.atype == UserOrgType::Owner && user_org.status == UserOrgStatus::Confirmed as i32 {
+                        // Removing owner, check that there is at least one other confirmed owner
+                        if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &mut conn)
+                            .await
+                            <= 1
+                        {
+                            warn!("Can't delete the last owner");
+                            continue;
+                        }
+                    }
+                    user_org.delete(&mut conn).await?;
+                }
+            }
+        }
+    }
+
+    Ok(())
+}
+
+#[derive(Debug)]
+pub struct PublicToken(String);
+
+#[rocket::async_trait]
+impl<'r> FromRequest<'r> for PublicToken {
+    type Error = &'static str;
+
+    async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
+        let headers = request.headers();
+        // Get access_token
+        let access_token: &str = match headers.get_one("Authorization") {
+            Some(a) => match a.rsplit("Bearer ").next() {
+                Some(split) => split,
+                None => err_handler!("No access token provided"),
+            },
+            None => err_handler!("No access token provided"),
+        };
+        // Check JWT token is valid and get device and user from it
+        let claims = match auth::decode_api_org(access_token) {
+            Ok(claims) => claims,
+            Err(_) => err_handler!("Invalid claim"),
+        };
+        // Check if time is between claims.nbf and claims.exp
+        let time_now = Utc::now().naive_utc().timestamp();
+        if time_now < claims.nbf {
+            err_handler!("Token issued in the future");
+        }
+        if time_now > claims.exp {
+            err_handler!("Token expired");
+        }
+        // Check if claims.iss is host|claims.scope[0]
+        let host = match auth::Host::from_request(request).await {
+            Outcome::Success(host) => host,
+            _ => err_handler!("Error getting Host"),
+        };
+        let complete_host = format!("{}|{}", host.host, claims.scope[0]);
+        if complete_host != claims.iss {
+            err_handler!("Token not issued by this server");
+        }
+
+        // Check if claims.sub is org_api_key.uuid
+        // Check if claims.client_sub is org_api_key.org_uuid
+        let conn = match DbConn::from_request(request).await {
+            Outcome::Success(conn) => conn,
+            _ => err_handler!("Error getting DB"),
+        };
+        let org_uuid = match claims.client_id.strip_prefix("organization.") {
+            Some(uuid) => uuid,
+            None => err_handler!("Malformed client_id"),
+        };
+        let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, &conn).await {
+            Some(org_api_key) => org_api_key,
+            None => err_handler!("Invalid client_id"),
+        };
+        if org_api_key.org_uuid != claims.client_sub {
+            err_handler!("Token not issued for this org");
+        }
+        if org_api_key.uuid != claims.sub {
+            err_handler!("Token not issued for this client");
+        }
+
+        Outcome::Success(PublicToken(claims.client_sub))
+    }
+}
diff --git a/src/auth.rs b/src/auth.rs
index d96e98e1..6b01a4d4 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -94,6 +94,10 @@ pub fn decode_send(token: &str) -> Result<BasicJwtClaims, Error> {
     decode_jwt(token, JWT_SEND_ISSUER.to_string())
 }
 
+pub fn decode_api_org(token: &str) -> Result<OrgApiKeyLoginJwtClaims, Error> {
+    decode_jwt(token, JWT_ORG_API_KEY_ISSUER.to_string())
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub struct LoginJwtClaims {
     // Not before
diff --git a/src/db/models/group.rs b/src/db/models/group.rs
index 258b9e42..5bae798d 100644
--- a/src/db/models/group.rs
+++ b/src/db/models/group.rs
@@ -10,7 +10,7 @@ db_object! {
         pub organizations_uuid: String,
         pub name: String,
         pub access_all: bool,
-        external_id: Option<String>,
+        pub external_id: Option<String>,
         pub creation_date: NaiveDateTime,
         pub revision_date: NaiveDateTime,
     }
@@ -107,10 +107,6 @@ impl Group {
             None => self.external_id = None,
         }
     }
-
-    pub fn get_external_id(&self) -> Option<String> {
-        self.external_id.clone()
-    }
 }
 
 impl CollectionGroup {
@@ -214,6 +210,15 @@ impl Group {
         }}
     }
 
+    pub async fn find_by_external_id(id: &str, conn: &mut DbConn) -> Option<Self> {
+        db_run! { conn: {
+            groups::table
+                .filter(groups::external_id.eq(id))
+                .first::<GroupDb>(conn)
+                .ok()
+                .from_db()
+        }}
+    }
     //Returns all organizations the user has full access to
     pub async fn gather_user_organizations_full_access(user_uuid: &str, conn: &mut DbConn) -> Vec<String> {
         db_run! { conn: {
diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs
index 47151a96..5d1f0af2 100644
--- a/src/db/models/organization.rs
+++ b/src/db/models/organization.rs
@@ -510,7 +510,7 @@ impl UserOrganization {
                             .set(UserOrganizationDb::to_db(self))
                             .execute(conn)
                             .map_res("Error adding user to organization")
-                    }
+                    },
                     Err(e) => Err(e.into()),
                 }.map_res("Error adding user to organization")
             }
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index 83a59524..a4764ada 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -50,6 +50,8 @@ db_object! {
         pub api_key: Option<String>,
 
         pub avatar_color: Option<String>,
+
+        pub external_id: Option<String>,
     }
 
     #[derive(Identifiable, Queryable, Insertable)]
@@ -126,6 +128,8 @@ impl User {
             api_key: None,
 
             avatar_color: None,
+
+            external_id: None,
         }
     }
 
@@ -150,6 +154,21 @@ impl User {
         matches!(self.api_key, Some(ref api_key) if crate::crypto::ct_eq(api_key, key))
     }
 
+    pub fn set_external_id(&mut self, external_id: Option<String>) {
+        //Check if external id is empty. We don't want to have
+        //empty strings in the database
+        match external_id {
+            Some(external_id) => {
+                if external_id.is_empty() {
+                    self.external_id = None;
+                } else {
+                    self.external_id = Some(external_id)
+                }
+            }
+            None => self.external_id = None,
+        }
+    }
+
     /// Set the password hash generated
     /// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different.
     ///
@@ -376,6 +395,11 @@ impl User {
         }}
     }
 
+    pub async fn find_by_external_id(id: &str, conn: &mut DbConn) -> Option<Self> {
+        db_run! {conn: {
+            users::table.filter(users::external_id.eq(id)).first::<UserDb>(conn).ok().from_db()
+        }}
+    }
     pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
         db_run! {conn: {
             users::table.load::<UserDb>(conn).expect("Error loading users").from_db()
diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs
index 4d58f63f..37803275 100644
--- a/src/db/schemas/mysql/schema.rs
+++ b/src/db/schemas/mysql/schema.rs
@@ -204,6 +204,7 @@ table! {
         client_kdf_parallelism -> Nullable<Integer>,
         api_key -> Nullable<Text>,
         avatar_color -> Nullable<Text>,
+        external_id -> Nullable<Text>,
     }
 }
 
diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs
index 941f51cb..0b69bbc4 100644
--- a/src/db/schemas/postgresql/schema.rs
+++ b/src/db/schemas/postgresql/schema.rs
@@ -204,6 +204,7 @@ table! {
         client_kdf_parallelism -> Nullable<Integer>,
         api_key -> Nullable<Text>,
         avatar_color -> Nullable<Text>,
+        external_id -> Nullable<Text>,
     }
 }
 
diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs
index ce9c7e33..10dd3fe8 100644
--- a/src/db/schemas/sqlite/schema.rs
+++ b/src/db/schemas/sqlite/schema.rs
@@ -204,6 +204,7 @@ table! {
         client_kdf_parallelism -> Nullable<Integer>,
         api_key -> Nullable<Text>,
         avatar_color -> Nullable<Text>,
+        external_id -> Nullable<Text>,
     }
 }
 

From a05187c0ff8e38b5828c19906a649798b4b650af Mon Sep 17 00:00:00 2001
From: BlackDex <black.dex@gmail.com>
Date: Fri, 9 Jun 2023 22:50:44 +0200
Subject: [PATCH 3/3] Some code changes and optimizations

Some cleanups and optimizations done on the code generated by @Kurnihil
---
 src/api/core/organizations.rs | 10 +++---
 src/api/core/public.rs        | 59 ++++++++++++++++++++---------------
 src/db/models/user.rs         | 14 ++++-----
 3 files changed, 44 insertions(+), 39 deletions(-)

diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs
index a71eb641..c404fbf0 100644
--- a/src/api/core/organizations.rs
+++ b/src/api/core/organizations.rs
@@ -2895,7 +2895,7 @@ async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) -
 }
 
 async fn _api_key(
-    org_id: String,
+    org_id: &str,
     data: JsonUpcase<PasswordData>,
     rotate: bool,
     headers: AdminHeaders,
@@ -2909,7 +2909,7 @@ async fn _api_key(
         err!("Invalid password")
     }
 
-    let org_api_key = match OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await {
+    let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
         Some(mut org_api_key) => {
             if rotate {
                 org_api_key.api_key = crate::crypto::generate_api_key();
@@ -2920,7 +2920,7 @@ async fn _api_key(
         }
         None => {
             let api_key = crate::crypto::generate_api_key();
-            let new_org_api_key = OrganizationApiKey::new(org_id, api_key);
+            let new_org_api_key = OrganizationApiKey::new(String::from(org_id), api_key);
             new_org_api_key.save(&conn).await.expect("Error creating organization API Key");
             new_org_api_key
         }
@@ -2934,13 +2934,13 @@ async fn _api_key(
 }
 
 #[post("/organizations/<org_id>/api-key", data = "<data>")]
-async fn api_key(org_id: String, data: JsonUpcase<PasswordData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
+async fn api_key(org_id: &str, data: JsonUpcase<PasswordData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
     _api_key(org_id, data, false, headers, conn).await
 }
 
 #[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
 async fn rotate_api_key(
-    org_id: String,
+    org_id: &str,
     data: JsonUpcase<PasswordData>,
     headers: AdminHeaders,
     conn: DbConn,
diff --git a/src/api/core/public.rs b/src/api/core/public.rs
index c8689222..b2945918 100644
--- a/src/api/core/public.rs
+++ b/src/api/core/public.rs
@@ -4,6 +4,8 @@ use rocket::{
     Request, Route,
 };
 
+use std::collections::HashSet;
+
 use crate::{
     api::{EmptyResult, JsonUpcase},
     auth,
@@ -15,7 +17,7 @@ pub fn routes() -> Vec<Route> {
     routes![ldap_import]
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize)]
 #[allow(non_snake_case)]
 struct OrgImportGroupData {
     Name: String,
@@ -23,7 +25,7 @@ struct OrgImportGroupData {
     MemberExternalIds: Vec<String>,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize)]
 #[allow(non_snake_case)]
 struct OrgImportUserData {
     Email: String,
@@ -31,19 +33,20 @@ struct OrgImportUserData {
     Deleted: bool,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize)]
 #[allow(non_snake_case)]
 struct OrgImportData {
     Groups: Vec<OrgImportGroupData>,
     Members: Vec<OrgImportUserData>,
     OverwriteExisting: bool,
-    #[allow(dead_code)]
-    LargeImport: bool,
+    // LargeImport: bool, // For now this will not be used, upstream uses this to prevent syncs of more then 2000 users or groups without the flag set.
 }
 
 #[post("/public/organization/import", data = "<data>")]
 async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut conn: DbConn) -> EmptyResult {
-    let _ = &conn;
+    // Most of the logic for this function can be found here
+    // https://github.com/bitwarden/server/blob/fd892b2ff4547648a276734fb2b14a8abae2c6f5/src/Core/Services/Implementations/OrganizationService.cs#L1797
+
     let org_id = token.0;
     let data = data.into_inner().data;
 
@@ -114,38 +117,43 @@ async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut co
         }
     }
 
-    for group_data in &data.Groups {
-        let group_uuid = match Group::find_by_external_id(&group_data.ExternalId, &mut conn).await {
-            Some(group) => group.uuid,
-            None => {
-                let mut group =
-                    Group::new(org_id.clone(), group_data.Name.clone(), false, Some(group_data.ExternalId.clone()));
-                group.save(&mut conn).await?;
-                group.uuid
-            }
-        };
+    if CONFIG.org_groups_enabled() {
+        for group_data in &data.Groups {
+            let group_uuid = match Group::find_by_external_id(&group_data.ExternalId, &mut conn).await {
+                Some(group) => group.uuid,
+                None => {
+                    let mut group =
+                        Group::new(org_id.clone(), group_data.Name.clone(), false, Some(group_data.ExternalId.clone()));
+                    group.save(&mut conn).await?;
+                    group.uuid
+                }
+            };
 
-        GroupUser::delete_all_by_group(&group_uuid, &mut conn).await?;
+            GroupUser::delete_all_by_group(&group_uuid, &mut conn).await?;
 
-        for ext_id in &group_data.MemberExternalIds {
-            if let Some(user) = User::find_by_external_id(ext_id, &mut conn).await {
-                if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &mut conn).await {
-                    let mut group_user = GroupUser::new(group_uuid.clone(), user_org.uuid.clone());
-                    group_user.save(&mut conn).await?;
+            for ext_id in &group_data.MemberExternalIds {
+                if let Some(user) = User::find_by_external_id(ext_id, &mut conn).await {
+                    if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &mut conn).await
+                    {
+                        let mut group_user = GroupUser::new(group_uuid.clone(), user_org.uuid.clone());
+                        group_user.save(&mut conn).await?;
+                    }
                 }
             }
         }
+    } else {
+        warn!("Group support is disabled, groups will not be imported!");
     }
 
     // If this flag is enabled, any user that isn't provided in the Users list will be removed (by default they will be kept unless they have Deleted == true)
     if data.OverwriteExisting {
+        // Generate a HashSet to quickly verify if a member is listed or not.
+        let sync_members: HashSet<String> = data.Members.into_iter().map(|m| m.ExternalId).collect();
         for user_org in UserOrganization::find_by_org(&org_id, &mut conn).await {
             if let Some(user_external_id) =
                 User::find_by_uuid(&user_org.user_uuid, &mut conn).await.map(|u| u.external_id)
             {
-                if user_external_id.is_some()
-                    && !data.Members.iter().any(|u| u.ExternalId == *user_external_id.as_ref().unwrap())
-                {
+                if user_external_id.is_some() && !sync_members.contains(&user_external_id.unwrap()) {
                     if user_org.atype == UserOrgType::Owner && user_org.status == UserOrgStatus::Confirmed as i32 {
                         // Removing owner, check that there is at least one other confirmed owner
                         if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &mut conn)
@@ -165,7 +173,6 @@ async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut co
     Ok(())
 }
 
-#[derive(Debug)]
 pub struct PublicToken(String);
 
 #[rocket::async_trait]
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index a4764ada..596d2d75 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -157,16 +157,13 @@ impl User {
     pub fn set_external_id(&mut self, external_id: Option<String>) {
         //Check if external id is empty. We don't want to have
         //empty strings in the database
-        match external_id {
-            Some(external_id) => {
-                if external_id.is_empty() {
-                    self.external_id = None;
-                } else {
-                    self.external_id = Some(external_id)
-                }
+        let mut ext_id: Option<String> = None;
+        if let Some(external_id) = external_id {
+            if !external_id.is_empty() {
+                ext_id = Some(external_id);
             }
-            None => self.external_id = None,
         }
+        self.external_id = ext_id;
     }
 
     /// Set the password hash generated
@@ -400,6 +397,7 @@ impl User {
             users::table.filter(users::external_id.eq(id)).first::<UserDb>(conn).ok().from_db()
         }}
     }
+
     pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
         db_run! {conn: {
             users::table.load::<UserDb>(conn).expect("Error loading users").from_db()