From 6591fd5bfac8c3ea91fa7d8361cd6b16757ee16a Mon Sep 17 00:00:00 2001 From: Pablo Ovelleiro Corral Date: Tue, 4 Oct 2022 16:05:43 +0200 Subject: [PATCH] Squash commits --- Cargo.lock | 1 + Cargo.toml | 4 + .../mysql/2021-09-16-133000_add_sso/down.sql | 2 + .../mysql/2021-09-16-133000_add_sso/up.sql | 18 + .../2021-09-16-133000_add_sso/down.sql | 2 + .../2021-09-16-133000_add_sso/up.sql | 18 + .../sqlite/2021-09-16-133000_add_sso/down.sql | 2 + .../sqlite/2021-09-16-133000_add_sso/up.sql | 18 + src/api/core/organizations.rs | 94 + src/api/identity.rs | 246 +- src/db/models/mod.rs | 4 + src/db/models/org_policy.rs | 2 +- src/db/models/organization.rs | 2417 +++++++++++++---- src/db/models/sso_config.rs | 104 + src/db/models/sso_nonce.rs | 71 + src/db/schemas/mysql/schema.rs | 23 + src/db/schemas/postgresql/schema.rs | 23 + src/db/schemas/sqlite/schema.rs | 23 + src/static/global_domains.json | 2 +- 19 files changed, 2477 insertions(+), 597 deletions(-) create mode 100644 migrations/mysql/2021-09-16-133000_add_sso/down.sql create mode 100644 migrations/mysql/2021-09-16-133000_add_sso/up.sql create mode 100644 migrations/postgresql/2021-09-16-133000_add_sso/down.sql create mode 100644 migrations/postgresql/2021-09-16-133000_add_sso/up.sql create mode 100644 migrations/sqlite/2021-09-16-133000_add_sso/down.sql create mode 100644 migrations/sqlite/2021-09-16-133000_add_sso/up.sql create mode 100644 src/db/models/sso_config.rs create mode 100644 src/db/models/sso_nonce.rs diff --git a/Cargo.lock b/Cargo.lock index b6ea2ad035..a75cecd798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2675,6 +2675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" dependencies = [ "itoa", + "js-sys", "libc", "num_threads", "time-macros", diff --git a/Cargo.toml b/Cargo.toml index 583fe71066..c5e4c26b21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,10 @@ pico-args = "0.5.0" paste = "1.0.9" governor = "0.5.0" +# OIDC SSo +openidconnect = "2.3.2" + + # Capture CTRL+C ctrlc = { version = "3.2.3", features = ["termination"] } diff --git a/migrations/mysql/2021-09-16-133000_add_sso/down.sql b/migrations/mysql/2021-09-16-133000_add_sso/down.sql new file mode 100644 index 0000000000..ade3aeedf3 --- /dev/null +++ b/migrations/mysql/2021-09-16-133000_add_sso/down.sql @@ -0,0 +1,2 @@ +DROP TABLE sso_nonce; +DROP TABLE sso_config; diff --git a/migrations/mysql/2021-09-16-133000_add_sso/up.sql b/migrations/mysql/2021-09-16-133000_add_sso/up.sql new file mode 100644 index 0000000000..e42102144a --- /dev/null +++ b/migrations/mysql/2021-09-16-133000_add_sso/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE organizations ADD COLUMN identifier TEXT; + +CREATE TABLE sso_nonce ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), + nonce CHAR(36) NOT NULL +); + +CREATE TABLE sso_config ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + use_sso BOOLEAN NOT NULL, + callback_path TEXT NOT NULL, + signed_out_callback_path TEXT NOT NULL, + authority TEXT, + client_id TEXT, + client_secret TEXT +); diff --git a/migrations/postgresql/2021-09-16-133000_add_sso/down.sql b/migrations/postgresql/2021-09-16-133000_add_sso/down.sql new file mode 100644 index 0000000000..ade3aeedf3 --- /dev/null +++ b/migrations/postgresql/2021-09-16-133000_add_sso/down.sql @@ -0,0 +1,2 @@ +DROP TABLE sso_nonce; +DROP TABLE sso_config; diff --git a/migrations/postgresql/2021-09-16-133000_add_sso/up.sql b/migrations/postgresql/2021-09-16-133000_add_sso/up.sql new file mode 100644 index 0000000000..e42102144a --- /dev/null +++ b/migrations/postgresql/2021-09-16-133000_add_sso/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE organizations ADD COLUMN identifier TEXT; + +CREATE TABLE sso_nonce ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), + nonce CHAR(36) NOT NULL +); + +CREATE TABLE sso_config ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + use_sso BOOLEAN NOT NULL, + callback_path TEXT NOT NULL, + signed_out_callback_path TEXT NOT NULL, + authority TEXT, + client_id TEXT, + client_secret TEXT +); diff --git a/migrations/sqlite/2021-09-16-133000_add_sso/down.sql b/migrations/sqlite/2021-09-16-133000_add_sso/down.sql new file mode 100644 index 0000000000..ade3aeedf3 --- /dev/null +++ b/migrations/sqlite/2021-09-16-133000_add_sso/down.sql @@ -0,0 +1,2 @@ +DROP TABLE sso_nonce; +DROP TABLE sso_config; diff --git a/migrations/sqlite/2021-09-16-133000_add_sso/up.sql b/migrations/sqlite/2021-09-16-133000_add_sso/up.sql new file mode 100644 index 0000000000..e42102144a --- /dev/null +++ b/migrations/sqlite/2021-09-16-133000_add_sso/up.sql @@ -0,0 +1,18 @@ +ALTER TABLE organizations ADD COLUMN identifier TEXT; + +CREATE TABLE sso_nonce ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), + nonce CHAR(36) NOT NULL +); + +CREATE TABLE sso_config ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + use_sso BOOLEAN NOT NULL, + callback_path TEXT NOT NULL, + signed_out_callback_path TEXT NOT NULL, + authority TEXT, + client_id TEXT, + client_secret TEXT +); diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 3934de8803..28a91bdcb7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -31,6 +31,8 @@ pub fn routes() -> Vec { put_collection_users, put_organization, post_organization, + get_organization_sso, + put_organization_sso, post_organization_collections, delete_organization_collection_user, post_organization_collection_delete_user, @@ -92,6 +94,14 @@ struct OrgData { struct OrganizationUpdateData { BillingEmail: String, Name: String, + Identifier: Option, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrganizationSsoUpdateData { + Enabled: Option, + Data: Option, } #[derive(Deserialize, Debug)] @@ -100,6 +110,45 @@ struct NewCollectionData { Name: String, } +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct SsoOrganizationData { + // authority: Option, + // clientId: Option, + // clientSecret: Option, + AcrValues: Option, + AdditionalEmailClaimTypes: Option, + AdditionalNameClaimTypes: Option, + AdditionalScopes: Option, + AdditionalUserIdClaimTypes: Option, + Authority: Option, + ClientId: Option, + ClientSecret: Option, + ConfigType: Option, + ExpectedReturnAcrValue: Option, + GetClaimsFromUserInfoEndpoint: Option, + IdpAllowUnsolicitedAuthnResponse: Option, + IdpArtifactResolutionServiceUrl: Option, + IdpBindingType: Option, + IdpDisableOutboundLogoutRequests: Option, + IdpEntityId: Option, + IdpOutboundSigningAlgorithm: Option, + IdpSingleLogoutServiceUrl: Option, + IdpSingleSignOnServiceUrl: Option, + IdpWantAuthnRequestsSigned: Option, + IdpX509PublicCert: Option, + KeyConnectorUrlY: Option, + KeyConnectorEnabled: Option, + MetadataAddress: Option, + RedirectBehavior: Option, + SpMinIncomingSigningAlgorithm: Option, + SpNameIdFormat: Option, + SpOutboundSigningAlgorithm: Option, + SpSigningBehavior: Option, + SpValidateCertificates: Option, + SpWantAssertionsSigned: Option, +} + #[derive(Deserialize)] #[allow(non_snake_case)] struct OrgKeyData { @@ -134,6 +183,7 @@ async fn create_organization(headers: Headers, data: JsonUpcase, conn: let org = Organization::new(data.Name, data.BillingEmail, private_key, public_key); let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone()); + let sso_config = SsoConfig::new(org.uuid.clone()); let collection = Collection::new(org.uuid.clone(), data.CollectionName); user_org.akey = data.Key; @@ -143,6 +193,7 @@ async fn create_organization(headers: Headers, data: JsonUpcase, conn: org.save(&conn).await?; user_org.save(&conn).await?; + sso_config.save(&conn).await?; collection.save(&conn).await?; Ok(Json(org.to_json())) @@ -228,11 +279,54 @@ async fn post_organization( org.name = data.Name; org.billing_email = data.BillingEmail; + org.identifier = data.Identifier; org.save(&conn).await?; Ok(Json(org.to_json())) } +#[get("/organizations//sso")] +async fn get_organization_sso(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult { + match SsoConfig::find_by_org(&org_id, &conn).await { + Some(sso_config) => { + let config_json = Json(sso_config.to_json()); + Ok(config_json) + } + None => err!("Can't find organization sso config"), + } +} + +#[post("/organizations//sso", data = "")] +async fn put_organization_sso( + org_id: String, + _headers: OwnerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let p: OrganizationSsoUpdateData = data.into_inner().data; + let d: SsoOrganizationData = p.Data.unwrap(); + + let mut sso_config = match SsoConfig::find_by_org(&org_id, &conn).await { + Some(sso_config) => sso_config, + None => SsoConfig::new(org_id), + }; + + sso_config.use_sso = p.Enabled.unwrap_or_default(); + + // let sso_config_data = data.Data.unwrap(); + + // TODO use real values + sso_config.callback_path = "http://localhost:8000/#/sso".to_string(); //data.CallbackPath; + sso_config.signed_out_callback_path = "http://localhost:8000/#/sso".to_string(); //data2.Data.unwrap().call + + sso_config.authority = d.Authority; + sso_config.client_id = d.ClientId; + sso_config.client_secret = d.ClientSecret; + + sso_config.save(&conn).await?; + Ok(Json(sso_config.to_json())) +} + // GET /api/collections?writeOnly=false #[get("/collections")] async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { diff --git a/src/api/identity.rs b/src/api/identity.rs index d0a3bcceb7..74dee4396b 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,11 +1,15 @@ use chrono::Utc; +use jsonwebtoken::DecodingKey; use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::Status, + response::Redirect, Route, }; use serde_json::Value; +use std::iter::FromIterator; use crate::{ api::{ @@ -20,7 +24,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![login, prelogin] + routes![login, prelogin, prevalidate, authorize] } #[post("/connect/token", data = "")] @@ -51,6 +55,13 @@ async fn login(data: Form, conn: DbConn, ip: ClientIp) -> JsonResul _api_key_login(data, conn, &ip).await } + "authorization_code" => { + _check_is_some(&data.code, "code cannot be blank")?; + _check_is_some(&data.org_identifier, "org_identifier cannot be blank")?; + _check_is_some(&data.device_identifier, "device identifier cannot be blank")?; + + _authorization_login(data, conn, &ip).await + } t => err!("Invalid type", t), } } @@ -87,6 +98,104 @@ async fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult { }))) } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: String, + nonce: String, +} + +async fn _authorization_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult { + let org_identifier = data.org_identifier.as_ref().unwrap(); + let code = data.code.as_ref().unwrap(); + + let organization = Organization::find_by_identifier(org_identifier, &conn).await.unwrap(); + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn).await.unwrap(); + + let (_access_token, refresh_token, id_token) = match get_auth_code_access_token(code, &sso_config).await { + Ok((_access_token, refresh_token, id_token)) => (_access_token, refresh_token, id_token), + Err(err) => err!(err), + }; + + // https://github.com/Keats/jsonwebtoken/issues/236#issuecomment-1093039195 + // let token = jsonwebtoken::decode::(access_token.as_str()).unwrap().claims; + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let token = jsonwebtoken::decode::(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) + .unwrap() + .claims; + + // let expiry = token.exp; + let nonce = token.nonce; + + match SsoNonce::find_by_org_and_nonce(&organization.uuid, &nonce, &conn).await { + Some(sso_nonce) => { + match sso_nonce.delete(&conn).await { + Ok(_) => { + // let expiry = token.exp; + let user_email = token.email; + let now = Utc::now().naive_utc(); + + // COMMON + // TODO handle missing users, currently this will panic if the user does not exist! + let user = User::find_by_mail(&user_email, &conn).await.unwrap(); + + let (mut device, new_device) = get_device(&data, &conn, &user).await; + + let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, &conn).await?; + + if CONFIG.mail_enabled() && new_device { + if let Err(e) = + mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await + { + error!("Error sending new device email: {:#?}", e); + + if CONFIG.require_device_email() { + err!("Could not send login notification email. Please contact your administrator.") + } + } + } + + device.refresh_token = refresh_token.clone(); + device.save(&conn).await?; + + let scope_vec = vec!["api".into(), "offline_access".into()]; + let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn).await; + let (access_token_new, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); + device.save(&conn).await?; + + let mut result = json!({ + "access_token": access_token_new, + "expires_in": expires_in, + "token_type": "Bearer", + "refresh_token": refresh_token, + "Key": user.akey, + "PrivateKey": user.private_key, + + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing + "scope": "api offline_access", + "unofficialServer": true, + }); + + if let Some(token) = twofactor_token { + result["TwoFactorToken"] = Value::String(token); + } + + info!("User {} logged in successfully. IP: {}", user.email, ip.ip); + Ok(Json(result)) + } + Err(_) => err!("Failed to delete nonce"), + } + } + None => { + err!("Invalid nonce") + } + } +} + async fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult { // Validate scope let scope = data.scope.as_ref().unwrap(); @@ -116,6 +225,15 @@ async fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> Json err!("This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, username)) } + // Check if org policy prevents password login + let user_orgs = UserOrganization::find_by_user_and_policy(&user.uuid, OrgPolicyType::RequireSso, &conn).await; + if !user_orgs.is_empty() && user_orgs[0].atype != UserOrgType::Owner && user_orgs[0].atype != UserOrgType::Admin { + // if requires SSO is active, user is in exactly one org by policy rules + // policy only applies to "non-owner/non-admin" members + + err!("Organization policy requires SSO sign in"); + } + let now = Utc::now().naive_utc(); if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { @@ -486,11 +604,137 @@ struct ConnectData { #[field(name = uncased("two_factor_remember"))] #[field(name = uncased("twofactorremember"))] two_factor_remember: Option, + + // Needed for authorization code + code: Option, + org_identifier: Option, } +// TODO Might need to migrate this: https://github.com/SergioBenitez/Rocket/pull/1489#issuecomment-1114750006 + fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } + +#[get("/account/prevalidate?")] +#[allow(non_snake_case)] +// The compiler warns about unreachable code here. But I've tested it, and it seems to work +// as expected. All errors appear to be reachable, as is the Ok response. +#[allow(unreachable_code)] +async fn prevalidate(domainHint: String, conn: DbConn) -> JsonResult { + let empty_result = json!({}); + + // TODO: fix panic on failig to retrive (no unwrap on null) + let organization = Organization::find_by_identifier(&domainHint, &conn).await.unwrap(); + + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn); + match sso_config.await { + Some(sso_config) => { + if !sso_config.use_sso { + return err_code!("SSO Not allowed for organization", Status::BadRequest.code); + } + if sso_config.authority.is_none() || sso_config.client_id.is_none() || sso_config.client_secret.is_none() { + return err_code!("Organization is incorrectly configured for SSO", Status::BadRequest.code); + } + } + None => { + return err_code!("Unable to find sso config", Status::BadRequest.code); + } + } + + if domainHint.is_empty() { + return err_code!("No Organization Identifier Provided", Status::BadRequest.code); + } + + Ok(Json(empty_result)) +} + +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, + RedirectUrl, Scope, +}; + +async fn get_client_from_sso_config(sso_config: &SsoConfig) -> Result { + let redirect = sso_config.callback_path.clone(); + let client_id = ClientId::new(sso_config.client_id.as_ref().unwrap().to_string()); + let client_secret = ClientSecret::new(sso_config.client_secret.as_ref().unwrap().to_string()); + let issuer_url = + IssuerUrl::new(sso_config.authority.as_ref().unwrap().to_string()).or(Err("invalid issuer URL"))?; + + // TODO: This comparison will fail if one URI has a trailing slash and the other one does not. + // Should we remove trailing slashes when saving? Or when checking? + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Ok(metadata) => metadata, + Err(_err) => { + return Err("Failed to discover OpenID provider"); + } + }; + + let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(RedirectUrl::new(redirect).or(Err("Invalid redirect URL"))?); + + Ok(client) +} + +#[get("/connect/authorize?&")] +async fn authorize(domain_hint: String, state: String, conn: DbConn) -> ApiResult { + let organization = Organization::find_by_identifier(&domain_hint, &conn).await.unwrap(); + let sso_config = SsoConfig::find_by_org(&organization.uuid, &conn).await.unwrap(); + + match get_client_from_sso_config(&sso_config).await { + Ok(client) => { + let (mut authorize_url, _csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + let sso_nonce = SsoNonce::new(organization.uuid, nonce.secret().to_string()); + sso_nonce.save(&conn).await?; + + // it seems impossible to set the state going in dynamically (requires static lifetime string) + // so I change it after the fact + let old_pairs = authorize_url.query_pairs(); + let new_pairs = old_pairs.map(|pair| { + let (key, value) = pair; + if key == "state" { + return format!("{}={}", key, state); + } + format!("{}={}", key, value) + }); + let full_query = Vec::from_iter(new_pairs).join("&"); + authorize_url.set_query(Some(full_query.as_str())); + + Ok(Redirect::to(authorize_url.to_string())) + } + Err(err) => err!("Unable to find client from identifier {}", err), + } +} + +async fn get_auth_code_access_token( + code: &str, + sso_config: &SsoConfig, +) -> Result<(String, String, String), &'static str> { + let oidc_code = AuthorizationCode::new(String::from(code)); + match get_client_from_sso_config(sso_config).await { + Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await { + Ok(token_response) => { + let access_token = token_response.access_token().secret().to_string(); + let refresh_token = token_response.refresh_token().unwrap().secret().to_string(); + let id_token = token_response.extra_fields().id_token().unwrap().to_string(); + Ok((access_token, refresh_token, id_token)) + } + Err(_err) => Err("Failed to contact token endpoint"), + }, + Err(_err) => Err("unable to find client"), + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index eb425d1ac9..84635a97d5 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -8,6 +8,8 @@ mod folder; mod org_policy; mod organization; mod send; +mod sso_config; +mod sso_nonce; mod two_factor; mod two_factor_incomplete; mod user; @@ -22,6 +24,8 @@ pub use self::folder::{Folder, FolderCipher}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; +pub use self::sso_config::SsoConfig; +pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, User, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 02ca8408fe..65cbf5e134 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -28,7 +28,7 @@ pub enum OrgPolicyType { MasterPassword = 1, PasswordGenerator = 2, SingleOrg = 3, - // RequireSso = 4, // Not supported + RequireSso = 4, PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 99787eb857..0fd1e398a4 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,697 +1,1926 @@ use num_traits::FromPrimitive; +use rocket::serde::json::Json; +use rocket::Route; use serde_json::Value; -use std::cmp::Ordering; - -use super::{CollectionUser, OrgPolicy, OrgPolicyType, User}; - -db_object! { - #[derive(Identifiable, Queryable, Insertable, AsChangeset)] - #[table_name = "organizations"] - #[primary_key(uuid)] - pub struct Organization { - pub uuid: String, - pub name: String, - pub billing_email: String, - pub private_key: Option, - pub public_key: Option, - } - - #[derive(Identifiable, Queryable, Insertable, AsChangeset)] - #[table_name = "users_organizations"] - #[primary_key(uuid)] - pub struct UserOrganization { - pub uuid: String, - pub user_uuid: String, - pub org_uuid: String, - - pub access_all: bool, - pub akey: String, - pub status: i32, - pub atype: i32, - } -} - -// https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs -pub enum UserOrgStatus { - Revoked = -1, - Invited = 0, - Accepted = 1, - Confirmed = 2, -} - -#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] -pub enum UserOrgType { - Owner = 0, - Admin = 1, - User = 2, - Manager = 3, -} - -impl UserOrgType { - pub fn from_str(s: &str) -> Option { - match s { - "0" | "Owner" => Some(UserOrgType::Owner), - "1" | "Admin" => Some(UserOrgType::Admin), - "2" | "User" => Some(UserOrgType::User), - "3" | "Manager" => Some(UserOrgType::Manager), - _ => None, - } + +use crate::{ + api::{ + core::{CipherSyncData, CipherSyncType}, + EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType, + }, + auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, + db::{models::*, DbConn}, + mail, + util::convert_json_key_lcase_first, + CONFIG, +}; + +use futures::{stream, stream::StreamExt}; + +pub fn routes() -> Vec { + routes![ + get_organization, + create_organization, + delete_organization, + post_delete_organization, + leave_organization, + get_user_collections, + get_org_collections, + get_org_collection_detail, + get_collection_users, + put_collection_users, + put_organization, + post_organization, + get_organization_sso, + put_organization_sso, + post_organization_collections, + delete_organization_collection_user, + post_organization_collection_delete_user, + post_organization_collection_update, + put_organization_collection_update, + delete_organization_collection, + post_organization_collection_delete, + get_org_details, + get_org_users, + send_invite, + reinvite_user, + bulk_reinvite_user, + confirm_invite, + bulk_confirm_invite, + accept_invite, + get_user, + edit_user, + put_organization_user, + delete_user, + bulk_delete_user, + post_delete_user, + post_org_import, + list_policies, + list_policies_token, + get_policy, + put_policy, + get_organization_tax, + get_plans, + get_plans_tax_rates, + import, + post_org_keys, + bulk_public_keys, + deactivate_organization_user, + bulk_deactivate_organization_user, + revoke_organization_user, + bulk_revoke_organization_user, + activate_organization_user, + bulk_activate_organization_user, + restore_organization_user, + bulk_restore_organization_user, + get_org_export + ] +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct OrgData { + BillingEmail: String, + CollectionName: String, + Key: String, + Name: String, + Keys: Option, + #[serde(rename = "PlanType")] + _PlanType: NumberOrString, // Ignored, always use the same plan +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrganizationUpdateData { + BillingEmail: String, + Name: String, + Identifier: Option, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrganizationSsoUpdateData { + Enabled: Option, + Data: Option, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct NewCollectionData { + Name: String, +} + +/* + From Bitwarden Entreprise + + + + +{ + "enabled": false, + "data": { + "acrValues": "requested authentication context class", + "additionalEmailClaimTypes": "additinaional email", + "additionalNameClaimTypes": "additioonal name claim tyeps", + "additionalScopes": "additonal scopes", + "additionalUserIdClaimTypes": "additoal userid", + "authority": "authority", + "clientId": "clientid", + "clientSecret": "clientsecrte", + "configType": 1, + "expectedReturnAcrValue": "expectde acr", + "getClaimsFromUserInfoEndpoint": true, + "idpAllowUnsolicitedAuthnResponse": false, + "idpArtifactResolutionServiceUrl": null, + "idpBindingType": 1, + "idpDisableOutboundLogoutRequests": false, + "idpEntityId": null, + "idpOutboundSigningAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "idpSingleLogoutServiceUrl": null, + "idpSingleSignOnServiceUrl": null, + "idpWantAuthnRequestsSigned": false + "idpX509PublicCert": null, + "keyConnectorEnabled": false, + "keyConnectorUrl": null, + "metadataAddress": "metadata adress", + "redirectBehavior": 1, + "spMinIncomingSigningAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "spNameIdFormat": 7, + "spOutboundSigningAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "spSigningBehavior": 0, + "spValidateCertificates": false, + "spWantAssertionsSigned": false, + } +} +*/ + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct SsoOrganizationData { + // authority: Option, + // clientId: Option, + // clientSecret: Option, + AcrValues: Option, + AdditionalEmailClaimTypes: Option, + AdditionalNameClaimTypes: Option, + AdditionalScopes: Option, + AdditionalUserIdClaimTypes: Option, + Authority: Option, + ClientId: Option, + ClientSecret: Option, + ConfigType: Option, + ExpectedReturnAcrValue: Option, + GetClaimsFromUserInfoEndpoint: Option, + IdpAllowUnsolicitedAuthnResponse: Option, + IdpArtifactResolutionServiceUrl: Option, + IdpBindingType: Option, + IdpDisableOutboundLogoutRequests: Option, + IdpEntityId: Option, + IdpOutboundSigningAlgorithm: Option, + IdpSingleLogoutServiceUrl: Option, + IdpSingleSignOnServiceUrl: Option, + IdpWantAuthnRequestsSigned: Option, + IdpX509PublicCert: Option, + KeyConnectorUrlY: Option, + KeyConnectorEnabled: Option, + MetadataAddress: Option, + RedirectBehavior: Option, + SpMinIncomingSigningAlgorithm: Option, + SpNameIdFormat: Option, + SpOutboundSigningAlgorithm: Option, + SpSigningBehavior: Option, + SpValidateCertificates: Option, + SpWantAssertionsSigned: Option, +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct OrgKeyData { + EncryptedPrivateKey: String, + PublicKey: String, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrgBulkIds { + Ids: Vec, +} + +#[post("/organizations", data = "")] +async fn create_organization(headers: Headers, data: JsonUpcase, conn: DbConn) -> JsonResult { + if !CONFIG.is_org_creation_allowed(&headers.user.email) { + err!("User not allowed to create organizations") } + if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await { + err!( + "You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization." + ) + } + + let data: OrgData = data.into_inner().data; + let (private_key, public_key) = if data.Keys.is_some() { + let keys: OrgKeyData = data.Keys.unwrap(); + (Some(keys.EncryptedPrivateKey), Some(keys.PublicKey)) + } else { + (None, None) + }; + + let org = Organization::new(data.Name, data.BillingEmail, private_key, public_key); + let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone()); + let sso_config = SsoConfig::new(org.uuid.clone()); + let collection = Collection::new(org.uuid.clone(), data.CollectionName); + + user_org.akey = data.Key; + user_org.access_all = true; + user_org.atype = UserOrgType::Owner as i32; + user_org.status = UserOrgStatus::Confirmed as i32; + + org.save(&conn).await?; + user_org.save(&conn).await?; + sso_config.save(&conn).await?; + collection.save(&conn).await?; + + Ok(Json(org.to_json())) } -impl Ord for UserOrgType { - fn cmp(&self, other: &UserOrgType) -> Ordering { - // For easy comparison, map each variant to an access level (where 0 is lowest). - static ACCESS_LEVEL: [i32; 4] = [ - 3, // Owner - 2, // Admin - 0, // User - 1, // Manager - ]; - ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) +#[delete("/organizations/", data = "")] +async fn delete_organization( + org_id: String, + data: JsonUpcase, + headers: OwnerHeaders, + conn: DbConn, +) -> EmptyResult { + let data: PasswordData = data.into_inner().data; + let password_hash = data.MasterPasswordHash; + + if !headers.user.check_valid_password(&password_hash) { + err!("Invalid password") } + + match Organization::find_by_uuid(&org_id, &conn).await { + None => err!("Organization not found"), + Some(org) => org.delete(&conn).await, + } +} + +#[post("/organizations//delete", data = "")] +async fn post_delete_organization( + org_id: String, + data: JsonUpcase, + headers: OwnerHeaders, + conn: DbConn, +) -> EmptyResult { + delete_organization(org_id, data, headers, conn).await } -impl PartialOrd for UserOrgType { - fn partial_cmp(&self, other: &UserOrgType) -> Option { - Some(self.cmp(other)) +#[post("/organizations//leave")] +async fn leave_organization(org_id: String, headers: Headers, conn: DbConn) -> EmptyResult { + match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + None => err!("User not part of organization"), + Some(user_org) => { + if user_org.atype == UserOrgType::Owner + && UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &conn).await <= 1 + { + err!("The last owner can't leave") + } + + user_org.delete(&conn).await + } } } -impl PartialEq for UserOrgType { - fn eq(&self, other: &i32) -> bool { - *other == *self as i32 +#[get("/organizations/")] +async fn get_organization(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult { + match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => Ok(Json(organization.to_json())), + None => err!("Can't find organization details"), } } -impl PartialOrd for UserOrgType { - fn partial_cmp(&self, other: &i32) -> Option { - if let Some(other) = Self::from_i32(*other) { - return Some(self.cmp(&other)); +#[put("/organizations/", data = "")] +async fn put_organization( + org_id: String, + headers: OwnerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + post_organization(org_id, headers, data, conn).await +} + +#[post("/organizations/", data = "")] +async fn post_organization( + org_id: String, + _headers: OwnerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let data: OrganizationUpdateData = data.into_inner().data; + + let mut org = match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => organization, + None => err!("Can't find organization details"), + }; + + org.name = data.Name; + org.billing_email = data.BillingEmail; + org.identifier = data.Identifier; + + org.save(&conn).await?; + Ok(Json(org.to_json())) +} + +#[get("/organizations//sso")] +async fn get_organization_sso(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult { + match SsoConfig::find_by_org(&org_id, &conn).await { + Some(sso_config) => { + let config_json = Json(sso_config.to_json()); + Ok(config_json) } - None + None => err!("Can't find organization sso config"), } +} - fn gt(&self, other: &i32) -> bool { - matches!(self.partial_cmp(other), Some(Ordering::Greater)) - } +#[post("/organizations//sso", data = "")] +async fn put_organization_sso( + org_id: String, + _headers: OwnerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let p: OrganizationSsoUpdateData = data.into_inner().data; + let d: SsoOrganizationData = p.Data.unwrap(); + + // TODO remove after debugging + println!( + " + p.Enabled: {:?}, + d.AcrValues: {:?}, + d.AdditionalEmailClaimTypes: {:?}, + d.AdditionalNameClaimTypes: {:?}, + d.AdditionalScopes: {:?}, + d.AdditionalUserIdClaimTypes: {:?}, + d.Authority: {:?}, + d.ClientId: {:?}, + d.ClientSecret: {:?}, + d.ConfigType: {:?}, + d.ExpectedReturnAcrValue: {:?}, + d.GetClaimsFromUserInfoEndpoint: {:?}, + d.IdpAllowUnsolicitedAuthnResponse: {:?}, + d.IdpArtifactResolutionServiceUrl: {:?}, + d.IdpBindingType: {:?}, + d.IdpDisableOutboundLogoutRequests: {:?}, + d.IdpEntityId: {:?}, + d.IdpOutboundSigningAlgorithm: {:?}, + d.IdpSingleLogoutServiceUrl: {:?}, + d.IdpSingleSignOnServiceUrl: {:?}, + d.IdpWantAuthnRequestsSigned: {:?}, + d.IdpX509PublicCert: {:?}, + d.KeyConnectorUrlY: {:?}, + d.KeyConnectorEnabled: {:?}, + d.MetadataAddress: {:?}, + d.RedirectBehavior: {:?}, + d.SpMinIncomingSigningAlgorithm: {:?}, + d.SpNameIdFormat: {:?}, + d.SpOutboundSigningAlgorithm: {:?}, + d.SpSigningBehavior: {:?}, + d.SpValidateCertificates: {:?}, + d.SpWantAssertionsSigned: {:?}", + p.Enabled.unwrap_or_default(), + d.AcrValues, + d.AdditionalEmailClaimTypes, + d.AdditionalNameClaimTypes, + d.AdditionalScopes, + d.AdditionalUserIdClaimTypes, + d.Authority, + d.ClientId, + d.ClientSecret, + d.ConfigType, + d.ExpectedReturnAcrValue, + d.GetClaimsFromUserInfoEndpoint, + d.IdpAllowUnsolicitedAuthnResponse, + d.IdpArtifactResolutionServiceUrl, + d.IdpBindingType, + d.IdpDisableOutboundLogoutRequests, + d.IdpEntityId, + d.IdpOutboundSigningAlgorithm, + d.IdpSingleLogoutServiceUrl, + d.IdpSingleSignOnServiceUrl, + d.IdpWantAuthnRequestsSigned, + d.IdpX509PublicCert, + d.KeyConnectorUrlY, + d.KeyConnectorEnabled, + d.MetadataAddress, + d.RedirectBehavior, + d.SpMinIncomingSigningAlgorithm, + d.SpNameIdFormat, + d.SpOutboundSigningAlgorithm, + d.SpSigningBehavior, + d.SpValidateCertificates, + d.SpWantAssertionsSigned + ); + + let mut sso_config = match SsoConfig::find_by_org(&org_id, &conn).await { + Some(sso_config) => sso_config, + None => SsoConfig::new(org_id), + }; + + sso_config.use_sso = p.Enabled.unwrap_or_default(); + + // let sso_config_data = data.Data.unwrap(); + + // TODO use real values + sso_config.callback_path = "http://localhost:8000/#/sso".to_string(); //data.CallbackPath; + sso_config.signed_out_callback_path = "http://localhost:8000/#/sso".to_string(); //data2.Data.unwrap().call + + sso_config.authority = d.Authority; + sso_config.client_id = d.ClientId; + sso_config.client_secret = d.ClientSecret; + + sso_config.save(&conn).await?; + Ok(Json(sso_config.to_json())) +} + +// GET /api/collections?writeOnly=false +#[get("/collections")] +async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { + Json(json!({ + "Data": + Collection::find_by_user_uuid(&headers.user.uuid, &conn).await + .iter() + .map(Collection::to_json) + .collect::(), + "Object": "list", + "ContinuationToken": null, + })) +} - fn ge(&self, other: &i32) -> bool { - matches!(self.partial_cmp(other), Some(Ordering::Greater) | Some(Ordering::Equal)) +#[get("/organizations//collections")] +async fn get_org_collections(org_id: String, _headers: ManagerHeadersLoose, conn: DbConn) -> Json { + Json(_get_org_collections(&org_id, &conn).await) +} + +async fn _get_org_collections(org_id: &str, conn: &DbConn) -> Value { + json!({ + "Data": + Collection::find_by_organization(org_id, conn).await + .iter() + .map(Collection::to_json) + .collect::(), + "Object": "list", + "ContinuationToken": null, + }) +} + +#[post("/organizations//collections", data = "")] +async fn post_organization_collections( + org_id: String, + headers: ManagerHeadersLoose, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let data: NewCollectionData = data.into_inner().data; + + let org = match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => organization, + None => err!("Can't find organization details"), + }; + + // Get the user_organization record so that we can check if the user has access to all collections. + let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; + + let collection = Collection::new(org.uuid, data.Name); + collection.save(&conn).await?; + + // If the user doesn't have access to all collections, only in case of a Manger, + // then we need to save the creating user uuid (Manager) to the users_collection table. + // Else the user will not have access to his own created collection. + if !user_org.access_all { + CollectionUser::save(&headers.user.uuid, &collection.uuid, false, false, &conn).await?; } + + Ok(Json(collection.to_json())) +} + +#[put("/organizations//collections/", data = "")] +async fn put_organization_collection_update( + org_id: String, + col_id: String, + headers: ManagerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + post_organization_collection_update(org_id, col_id, headers, data, conn).await } -impl PartialEq for i32 { - fn eq(&self, other: &UserOrgType) -> bool { - *self == *other as i32 +#[post("/organizations//collections/", data = "")] +async fn post_organization_collection_update( + org_id: String, + col_id: String, + _headers: ManagerHeaders, + data: JsonUpcase, + conn: DbConn, +) -> JsonResult { + let data: NewCollectionData = data.into_inner().data; + + let org = match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => organization, + None => err!("Can't find organization details"), + }; + + let mut collection = match Collection::find_by_uuid(&col_id, &conn).await { + Some(collection) => collection, + None => err!("Collection not found"), + }; + + if collection.org_uuid != org.uuid { + err!("Collection is not owned by organization"); } + + collection.name = data.Name; + collection.save(&conn).await?; + + Ok(Json(collection.to_json())) } -impl PartialOrd for i32 { - fn partial_cmp(&self, other: &UserOrgType) -> Option { - if let Some(self_type) = UserOrgType::from_i32(*self) { - return Some(self_type.cmp(other)); +#[delete("/organizations//collections//user/")] +async fn delete_organization_collection_user( + org_id: String, + col_id: String, + org_user_id: String, + _headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + let collection = match Collection::find_by_uuid(&col_id, &conn).await { + None => err!("Collection not found"), + Some(collection) => { + if collection.org_uuid == org_id { + collection + } else { + err!("Collection and Organization id do not match") + } + } + }; + + match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &conn).await { + None => err!("User not found in organization"), + Some(user_org) => { + match CollectionUser::find_by_collection_and_user(&collection.uuid, &user_org.user_uuid, &conn).await { + None => err!("User not assigned to collection"), + Some(col_user) => col_user.delete(&conn).await, + } } - None } +} - fn lt(&self, other: &UserOrgType) -> bool { - matches!(self.partial_cmp(other), Some(Ordering::Less) | None) - } +#[post("/organizations//collections//delete-user/")] +async fn post_organization_collection_delete_user( + org_id: String, + col_id: String, + org_user_id: String, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + delete_organization_collection_user(org_id, col_id, org_user_id, headers, conn).await +} - fn le(&self, other: &UserOrgType) -> bool { - matches!(self.partial_cmp(other), Some(Ordering::Less) | Some(Ordering::Equal) | None) +#[delete("/organizations//collections/")] +async fn delete_organization_collection( + org_id: String, + col_id: String, + _headers: ManagerHeaders, + conn: DbConn, +) -> EmptyResult { + match Collection::find_by_uuid(&col_id, &conn).await { + None => err!("Collection not found"), + Some(collection) => { + if collection.org_uuid == org_id { + collection.delete(&conn).await + } else { + err!("Collection and Organization id do not match") + } + } } } -/// Local methods -impl Organization { - pub fn new(name: String, billing_email: String, private_key: Option, public_key: Option) -> Self { - Self { - uuid: crate::util::get_uuid(), - name, - billing_email, - private_key, - public_key, +#[derive(Deserialize, Debug)] +#[allow(non_snake_case, dead_code)] +struct DeleteCollectionData { + Id: String, + OrgId: String, +} + +#[post("/organizations//collections//delete", data = "<_data>")] +async fn post_organization_collection_delete( + org_id: String, + col_id: String, + headers: ManagerHeaders, + _data: JsonUpcase, + conn: DbConn, +) -> EmptyResult { + delete_organization_collection(org_id, col_id, headers, conn).await +} + +#[get("/organizations//collections//details")] +async fn get_org_collection_detail( + org_id: String, + coll_id: String, + headers: ManagerHeaders, + conn: DbConn, +) -> JsonResult { + match Collection::find_by_uuid_and_user(&coll_id, &headers.user.uuid, &conn).await { + None => err!("Collection not found"), + Some(collection) => { + if collection.org_uuid != org_id { + err!("Collection is not owned by organization") + } + + Ok(Json(collection.to_json())) } } - // https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/Organizations/OrganizationResponseModel.cs - pub fn to_json(&self) -> Value { - json!({ - "Id": self.uuid, - "Identifier": null, // not supported by us - "Name": self.name, - "Seats": 10, // The value doesn't matter, we don't check server-side - // "MaxAutoscaleSeats": null, // The value doesn't matter, we don't check server-side - "MaxCollections": 10, // The value doesn't matter, we don't check server-side - "MaxStorageGb": 10, // The value doesn't matter, we don't check server-side - "Use2fa": true, - "UseDirectory": false, // Is supported, but this value isn't checked anywhere (yet) - "UseEvents": false, // Not supported - "UseGroups": false, // Not supported - "UseTotp": true, - "UsePolicies": true, - // "UseScim": false, // Not supported (Not AGPLv3 Licensed) - "UseSso": false, // Not supported - // "UseKeyConnector": false, // Not supported - "SelfHost": true, - "UseApi": false, // Not supported - "HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), - "UseResetPassword": false, // Not supported - - "BusinessName": null, - "BusinessAddress1": null, - "BusinessAddress2": null, - "BusinessAddress3": null, - "BusinessCountry": null, - "BusinessTaxNumber": null, - - "BillingEmail": self.billing_email, - "Plan": "TeamsAnnually", - "PlanType": 5, // TeamsAnnually plan - "UsersGetPremium": true, - "Object": "organization", +} + +#[get("/organizations//collections//users")] +async fn get_collection_users(org_id: String, coll_id: String, _headers: ManagerHeaders, conn: DbConn) -> JsonResult { + // Get org and collection, check that collection is from org + let collection = match Collection::find_by_uuid_and_org(&coll_id, &org_id, &conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => collection, + }; + + let user_list = stream::iter(CollectionUser::find_by_collection(&collection.uuid, &conn).await) + .then(|col_user| async { + let col_user = col_user; // Move out this single variable + UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn) + .await + .unwrap() + .to_json_user_access_restrictions(&col_user) }) - } + .collect::>() + .await; + + Ok(Json(json!(user_list))) } -// Used to either subtract or add to the current status -// The number 128 should be fine, it is well within the range of an i32 -// The same goes for the database where we only use INTEGER (the same as an i32) -// It should also provide enough room for 100+ types, which i doubt will ever happen. -static ACTIVATE_REVOKE_DIFF: i32 = 128; +#[put("/organizations//collections//users", data = "")] +async fn put_collection_users( + org_id: String, + coll_id: String, + data: JsonUpcaseVec, + _headers: ManagerHeaders, + conn: DbConn, +) -> EmptyResult { + // Get org and collection, check that collection is from org + if Collection::find_by_uuid_and_org(&coll_id, &org_id, &conn).await.is_none() { + err!("Collection not found in Organization") + } -impl UserOrganization { - pub fn new(user_uuid: String, org_uuid: String) -> Self { - Self { - uuid: crate::util::get_uuid(), + // Delete all the user-collections + CollectionUser::delete_all_by_collection(&coll_id, &conn).await?; - user_uuid, - org_uuid, + // And then add all the received ones (except if the user has access_all) + for d in data.iter().map(|d| &d.data) { + let user = match UserOrganization::find_by_uuid(&d.Id, &conn).await { + Some(u) => u, + None => err!("User is not part of organization"), + }; - access_all: false, - akey: String::new(), - status: UserOrgStatus::Accepted as i32, - atype: UserOrgType::User as i32, + if user.access_all { + continue; } + + CollectionUser::save(&user.user_uuid, &coll_id, d.ReadOnly, d.HidePasswords, &conn).await?; } - pub fn restore(&mut self) { - if self.status < UserOrgStatus::Accepted as i32 { - self.status += ACTIVATE_REVOKE_DIFF; + Ok(()) +} + +#[derive(FromForm)] +struct OrgIdData { + #[field(name = "organizationId")] + organization_id: String, +} + +#[get("/ciphers/organization-details?")] +async fn get_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> Json { + Json(_get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await) +} + +async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &DbConn) -> Value { + let ciphers = Cipher::find_by_org(org_id, conn).await; + let cipher_sync_data = CipherSyncData::new(user_uuid, &ciphers, CipherSyncType::Organization, conn).await; + + let ciphers_json = stream::iter(ciphers) + .then(|c| async { + let c = c; // Move out this single variable + c.to_json(host, user_uuid, Some(&cipher_sync_data), conn).await + }) + .collect::>() + .await; + + json!({ + "Data": ciphers_json, + "Object": "list", + "ContinuationToken": null, + }) +} + +#[get("/organizations//users")] +async fn get_org_users(org_id: String, _headers: ManagerHeadersLoose, conn: DbConn) -> Json { + let users_json = stream::iter(UserOrganization::find_by_org(&org_id, &conn).await) + .then(|u| async { + let u = u; // Move out this single variable + u.to_json_user_details(&conn).await + }) + .collect::>() + .await; + + Json(json!({ + "Data": users_json, + "Object": "list", + "ContinuationToken": null, + })) +} + +#[post("/organizations//keys", data = "")] +async fn post_org_keys( + org_id: String, + data: JsonUpcase, + _headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + let data: OrgKeyData = data.into_inner().data; + + let mut org = match Organization::find_by_uuid(&org_id, &conn).await { + Some(organization) => { + if organization.private_key.is_some() && organization.public_key.is_some() { + err!("Organization Keys already exist") + } + organization } + None => err!("Can't find organization details"), + }; + + org.private_key = Some(data.EncryptedPrivateKey); + org.public_key = Some(data.PublicKey); + + org.save(&conn).await?; + + Ok(Json(json!({ + "Object": "organizationKeys", + "PublicKey": org.public_key, + "PrivateKey": org.private_key, + }))) +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct CollectionData { + Id: String, + ReadOnly: bool, + HidePasswords: bool, +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct InviteData { + Emails: Vec, + Type: NumberOrString, + Collections: Option>, + AccessAll: Option, +} + +#[post("/organizations//users/invite", data = "")] +async fn send_invite(org_id: String, data: JsonUpcase, headers: AdminHeaders, conn: DbConn) -> EmptyResult { + let data: InviteData = data.into_inner().data; + + let new_type = match UserOrgType::from_str(&data.Type.into_string()) { + Some(new_type) => new_type as i32, + None => err!("Invalid type"), + }; + + if new_type != UserOrgType::User && headers.org_user_type != UserOrgType::Owner { + err!("Only Owners can invite Managers, Admins or Owners") } - pub fn revoke(&mut self) { - if self.status > UserOrgStatus::Revoked as i32 { - self.status -= ACTIVATE_REVOKE_DIFF; + for email in data.Emails.iter() { + let email = email.to_lowercase(); + let mut 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 user = match User::find_by_mail(&email, &conn).await { + None => { + if !CONFIG.invitations_allowed() { + err!(format!("User does not exist: {}", email)) + } + + if !CONFIG.is_email_domain_allowed(&email) { + err!("Email domain not eligible for invitations") + } + + if !CONFIG.mail_enabled() { + let invitation = Invitation::new(email.clone()); + invitation.save(&conn).await?; + } + + let mut user = User::new(email.clone()); + user.save(&conn).await?; + user_org_status = UserOrgStatus::Invited as i32; + user + } + Some(user) => { + if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { + err!(format!("User already in organization: {}", email)) + } else { + user + } + } + }; + + let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone()); + let access_all = data.AccessAll.unwrap_or(false); + new_user.access_all = access_all; + new_user.atype = new_type; + new_user.status = user_org_status; + + // If no accessAll, add the collections received + if !access_all { + for col in data.Collections.iter().flatten() { + match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => { + CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, &conn) + .await?; + } + } + } + } + + new_user.save(&conn).await?; + + if CONFIG.mail_enabled() { + let org_name = match Organization::find_by_uuid(&org_id, &conn).await { + Some(org) => org.name, + None => err!("Error looking up organization"), + }; + + mail::send_invite( + &email, + &user.uuid, + Some(org_id.clone()), + Some(new_user.uuid), + &org_name, + Some(headers.user.email.clone()), + ) + .await?; } } + + Ok(()) } -use crate::db::DbConn; +#[post("/organizations//users/reinvite", data = "")] +async fn bulk_reinvite_user( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + let data: OrgBulkIds = data.into_inner().data; + + let mut bulk_response = Vec::new(); + for org_user_id in data.Ids { + let err_msg = match _reinvite_user(&org_id, &org_user_id, &headers.user.email, &conn).await { + Ok(_) => String::from(""), + Err(e) => format!("{:?}", e), + }; -use crate::api::EmptyResult; -use crate::error::MapResult; + bulk_response.push(json!( + { + "Object": "OrganizationBulkConfirmResponseModel", + "Id": org_user_id, + "Error": err_msg + } + )) + } -/// Database methods -impl Organization { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { - for user_org in UserOrganization::find_by_org(&self.uuid, conn).await.iter() { - User::update_uuid_revision(&user_org.user_uuid, conn).await; - } + Json(json!({ + "Data": bulk_response, + "Object": "list", + "ContinuationToken": null + })) +} + +#[post("/organizations//users//reinvite")] +async fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult { + _reinvite_user(&org_id, &user_org, &headers.user.email, &conn).await +} + +async fn _reinvite_user(org_id: &str, user_org: &str, invited_by_email: &str, conn: &DbConn) -> EmptyResult { + if !CONFIG.invitations_allowed() { + err!("Invitations are not allowed.") + } + + if !CONFIG.mail_enabled() { + err!("SMTP is not configured.") + } + + let user_org = match UserOrganization::find_by_uuid(user_org, conn).await { + Some(user_org) => user_org, + None => err!("The user hasn't been invited to the organization."), + }; + + if user_org.status != UserOrgStatus::Invited as i32 { + err!("The user is already accepted or confirmed to the organization") + } + + let user = match User::find_by_uuid(&user_org.user_uuid, conn).await { + Some(user) => user, + None => err!("User not found."), + }; + + let org_name = match Organization::find_by_uuid(org_id, conn).await { + Some(org) => org.name, + None => err!("Error looking up organization."), + }; + + if CONFIG.mail_enabled() { + mail::send_invite( + &user.email, + &user.uuid, + Some(org_id.to_string()), + Some(user_org.uuid), + &org_name, + Some(invited_by_email.to_string()), + ) + .await?; + } else { + let invitation = Invitation::new(user.email); + invitation.save(conn).await?; + } + + Ok(()) +} - db_run! { conn: - sqlite, mysql { - match diesel::replace_into(organizations::table) - .values(OrganizationDb::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(organizations::table) - .filter(organizations::uuid.eq(&self.uuid)) - .set(OrganizationDb::to_db(self)) - .execute(conn) - .map_res("Error saving organization") +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct AcceptData { + Token: String, +} + +#[post("/organizations//users/<_org_user_id>/accept", data = "")] +async fn accept_invite( + org_id: String, + _org_user_id: String, + data: JsonUpcase, + conn: DbConn, +) -> EmptyResult { + // The web-vault passes org_id and org_user_id in the URL, but we are just reading them from the JWT instead + let data: AcceptData = data.into_inner().data; + let claims = decode_invite(&data.Token)?; + + match User::find_by_mail(&claims.email, &conn).await { + Some(_) => { + Invitation::take(&claims.email, &conn).await; + + if let (Some(user_org), Some(org)) = (&claims.user_org_id, &claims.org_id) { + let mut user_org = match UserOrganization::find_by_uuid_and_org(user_org, org, &conn).await { + Some(user_org) => user_org, + None => err!("Error accepting the invitation"), + }; + + if user_org.status != UserOrgStatus::Invited as i32 { + err!("User already accepted the invitation") + } + + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if user_org.atype < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_org.user_uuid, &org_id, false, &conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + err!("You cannot join this organization until you enable two-step login on your user account"); + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot join this organization because you are a member of an organization which forbids it"); + } } - Err(e) => Err(e.into()), - }.map_res("Error saving organization") + } + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(&conn).await?; } - postgresql { - let value = OrganizationDb::to_db(self); - diesel::insert_into(organizations::table) - .values(&value) - .on_conflict(organizations::uuid) - .do_update() - .set(&value) - .execute(conn) - .map_res("Error saving organization") + } + None => err!("Invited user not found"), + } + + if CONFIG.mail_enabled() { + let mut org_name = CONFIG.invitation_org_name(); + if let Some(org_id) = &claims.org_id { + org_name = match Organization::find_by_uuid(org_id, &conn).await { + Some(org) => org.name, + None => err!("Organization not found."), + }; + }; + if let Some(invited_by_email) = &claims.invited_by_email { + // User was invited to an organization, so they must be confirmed manually after acceptance + mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?; + } else { + // User was invited from /admin, so they are automatically confirmed + mail::send_invite_confirmed(&claims.email, &org_name).await?; + } + } + + Ok(()) +} + +#[post("/organizations//users/confirm", data = "")] +async fn bulk_confirm_invite( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + let data = data.into_inner().data; + + let mut bulk_response = Vec::new(); + match data["Keys"].as_array() { + Some(keys) => { + for invite in keys { + let org_user_id = invite["Id"].as_str().unwrap_or_default(); + let user_key = invite["Key"].as_str().unwrap_or_default(); + let err_msg = match _confirm_invite(&org_id, org_user_id, user_key, &headers, &conn).await { + Ok(_) => String::from(""), + Err(e) => format!("{:?}", e), + }; + + bulk_response.push(json!( + { + "Object": "OrganizationBulkConfirmResponseModel", + "Id": org_user_id, + "Error": err_msg + } + )); } } + None => error!("No keys to confirm"), } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { - use super::{Cipher, Collection}; - - Cipher::delete_all_by_organization(&self.uuid, conn).await?; - Collection::delete_all_by_organization(&self.uuid, conn).await?; - UserOrganization::delete_all_by_organization(&self.uuid, conn).await?; - OrgPolicy::delete_all_by_organization(&self.uuid, conn).await?; - - db_run! { conn: { - diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid))) - .execute(conn) - .map_res("Error saving organization") - }} - } - - pub async fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option { - db_run! { conn: { - organizations::table - .filter(organizations::uuid.eq(uuid)) - .first::(conn) - .ok().from_db() - }} - } - - pub async fn get_all(conn: &DbConn) -> Vec { - db_run! { conn: { - organizations::table.load::(conn).expect("Error loading organizations").from_db() - }} - } -} - -impl UserOrganization { - pub async fn to_json(&self, conn: &DbConn) -> Value { - let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap(); - - // https://github.com/bitwarden/server/blob/13d1e74d6960cf0d042620b72d85bf583a4236f7/src/Api/Models/Response/ProfileOrganizationResponseModel.cs - json!({ - "Id": self.org_uuid, - "Identifier": null, // Not supported - "Name": org.name, - "Seats": 10, // The value doesn't matter, we don't check server-side - "MaxCollections": 10, // The value doesn't matter, we don't check server-side - "UsersGetPremium": true, - - "Use2fa": true, - "UseDirectory": false, // Is supported, but this value isn't checked anywhere (yet) - "UseEvents": false, // Not supported - "UseGroups": false, // Not supported - "UseTotp": true, - // "UseScim": false, // Not supported (Not AGPLv3 Licensed) - "UsePolicies": true, - "UseApi": false, // Not supported - "SelfHost": true, - "HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(), - "ResetPasswordEnrolled": false, // Not supported - "SsoBound": false, // Not supported - "UseSso": false, // Not supported - "ProviderId": null, - "ProviderName": null, - // "KeyConnectorEnabled": false, - // "KeyConnectorUrl": null, - - // TODO: Add support for Custom User Roles - // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role - // "Permissions": { - // "AccessEventLogs": false, // Not supported - // "AccessImportExport": false, - // "AccessReports": false, - // "ManageAllCollections": false, - // "CreateNewCollections": false, - // "EditAnyCollection": false, - // "DeleteAnyCollection": false, - // "ManageAssignedCollections": false, - // "editAssignedCollections": false, - // "deleteAssignedCollections": false, - // "ManageCiphers": false, - // "ManageGroups": false, // Not supported - // "ManagePolicies": false, - // "ManageResetPassword": false, // Not supported - // "ManageSso": false, // Not supported - // "ManageUsers": false, - // "ManageScim": false, // Not supported (Not AGPLv3 Licensed) - // }, - - "MaxStorageGb": 10, // The value doesn't matter, we don't check server-side - - // These are per user - "UserId": self.user_uuid, - "Key": self.akey, - "Status": self.status, - "Type": self.atype, - "Enabled": true, - - "Object": "profileOrganization", - }) + Json(json!({ + "Data": bulk_response, + "Object": "list", + "ContinuationToken": null + })) +} + +#[post("/organizations//users//confirm", data = "")] +async fn confirm_invite( + org_id: String, + org_user_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + let data = data.into_inner().data; + let user_key = data["Key"].as_str().unwrap_or_default(); + _confirm_invite(&org_id, &org_user_id, user_key, &headers, &conn).await +} + +async fn _confirm_invite( + org_id: &str, + org_user_id: &str, + key: &str, + headers: &AdminHeaders, + conn: &DbConn, +) -> EmptyResult { + if key.is_empty() || org_user_id.is_empty() { + err!("Key or UserId is not set, unable to process request"); } - pub async fn to_json_user_details(&self, conn: &DbConn) -> Value { - let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); + let mut user_to_confirm = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(user) => user, + None => err!("The specified user isn't a member of the organization"), + }; - // Because BitWarden want the status to be -1 for revoked users we need to catch that here. - // We subtract/add a number so we can restore/activate the user to it's previouse state again. - let status = if self.status < UserOrgStatus::Revoked as i32 { - UserOrgStatus::Revoked as i32 - } else { - self.status + if user_to_confirm.atype != UserOrgType::User && headers.org_user_type != UserOrgType::Owner { + err!("Only Owners can confirm Managers, Admins or Owners") + } + + if user_to_confirm.status != UserOrgStatus::Accepted as i32 { + err!("User in invalid state") + } + + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if user_to_confirm.atype < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_to_confirm.user_uuid, org_id, true, conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + err!("You cannot confirm this user because it has no two-step login method activated"); + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot confirm this user because it is a member of an organization which forbids it"); + } + } + } + + user_to_confirm.status = UserOrgStatus::Confirmed as i32; + user_to_confirm.akey = key.to_string(); + + if CONFIG.mail_enabled() { + let org_name = match Organization::find_by_uuid(org_id, conn).await { + Some(org) => org.name, + None => err!("Error looking up organization."), + }; + let address = match User::find_by_uuid(&user_to_confirm.user_uuid, conn).await { + Some(user) => user.email, + None => err!("Error looking up user."), }; + mail::send_invite_confirmed(&address, &org_name).await?; + } + + user_to_confirm.save(conn).await +} - json!({ - "Id": self.uuid, - "UserId": self.user_uuid, - "Name": user.name, - "Email": user.email, +#[get("/organizations//users/")] +async fn get_user(org_id: String, org_user_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult { + let user = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &conn).await { + Some(user) => user, + None => err!("The specified user isn't a member of the organization"), + }; - "Status": status, - "Type": self.atype, - "AccessAll": self.access_all, + Ok(Json(user.to_json_details(&conn).await)) +} - "Object": "organizationUserUserDetails", - }) +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct EditUserData { + Type: NumberOrString, + Collections: Option>, + AccessAll: bool, +} + +#[put("/organizations//users/", data = "", rank = 1)] +async fn put_organization_user( + org_id: String, + org_user_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + edit_user(org_id, org_user_id, data, headers, conn).await +} + +#[post("/organizations//users/", data = "", rank = 1)] +async fn edit_user( + org_id: String, + org_user_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + let data: EditUserData = data.into_inner().data; + + let new_type = match UserOrgType::from_str(&data.Type.into_string()) { + Some(new_type) => new_type, + None => err!("Invalid type"), + }; + + let mut user_to_edit = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &conn).await { + Some(user) => user, + None => err!("The specified user isn't member of the organization"), + }; + + if new_type != user_to_edit.atype + && (user_to_edit.atype >= UserOrgType::Admin || new_type >= UserOrgType::Admin) + && headers.org_user_type != UserOrgType::Owner + { + err!("Only Owners can grant and remove Admin or Owner privileges") } - pub fn to_json_user_access_restrictions(&self, col_user: &CollectionUser) -> Value { - json!({ - "Id": self.uuid, - "ReadOnly": col_user.read_only, - "HidePasswords": col_user.hide_passwords, - }) + if user_to_edit.atype == UserOrgType::Owner && headers.org_user_type != UserOrgType::Owner { + err!("Only Owners can edit Owner users") } - pub async fn to_json_details(&self, conn: &DbConn) -> Value { - let coll_uuids = if self.access_all { - vec![] // If we have complete access, no need to fill the array - } else { - let collections = - CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn).await; - collections - .iter() - .map(|c| { - json!({ - "Id": c.collection_uuid, - "ReadOnly": c.read_only, - "HidePasswords": c.hide_passwords, - }) - }) - .collect() - }; + if user_to_edit.atype == UserOrgType::Owner && new_type != UserOrgType::Owner { + // Removing owner permmission, check that there is at least one other confirmed owner + if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &conn).await <= 1 { + err!("Can't delete the last owner") + } + } - // Because BitWarden want the status to be -1 for revoked users we need to catch that here. - // We subtract/add a number so we can restore/activate the user to it's previouse state again. - let status = if self.status < UserOrgStatus::Revoked as i32 { - UserOrgStatus::Revoked as i32 - } else { - self.status + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if new_type < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_to_edit.user_uuid, &org_id, true, &conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + err!("You cannot modify this user to this type because it has no two-step login method activated"); + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot modify this user to this type because it is a member of an organization which forbids it"); + } + } + } + + user_to_edit.access_all = data.AccessAll; + user_to_edit.atype = new_type as i32; + + // Delete all the odd collections + for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &user_to_edit.user_uuid, &conn).await { + c.delete(&conn).await?; + } + + // If no accessAll, add the collections received + if !data.AccessAll { + for col in data.Collections.iter().flatten() { + match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => { + CollectionUser::save( + &user_to_edit.user_uuid, + &collection.uuid, + col.ReadOnly, + col.HidePasswords, + &conn, + ) + .await?; + } + } + } + } + + user_to_edit.save(&conn).await +} + +#[delete("/organizations//users", data = "")] +async fn bulk_delete_user( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + let data: OrgBulkIds = data.into_inner().data; + + let mut bulk_response = Vec::new(); + for org_user_id in data.Ids { + let err_msg = match _delete_user(&org_id, &org_user_id, &headers, &conn).await { + Ok(_) => String::from(""), + Err(e) => format!("{:?}", e), }; - json!({ - "Id": self.uuid, - "UserId": self.user_uuid, + bulk_response.push(json!( + { + "Object": "OrganizationBulkConfirmResponseModel", + "Id": org_user_id, + "Error": err_msg + } + )) + } - "Status": status, - "Type": self.atype, - "AccessAll": self.access_all, - "Collections": coll_uuids, + Json(json!({ + "Data": bulk_response, + "Object": "list", + "ContinuationToken": null + })) +} - "Object": "organizationUserDetails", - }) +#[delete("/organizations//users/")] +async fn delete_user(org_id: String, org_user_id: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult { + _delete_user(&org_id, &org_user_id, &headers, &conn).await +} + +async fn _delete_user(org_id: &str, org_user_id: &str, headers: &AdminHeaders, conn: &DbConn) -> EmptyResult { + let user_to_delete = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(user) => user, + None => err!("User to delete isn't member of the organization"), + }; + + if user_to_delete.atype != UserOrgType::User && headers.org_user_type != UserOrgType::Owner { + err!("Only Owners can delete Admins or Owners") + } + + if user_to_delete.atype == UserOrgType::Owner { + // Removing owner, check that there is at least one other confirmed owner + if UserOrganization::count_confirmed_by_org_and_type(org_id, UserOrgType::Owner, conn).await <= 1 { + err!("Can't delete the last owner") + } } - pub async fn save(&self, conn: &DbConn) -> EmptyResult { - User::update_uuid_revision(&self.user_uuid, conn).await; - - db_run! { conn: - sqlite, mysql { - match diesel::replace_into(users_organizations::table) - .values(UserOrganizationDb::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(users_organizations::table) - .filter(users_organizations::uuid.eq(&self.uuid)) - .set(UserOrganizationDb::to_db(self)) - .execute(conn) - .map_res("Error adding user to organization") + + user_to_delete.delete(conn).await +} + +#[post("/organizations//users//delete")] +async fn post_delete_user(org_id: String, org_user_id: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult { + delete_user(org_id, org_user_id, headers, conn).await +} + +#[post("/organizations//users/public-keys", data = "")] +async fn bulk_public_keys( + org_id: String, + data: JsonUpcase, + _headers: AdminHeaders, + conn: DbConn, +) -> Json { + let data: OrgBulkIds = data.into_inner().data; + + let mut bulk_response = Vec::new(); + // Check all received UserOrg UUID's and find the matching User to retreive the public-key. + // If the user does not exists, just ignore it, and do not return any information regarding that UserOrg UUID. + // The web-vault will then ignore that user for the folowing steps. + for user_org_id in data.Ids { + match UserOrganization::find_by_uuid_and_org(&user_org_id, &org_id, &conn).await { + Some(user_org) => match User::find_by_uuid(&user_org.user_uuid, &conn).await { + Some(user) => bulk_response.push(json!( + { + "Object": "organizationUserPublicKeyResponseModel", + "Id": user_org_id, + "UserId": user.uuid, + "Key": user.public_key } - Err(e) => Err(e.into()), - }.map_res("Error adding user to organization") - } - postgresql { - let value = UserOrganizationDb::to_db(self); - diesel::insert_into(users_organizations::table) - .values(&value) - .on_conflict(users_organizations::uuid) - .do_update() - .set(&value) - .execute(conn) - .map_res("Error adding user to organization") - } + )), + None => debug!("User doesn't exist"), + }, + None => debug!("UserOrg doesn't exist"), } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { - User::update_uuid_revision(&self.user_uuid, conn).await; + Json(json!({ + "Data": bulk_response, + "Object": "list", + "ContinuationToken": null + })) +} - CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?; +use super::ciphers::update_cipher_from_data; +use super::ciphers::CipherData; - db_run! { conn: { - diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid))) - .execute(conn) - .map_res("Error removing user from organization") - }} +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct ImportData { + Ciphers: Vec, + Collections: Vec, + CollectionRelationships: Vec, +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct RelationsData { + // Cipher index + Key: usize, + // Collection index + Value: usize, +} + +#[post("/ciphers/import-organization?", data = "")] +async fn post_org_import( + query: OrgIdData, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let data: ImportData = data.into_inner().data; + let org_id = query.organization_id; + + let collections = stream::iter(data.Collections) + .then(|coll| async { + let collection = Collection::new(org_id.clone(), coll.Name); + if collection.save(&conn).await.is_err() { + err!("Failed to create Collection"); + } + + Ok(collection) + }) + .collect::>() + .await; + + // Read the relations between collections and ciphers + let mut relations = Vec::new(); + for relation in data.CollectionRelationships { + relations.push((relation.Key, relation.Value)); + } + + let headers: Headers = headers.into(); + + let ciphers = stream::iter(data.Ciphers) + .then(|cipher_data| async { + let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone()); + update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::None).await.ok(); + cipher + }) + .collect::>() + .await; + + // Assign the collections + for (cipher_index, coll_index) in relations { + let cipher_id = &ciphers[cipher_index].uuid; + let coll = &collections[coll_index]; + let coll_id = match coll { + Ok(coll) => coll.uuid.as_str(), + Err(_) => err!("Failed to assign to collection"), + }; + + CollectionCipher::save(cipher_id, coll_id, &conn).await?; + } + + let mut user = headers.user; + user.update_revision(&conn).await +} + +#[get("/organizations//policies")] +async fn list_policies(org_id: String, _headers: AdminHeaders, conn: DbConn) -> Json { + let policies = OrgPolicy::find_by_org(&org_id, &conn).await; + let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + Json(json!({ + "Data": policies_json, + "Object": "list", + "ContinuationToken": null + })) +} + +#[get("/organizations//policies/token?")] +async fn list_policies_token(org_id: String, token: String, conn: DbConn) -> JsonResult { + let invite = crate::auth::decode_invite(&token)?; + + let invite_org_id = match invite.org_id { + Some(invite_org_id) => invite_org_id, + None => err!("Invalid token"), + }; + + if invite_org_id != org_id { + err!("Token doesn't match request organization"); } - pub async fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult { - for user_org in Self::find_by_org(org_uuid, conn).await { - user_org.delete(conn).await?; + // TODO: We receive the invite token as ?token=<>, validate it contains the org id + let policies = OrgPolicy::find_by_org(&org_id, &conn).await; + let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + Ok(Json(json!({ + "Data": policies_json, + "Object": "list", + "ContinuationToken": null + }))) +} + +#[get("/organizations//policies/")] +async fn get_policy(org_id: String, pol_type: i32, _headers: AdminHeaders, conn: DbConn) -> JsonResult { + let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { + Some(pt) => pt, + None => err!("Invalid or unsupported policy type"), + }; + + let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { + Some(p) => p, + None => OrgPolicy::new(org_id, pol_type_enum, "{}".to_string()), + }; + + Ok(Json(policy.to_json())) +} + +#[derive(Deserialize)] +struct PolicyData { + enabled: bool, + #[serde(rename = "type")] + _type: i32, + data: Option, +} + +#[put("/organizations//policies/", data = "")] +async fn put_policy( + org_id: String, + pol_type: i32, + data: Json, + _headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + let data: PolicyData = data.into_inner(); + + let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { + Some(pt) => pt, + None => err!("Invalid or unsupported policy type"), + }; + + // When enabling the TwoFactorAuthentication policy, remove this org's members that do have 2FA + if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { + for member in UserOrganization::find_by_org(&org_id, &conn).await.into_iter() { + let user_twofactor_disabled = TwoFactor::find_by_user(&member.user_uuid, &conn).await.is_empty(); + + // Policy only applies to non-Owner/non-Admin members who have accepted joining the org + // Invited users still need to accept the invite and will get an error when they try to accept the invite. + if user_twofactor_disabled + && member.atype < UserOrgType::Admin + && member.status != UserOrgStatus::Invited as i32 + { + if CONFIG.mail_enabled() { + let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap(); + let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap(); + + mail::send_2fa_removed_from_org(&user.email, &org.name).await?; + } + member.delete(&conn).await?; + } } - Ok(()) } - pub async fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { - for user_org in Self::find_any_state_by_user(user_uuid, conn).await { - user_org.delete(conn).await?; + // When enabling the SingleOrg policy, remove this org's members that are members of other orgs + if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { + for member in UserOrganization::find_by_org(&org_id, &conn).await.into_iter() { + // Policy only applies to non-Owner/non-Admin members who have accepted joining the org + // Exclude invited and revoked users when checking for this policy. + // Those users will not be allowed to accept or be activated because of the policy checks done there. + // We check if the count is larger then 1, because it includes this organization also. + if member.atype < UserOrgType::Admin + && member.status != UserOrgStatus::Invited as i32 + && UserOrganization::count_accepted_and_confirmed_by_user(&member.user_uuid, &conn).await > 1 + { + if CONFIG.mail_enabled() { + let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap(); + let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap(); + + mail::send_single_org_removed_from_org(&user.email, &org.name).await?; + } + member.delete(&conn).await?; + } } - Ok(()) } - pub async fn find_by_email_and_org(email: &str, org_id: &str, conn: &DbConn) -> Option { - if let Some(user) = super::User::find_by_mail(email, conn).await { - if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, org_id, conn).await { - return Some(user_org); + let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { + Some(p) => p, + None => OrgPolicy::new(org_id, pol_type_enum, "{}".to_string()), + }; + + policy.enabled = data.enabled; + policy.data = serde_json::to_string(&data.data)?; + policy.save(&conn).await?; + + Ok(Json(policy.to_json())) +} + +#[allow(unused_variables)] +#[get("/organizations//tax")] +fn get_organization_tax(org_id: String, _headers: Headers) -> Json { + // Prevent a 404 error, which also causes Javascript errors. + // Upstream sends "Only allowed when not self hosted." As an error message. + // If we do the same it will also output this to the log, which is overkill. + // An empty list/data also works fine. + Json(_empty_data_json()) +} + +#[get("/plans")] +fn get_plans() -> Json { + // Respond with a minimal json just enough to allow the creation of an new organization. + Json(json!({ + "Object": "list", + "Data": [{ + "Object": "plan", + "Type": 0, + "Product": 0, + "Name": "Free", + "NameLocalizationKey": "planNameFree", + "DescriptionLocalizationKey": "planDescFree" + }], + "ContinuationToken": null + })) +} + +#[get("/plans/sales-tax-rates")] +fn get_plans_tax_rates(_headers: Headers) -> Json { + // Prevent a 404 error, which also causes Javascript errors. + Json(_empty_data_json()) +} + +fn _empty_data_json() -> Value { + json!({ + "Object": "list", + "Data": [], + "ContinuationToken": null + }) +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case, dead_code)] +struct OrgImportGroupData { + Name: String, // "GroupName" + ExternalId: String, // "cn=GroupName,ou=Groups,dc=example,dc=com" + Users: Vec, // ["uid=user,ou=People,dc=example,dc=com"] +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrgImportUserData { + Email: String, // "user@maildomain.net" + #[allow(dead_code)] + ExternalId: String, // "uid=user,ou=People,dc=example,dc=com" + Deleted: bool, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct OrgImportData { + #[allow(dead_code)] + Groups: Vec, + OverwriteExisting: bool, + Users: Vec, +} + +#[post("/organizations//import", data = "")] +async fn import(org_id: String, data: JsonUpcase, headers: Headers, conn: DbConn) -> EmptyResult { + let data = data.into_inner().data; + + // TODO: Currently we aren't storing the externalId's anywhere, so we also don't have a way + // to differentiate between auto-imported users and manually added ones. + // This means that this endpoint can end up removing users that were added manually by an admin, + // as opposed to upstream which only removes auto-imported users. + + // User needs to be admin or owner to use the Directry Connector + match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(user_org) if user_org.atype >= UserOrgType::Admin => { /* Okay, nothing to do */ } + Some(_) => err!("User has insufficient permissions to use Directory Connector"), + None => err!("User not part of organization"), + }; + + for user_data in &data.Users { + if user_data.Deleted { + // If user is marked for deletion and it exists, delete it + if let Some(user_org) = UserOrganization::find_by_email_and_org(&user_data.Email, &org_id, &conn).await { + user_org.delete(&conn).await?; + } + + // If user is not part of the organization, but it exists + } else if UserOrganization::find_by_email_and_org(&user_data.Email, &org_id, &conn).await.is_none() { + if let Some(user) = User::find_by_mail(&user_data.Email, &conn).await { + 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(&conn).await?; + + if CONFIG.mail_enabled() { + let org_name = match Organization::find_by_uuid(&org_id, &conn).await { + Some(org) => org.name, + 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(headers.user.email.clone()), + ) + .await?; + } } } + } - None + // 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_and_type(&org_id, UserOrgType::User, &conn).await { + if let Some(user_email) = User::find_by_uuid(&user_org.user_uuid, &conn).await.map(|u| u.email) { + if !data.Users.iter().any(|u| u.Email == user_email) { + user_org.delete(&conn).await?; + } + } + } } - - pub fn has_status(&self, status: UserOrgStatus) -> bool { - self.status == status as i32 + + Ok(()) +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users//deactivate")] +async fn deactivate_organization_user( + org_id: String, + org_user_id: String, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + _revoke_organization_user(&org_id, &org_user_id, &headers, &conn).await +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users/deactivate", data = "")] +async fn bulk_deactivate_organization_user( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + bulk_revoke_organization_user(org_id, data, headers, conn).await +} + +#[put("/organizations//users//revoke")] +async fn revoke_organization_user( + org_id: String, + org_user_id: String, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + _revoke_organization_user(&org_id, &org_user_id, &headers, &conn).await +} + +#[put("/organizations//users/revoke", data = "")] +async fn bulk_revoke_organization_user( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + let data = data.into_inner().data; + + let mut bulk_response = Vec::new(); + match data["Ids"].as_array() { + Some(org_users) => { + for org_user_id in org_users { + let org_user_id = org_user_id.as_str().unwrap_or_default(); + let err_msg = match _revoke_organization_user(&org_id, org_user_id, &headers, &conn).await { + Ok(_) => String::from(""), + Err(e) => format!("{:?}", e), + }; + + bulk_response.push(json!( + { + "Object": "OrganizationUserBulkResponseModel", + "Id": org_user_id, + "Error": err_msg + } + )); + } + } + None => error!("No users to revoke"), } - pub fn has_type(&self, user_type: UserOrgType) -> bool { - self.atype == user_type as i32 + Json(json!({ + "Data": bulk_response, + "Object": "list", + "ContinuationToken": null + })) +} + +async fn _revoke_organization_user( + org_id: &str, + org_user_id: &str, + headers: &AdminHeaders, + conn: &DbConn, +) -> EmptyResult { + match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(mut user_org) if user_org.status > UserOrgStatus::Revoked as i32 => { + if user_org.user_uuid == headers.user.uuid { + err!("You cannot revoke yourself") + } + if user_org.atype == UserOrgType::Owner && headers.org_user_type != UserOrgType::Owner { + err!("Only owners can revoke other owners") + } + if user_org.atype == UserOrgType::Owner + && UserOrganization::count_confirmed_by_org_and_type(org_id, UserOrgType::Owner, conn).await <= 1 + { + err!("Organization must have at least one confirmed owner") + } + + user_org.revoke(); + user_org.save(conn).await?; + } + Some(_) => err!("User is already revoked"), + None => err!("User not found in organization"), } - - pub fn has_full_access(&self) -> bool { - (self.access_all || self.atype >= UserOrgType::Admin) && self.has_status(UserOrgStatus::Confirmed) - } - - pub async fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::uuid.eq(uuid)) - .first::(conn) - .ok().from_db() - }} - } - - pub async fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &DbConn) -> Option { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::uuid.eq(uuid)) - .filter(users_organizations::org_uuid.eq(org_uuid)) - .first::(conn) - .ok().from_db() - }} - } - - pub async fn find_confirmed_by_user(user_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) - .load::(conn) - .unwrap_or_default().from_db() - }} - } - - pub async fn find_invited_by_user(user_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(UserOrgStatus::Invited as i32)) - .load::(conn) - .unwrap_or_default().from_db() - }} - } - - pub async fn find_any_state_by_user(user_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .load::(conn) - .unwrap_or_default().from_db() - }} - } - - pub async fn count_accepted_and_confirmed_by_user(user_uuid: &str, conn: &DbConn) -> i64 { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(UserOrgStatus::Accepted as i32)) - .or_filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) - .count() - .first::(conn) - .unwrap_or(0) - }} - } - - pub async fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .load::(conn) - .expect("Error loading user organizations").from_db() - }} - } - - pub async fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - }} - } - - pub async fn find_by_org_and_type(org_uuid: &str, atype: UserOrgType, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::atype.eq(atype as i32)) - .load::(conn) - .expect("Error loading user organizations").from_db() - }} - } - - pub async fn count_confirmed_by_org_and_type(org_uuid: &str, atype: UserOrgType, conn: &DbConn) -> i64 { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::atype.eq(atype as i32)) - .filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32)) - .count() - .first::(conn) - .unwrap_or(0) - }} - } - - pub async fn find_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &DbConn) -> Option { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::org_uuid.eq(org_uuid)) - .first::(conn) - .ok().from_db() - }} - } - - pub async fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::user_uuid.eq(user_uuid)) - .load::(conn) - .expect("Error loading user organizations").from_db() - }} - } - - pub async fn find_by_user_and_policy(user_uuid: &str, policy_type: OrgPolicyType, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .inner_join( - org_policies::table.on( - org_policies::org_uuid.eq(users_organizations::org_uuid) - .and(users_organizations::user_uuid.eq(user_uuid)) - .and(org_policies::atype.eq(policy_type as i32)) - .and(org_policies::enabled.eq(true))) - ) - .filter( - users_organizations::status.eq(UserOrgStatus::Confirmed as i32) - ) - .select(users_organizations::all_columns) - .load::(conn) - .unwrap_or_default().from_db() - }} - } - - pub async fn find_by_cipher_and_org(cipher_uuid: &str, org_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .left_join(users_collections::table.on( - users_collections::user_uuid.eq(users_organizations::user_uuid) - )) - .left_join(ciphers_collections::table.on( - ciphers_collections::collection_uuid.eq(users_collections::collection_uuid).and( - ciphers_collections::cipher_uuid.eq(&cipher_uuid) - ) - )) - .filter( - users_organizations::access_all.eq(true).or( // AccessAll.. - ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection with cipher - ) - ) - .select(users_organizations::all_columns) - .load::(conn).expect("Error loading user organizations").from_db() - }} - } - - pub async fn find_by_collection_and_org(collection_uuid: &str, org_uuid: &str, conn: &DbConn) -> Vec { - db_run! { conn: { - users_organizations::table - .filter(users_organizations::org_uuid.eq(org_uuid)) - .left_join(users_collections::table.on( - users_collections::user_uuid.eq(users_organizations::user_uuid) - )) - .filter( - users_organizations::access_all.eq(true).or( // AccessAll.. - users_collections::collection_uuid.eq(&collection_uuid) // ..or access to collection with cipher - ) - ) - .select(users_organizations::all_columns) - .load::(conn).expect("Error loading user organizations").from_db() - }} + Ok(()) +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users//activate")] +async fn activate_organization_user( + org_id: String, + org_user_id: String, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + _restore_organization_user(&org_id, &org_user_id, &headers, &conn).await +} + +// Pre web-vault v2022.9.x endpoint +#[put("/organizations//users/activate", data = "")] +async fn bulk_activate_organization_user( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + bulk_restore_organization_user(org_id, data, headers, conn).await +} + +#[put("/organizations//users//restore")] +async fn restore_organization_user( + org_id: String, + org_user_id: String, + headers: AdminHeaders, + conn: DbConn, +) -> EmptyResult { + _restore_organization_user(&org_id, &org_user_id, &headers, &conn).await +} + +#[put("/organizations//users/restore", data = "")] +async fn bulk_restore_organization_user( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> Json { + let data = data.into_inner().data; + + let mut bulk_response = Vec::new(); + match data["Ids"].as_array() { + Some(org_users) => { + for org_user_id in org_users { + let org_user_id = org_user_id.as_str().unwrap_or_default(); + let err_msg = match _restore_organization_user(&org_id, org_user_id, &headers, &conn).await { + Ok(_) => String::from(""), + Err(e) => format!("{:?}", e), + }; + + bulk_response.push(json!( + { + "Object": "OrganizationUserBulkResponseModel", + "Id": org_user_id, + "Error": err_msg + } + )); + } + } + None => error!("No users to restore"), } + + Json(json!({ + "Data": bulk_response, + "Object": "list", + "ContinuationToken": null + })) } -#[cfg(test)] -mod tests { - use super::*; +async fn _restore_organization_user( + org_id: &str, + org_user_id: &str, + headers: &AdminHeaders, + conn: &DbConn, +) -> EmptyResult { + match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { + Some(mut user_org) if user_org.status < UserOrgStatus::Accepted as i32 => { + if user_org.user_uuid == headers.user.uuid { + err!("You cannot restore yourself") + } + if user_org.atype == UserOrgType::Owner && headers.org_user_type != UserOrgType::Owner { + err!("Only owners can restore other owners") + } - #[test] - #[allow(non_snake_case)] - fn partial_cmp_UserOrgType() { - assert!(UserOrgType::Owner > UserOrgType::Admin); - assert!(UserOrgType::Admin > UserOrgType::Manager); - assert!(UserOrgType::Manager > UserOrgType::User); + // This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type + // It returns different error messages per function. + if user_org.atype < UserOrgType::Admin { + match OrgPolicy::is_user_allowed(&user_org.user_uuid, org_id, false, conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + err!("You cannot restore this user because it has no two-step login method activated"); + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot restore this user because it is a member of an organization which forbids it"); + } + } + } + + user_org.restore(); + user_org.save(conn).await?; + } + Some(_) => err!("User is already active"), + None => err!("User not found in organization"), } + Ok(()) +} + +// This is a new function active since the v2022.9.x clients. +// It combines the previous two calls done before. +// We call those two functions here and combine them our selfs. +// +// NOTE: It seems clients can't handle uppercase-first keys!! +// We need to convert all keys so they have the first character to be a lowercase. +// Else the export will be just an empty JSON file. +#[get("/organizations//export")] +async fn get_org_export(org_id: String, headers: AdminHeaders, conn: DbConn) -> Json { + // Also both main keys here need to be lowercase, else the export will fail. + Json(json!({ + "collections": convert_json_key_lcase_first(_get_org_collections(&org_id, &conn).await), + "ciphers": convert_json_key_lcase_first(_get_org_details(&org_id, &headers.host, &headers.user.uuid, &conn).await), + })) } diff --git a/src/db/models/sso_config.rs b/src/db/models/sso_config.rs new file mode 100644 index 0000000000..e807353574 --- /dev/null +++ b/src/db/models/sso_config.rs @@ -0,0 +1,104 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; +use serde_json::Value; + +use super::Organization; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)] + #[table_name = "sso_config"] + #[belongs_to(Organization, foreign_key = "org_uuid")] + #[primary_key(uuid)] + pub struct SsoConfig { + pub uuid: String, + pub org_uuid: String, + pub use_sso: bool, + pub callback_path: String, + pub signed_out_callback_path: String, + pub authority: Option, + pub client_id: Option, + pub client_secret: Option, + } +} + +/// Local methods +impl SsoConfig { + pub fn new(org_uuid: String) -> Self { + Self { + uuid: crate::util::get_uuid(), + org_uuid, + use_sso: false, + callback_path: String::from("http://localhost/#/sso/"), + signed_out_callback_path: String::from("http://localhost/#/sso/"), + authority: None, + client_id: None, + client_secret: None, + } + } + + pub fn to_json(&self) -> Value { + json!({ + "Id": self.uuid, + "UseSso": self.use_sso, + "CallbackPath": self.callback_path, + "SignedOutCallbackPath": self.signed_out_callback_path, + "Authority": self.authority, + "ClientId": self.client_id, + "ClientSecret": self.client_secret, + }) + } +} + +/// Database methods +impl SsoConfig { + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + match diesel::replace_into(sso_config::table) + .values(SsoConfigDb::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(sso_config::table) + .filter(sso_config::uuid.eq(&self.uuid)) + .set(SsoConfigDb::to_db(self)) + .execute(conn) + .map_res("Error adding sso config to organization") + } + Err(e) => Err(e.into()), + }.map_res("Error adding sso config to organization") + } + postgresql { + let value = SsoConfigDb::to_db(self); + diesel::insert_into(sso_config::table) + .values(&value) + .on_conflict(sso_config::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error adding sso config to organization") + } + } + } + + pub async fn delete(self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_config::table.filter(sso_config::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting SSO Config") + }} + } + + pub async fn find_by_org(org_uuid: &str, conn: &DbConn) -> Option { + db_run! { conn: { + sso_config::table + .filter(sso_config::org_uuid.eq(org_uuid)) + .first::(conn) + .ok() + .from_db() + }} + } +} diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 0000000000..0897a186b1 --- /dev/null +++ b/src/db/models/sso_nonce.rs @@ -0,0 +1,71 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; + +use super::Organization; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)] + #[table_name = "sso_nonce"] + #[belongs_to(Organization, foreign_key = "org_uuid")] + #[primary_key(uuid)] + pub struct SsoNonce { + pub uuid: String, + pub org_uuid: String, + pub nonce: String, + } +} + +/// Local methods +impl SsoNonce { + pub fn new(org_uuid: String, nonce: String) -> Self { + Self { + uuid: crate::util::get_uuid(), + org_uuid, + nonce, + } + } +} + +/// Database methods +impl SsoNonce { + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(sso_nonce::table) + .values(SsoNonceDb::to_db(self)) + .execute(conn) + .map_res("Error saving device") + } + postgresql { + let value = SsoNonceDb::to_db(self); + diesel::insert_into(sso_nonce::table) + .values(&value) + .on_conflict(sso_nonce::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error saving SSO nonce") + } + } + } + + pub async fn delete(self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting SSO nonce") + }} + } + + pub async fn find_by_org_and_nonce(org_uuid: &str, nonce: &str, conn: &DbConn) -> Option { + db_run! { conn: { + sso_nonce::table + .filter(sso_nonce::org_uuid.eq(org_uuid)) + .filter(sso_nonce::nonce.eq(nonce)) + .first::(conn) + .ok() + .from_db() + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index a49159f27c..1b08ad157c 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -100,11 +100,25 @@ table! { uuid -> Text, name -> Text, billing_email -> Text, + identifier -> Nullable, private_key -> Nullable, public_key -> Nullable, } } +table! { + sso_config (uuid) { + uuid -> Text, + org_uuid -> Text, + use_sso -> Bool, + callback_path -> Text, + signed_out_callback_path -> Text, + authority -> Nullable, + client_id -> Nullable, + client_secret -> Nullable, + } +} + table! { sends (uuid) { uuid -> Text, @@ -203,6 +217,14 @@ table! { } } +table! { + sso_nonce (uuid) { + uuid -> Text, + org_uuid -> Text, + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -239,6 +261,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(emergency_access -> users (grantor_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); allow_tables_to_appear_in_same_query!( attachments, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 9fd6fd9727..1e23b10194 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -100,11 +100,25 @@ table! { uuid -> Text, name -> Text, billing_email -> Text, + identifier -> Nullable, private_key -> Nullable, public_key -> Nullable, } } +table! { + sso_config (uuid) { + uuid -> Text, + org_uuid -> Text, + use_sso -> Bool, + callback_path -> Text, + signed_out_callback_path -> Text, + authority -> Nullable, + client_id -> Nullable, + client_secret -> Nullable, + } +} + table! { sends (uuid) { uuid -> Text, @@ -203,6 +217,14 @@ table! { } } +table! { + sso_nonce (uuid) { + uuid -> Text, + org_uuid -> Text, + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -239,6 +261,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(emergency_access -> users (grantor_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); allow_tables_to_appear_in_same_query!( attachments, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 9fd6fd9727..1e23b10194 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -100,11 +100,25 @@ table! { uuid -> Text, name -> Text, billing_email -> Text, + identifier -> Nullable, private_key -> Nullable, public_key -> Nullable, } } +table! { + sso_config (uuid) { + uuid -> Text, + org_uuid -> Text, + use_sso -> Bool, + callback_path -> Text, + signed_out_callback_path -> Text, + authority -> Nullable, + client_id -> Nullable, + client_secret -> Nullable, + } +} + table! { sends (uuid) { uuid -> Text, @@ -203,6 +217,14 @@ table! { } } +table! { + sso_nonce (uuid) { + uuid -> Text, + org_uuid -> Text, + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -239,6 +261,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(emergency_access -> users (grantor_uuid)); +joinable!(sso_nonce -> organizations (org_uuid)); allow_tables_to_appear_in_same_query!( attachments, diff --git a/src/static/global_domains.json b/src/static/global_domains.json index 06df70a3d2..ef9f76325b 100644 --- a/src/static/global_domains.json +++ b/src/static/global_domains.json @@ -940,4 +940,4 @@ ], "Excluded": false } -] \ No newline at end of file +]