Skip to content

Commit

Permalink
Allow Group/Organization mapping to trigger invitation
Browse files Browse the repository at this point in the history
  • Loading branch information
Timshel committed Jan 18, 2024
1 parent 1eb8390 commit e718547
Show file tree
Hide file tree
Showing 12 changed files with 359 additions and 120 deletions.
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@
# SSO_ROLES_DEFAULT_TO_USER=true
## Access token path to read roles
# SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles
## Controls whether to add users to organization
# SSO_ORGANIZATIONS_INVITE=false
## Optional scope to retrieve user organizations
# SSO_ORGANIZATIONS_SCOPE=groups
## Access token path to read groups
# SSO_ORGANIZATIONS_TOKEN_PATH=/groups

## Set the lifetime of admin sessions to this value (in minutes).
# ADMIN_SESSION_LIFETIME=20
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@ This feature is controlled by the following conf:
- `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`.
- `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token


### Group/Organization invitation mapping

Allow to invite user to existing Oganization if they are listed in the Access token.
If activated it will check if the token contain a list of potential Orgnaization.
If an Oganization with a matching name (case sensitive) is found it will the start the invitation process for this user.
It will use the email associated with the Organization to send further notifications (admin side).

The flow look like this:

- Decode the JWT Access token and check if a list of organization is present (default path is `/groups`).
- Check if an Organization with a matching name exist and the user is not part of it.
- Depending on `SSO_ACCEPTALL_INVITES` :
- `false` - Invite the user to the Orgnization
- The user will need to click on the link in the mail he received
- A notification is sent tto he `email` associated with the Organization that a new user is ready to join
- An admin will have to validate the user to finalize the user joining the org.
- `true` - Add the user to the Organization
- A notification is sent to the user to inform of the enrollment in the org
- A notification is sent to the `email` associated with the Organization that a new user is ready to join
- An admin will have to validate the user to confirm the user joining the org.

If email are disabled then the user will silently be enrolled and the admin will need to check the org to finish the process.

One of the bonus of invitation is that if an organization define a specific password policy then it will apply to new user when they set their new master password.
If a user is part of two organizations then it will order them using the role of the user (`Owner`, `Admin`, `User` or `Manager` for now manager is last :() and return the password policy of the first one.

This feature is controlled with the following conf:

- `SSO_ORGANIZATIONS_INVITE`: control if the mapping is done, default is `false`
- `SSO_ORGANIZATIONS_SCOPE`: Optional scope to request if needed
- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token


## Docker

Change the docker files to package both front-end from [Timshel/oidc_web_builds](https://github.com/Timshel/oidc_web_builds/releases).
Expand Down
3 changes: 3 additions & 0 deletions SSO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ The following configurations are available
- `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false`
- `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`.
- `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token
- `SSO_ORGANIZATIONS_INVIT`: control if the mapping is done, default is `false`
- `SSO_ORGANIZATIONS_SCOPE`: Optional scope to request if needed
- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token

The callback url is : `https://your.domain/identity/connect/oidc-signin`

Expand Down
2 changes: 1 addition & 1 deletion src/api/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ mod ciphers;
mod emergency_access;
mod events;
mod folders;
mod organizations;
pub mod organizations;
mod public;
mod sends;
pub mod two_factor;
Expand Down
154 changes: 70 additions & 84 deletions src/api/core/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
UpdateType,
},
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
business::organization_logic,
db::{models::*, DbConn},
error::Error,
mail,
Expand Down Expand Up @@ -308,10 +309,19 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
}

// Called during the SSO enrollment
#[get("/organizations/<_identifier>/auto-enroll-status")]
fn get_auto_enroll_status(_identifier: &str) -> JsonResult {
// We return the org_id if it exists ortherwise we return the first associated with the user
#[get("/organizations/<identifier>/auto-enroll-status")]
async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
let org_id = match Organization::find_by_name(identifier, &mut conn).await.map(|o| o.uuid) {
Some(org_id) => org_id,
None => UserOrganization::find_main_user_org(&headers.user.uuid, &mut conn)
.await
.map(|uo| uo.org_uuid)
.unwrap_or_else(|| "null".to_string()),
};

Ok(Json(json!({
"Id": "null",
"Id": org_id,
"ResetPasswordEnabled": false, // Not implemented
})))
}
Expand Down Expand Up @@ -793,13 +803,25 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut
json!(ciphers_json)
}

// Endpoint called when the user select SSO login (body: `{ "email": "" }`).
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct OrgDomainDetails {
Email: String,
}

// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
// VaultWarden sso login is not linked to Org so we set a dummy value.
#[post("/organizations/domain/sso/details")]
fn get_org_domain_sso_details() -> JsonResult {
// So we either return an Org name associated to the user or a dummy value.
#[post("/organizations/domain/sso/details", data = "<data>")]
async fn get_org_domain_sso_details(data: JsonUpcase<OrgDomainDetails>, mut conn: DbConn) -> JsonResult {
let data: OrgDomainDetails = data.into_inner().data;

let identifier = match Organization::find_main_org_user_email(&data.Email, &mut conn).await {
Some(org) => org.name,
None => crate::sso::FAKE_IDENTIFIER.clone(),
};

Ok(Json(json!({
"organizationIdentifier": "vaultwarden",
"organizationIdentifier": identifier,
"ssoAvailable": CONFIG.sso_enabled()
})))
}
Expand Down Expand Up @@ -871,10 +893,10 @@ async fn post_org_keys(

#[derive(Deserialize)]
#[allow(non_snake_case)]
struct CollectionData {
Id: String,
ReadOnly: bool,
HidePasswords: bool,
pub struct CollectionData {
pub Id: String,
pub ReadOnly: bool,
pub HidePasswords: bool,
}

#[derive(Deserialize)]
Expand All @@ -897,17 +919,23 @@ async fn send_invite(
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,
Some(new_type) => new_type,
None => err!("Invalid type"),
};

if new_type != UserOrgType::User && headers.org_user_type != UserOrgType::Owner {
err!("Only Owners can invite Managers, Admins or Owners")
}

let org = match Organization::find_by_uuid(org_id, &mut conn).await {
Some(org) => org,
None => err!("Error looking up organization"),
};

let collections = data.Collections.into_iter().flatten().collect();

for email in data.Emails.iter() {
let email = email.to_lowercase();
let mut user_org_status = UserOrgStatus::Invited as i32;
let user = match User::find_by_mail(&email, &mut conn).await {
None => {
if !CONFIG.invitations_allowed() {
Expand All @@ -930,70 +958,24 @@ async fn send_invite(
Some(user) => {
if UserOrganization::find_by_user_and_org(&user.uuid, org_id, &mut conn).await.is_some() {
err!(format!("User already in organization: {email}"))
} else {
// automatically accept existing users if mail is disabled
if !CONFIG.mail_enabled() && !user.password_hash.is_empty() {
user_org_status = UserOrgStatus::Accepted as i32;
}
user
}
user
}
};

let mut new_user =
UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.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, &mut conn).await {
None => err!("Collection not found in Organization"),
Some(collection) => {
CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, &mut conn)
.await?;
}
}
}
}

new_user.save(&mut conn).await?;

for group in data.Groups.iter() {
let mut group_entry = GroupUser::new(String::from(group), user.uuid.clone());
group_entry.save(&mut conn).await?;
}

log_event(
EventType::OrganizationUserInvited as i32,
&new_user.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
organization_logic::invite(
&user,
&headers.device,
&headers.ip,
&org,
new_type,
&data.Groups,
data.AccessAll.unwrap_or(false),
&collections,
headers.user.email.clone(),
&mut conn,
)
.await;

if CONFIG.mail_enabled() {
let org_name = match Organization::find_by_uuid(org_id, &mut conn).await {
Some(org) => org.name,
None => err!("Error looking up organization"),
};

mail::send_invite(
&email,
&user.uuid,
Some(String::from(org_id)),
Some(new_user.uuid),
&org_name,
Some(headers.user.email.clone()),
)
.await?;
}
.await?;
}

Ok(())
Expand Down Expand Up @@ -1709,20 +1691,24 @@ async fn list_policies_invited_user(org_id: &str, userId: &str, mut conn: DbConn
}

// Called during the SSO enrollment.
// Return the org policy if it exists, otherwise use the default one.
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
fn get_policy_master_password(org_id: &str, _headers: Headers) -> JsonResult {
let data = match CONFIG.sso_master_password_policy() {
Some(policy) => policy,
None => "null".to_string(),
};
async fn get_policy_master_password(org_id: &str, _headers: Headers, mut conn: DbConn) -> JsonResult {
let policy =
OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| {
let data = match CONFIG.sso_master_password_policy() {
Some(policy) => policy,
None => "null".to_string(),
};

let policy = OrgPolicy {
uuid: String::from(org_id),
org_uuid: String::from(org_id),
atype: OrgPolicyType::MasterPassword as i32,
enabled: CONFIG.sso_master_password_policy().is_some(),
data,
};
OrgPolicy {
uuid: String::from(org_id),
org_uuid: String::from(org_id),
atype: OrgPolicyType::MasterPassword as i32,
enabled: CONFIG.sso_master_password_policy().is_some(),
data,
}
});

Ok(Json(policy.to_json()))
}
Expand Down
2 changes: 2 additions & 0 deletions src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ async fn _sso_login(
// Set the user_uuid here to be passed back used for event logging.
*user_uuid = Some(user.uuid.clone());

sso::sync_groups(&user, &device, ip, &auth_user.groups, conn).await?;

if auth_user.is_admin() {
info!("User {} logged with admin cookie", user.email);
cookies.add(admin::create_admin_cookie());
Expand Down
1 change: 1 addition & 0 deletions src/business/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod organization_logic;
88 changes: 88 additions & 0 deletions src/business/organization_logic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use crate::{
api::{core::log_event, core::organizations::CollectionData, ApiResult},
auth::ClientIp,
db::models::*,
db::DbConn,
mail, CONFIG,
};

#[allow(clippy::too_many_arguments)]
pub async fn invite(
user: &User,
device: &Device,
ip: &ClientIp,
org: &Organization,
user_org_type: UserOrgType,
groups: &Vec<String>,
access_all: bool,
collections: &Vec<CollectionData>,
invited_by_email: String,
conn: &mut DbConn,
) -> ApiResult<()> {
let mut user_org_status = UserOrgStatus::Invited;

// automatically accept existing users if mail is disabled
if (!CONFIG.mail_enabled() && !user.password_hash.is_empty())
|| (CONFIG.sso_enabled() && CONFIG.sso_acceptall_invites())
{
user_org_status = UserOrgStatus::Accepted;
}

let mut new_uo = UserOrganization::new(user.uuid.clone(), org.uuid.clone(), Some(invited_by_email.clone()));
new_uo.access_all = access_all;
new_uo.atype = user_org_type as i32;
new_uo.status = user_org_status as i32;

// If no accessAll, add the collections received
if !access_all {
for col in collections {
match Collection::find_by_uuid_and_org(&col.Id, &org.uuid, conn).await {
None => err!("Collection not found in Organization"),
Some(collection) => {
CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, conn).await?;
}
}
}
}

new_uo.save(conn).await?;

for group in groups {
let mut group_entry = GroupUser::new(group.clone(), user.uuid.clone());
group_entry.save(conn).await?;
}

log_event(
EventType::OrganizationUserInvited as i32,
&new_uo.uuid,
&org.uuid,
&user.uuid,
device.atype,
&ip.ip,
conn,
)
.await;

if CONFIG.mail_enabled() {
match user_org_status {
UserOrgStatus::Invited => {
mail::send_invite(
&user.email,
&user.uuid,
Some(org.uuid.clone()),
Some(new_uo.uuid),
&org.name,
new_uo.invited_by_email.clone(),
)
.await?
}
UserOrgStatus::Accepted => {
mail::send_org_enrolled(&user.email, &org.name, Some(invited_by_email.clone())).await?;
mail::send_invite_accepted(&user.email, &invited_by_email, &org.name).await?;
}
UserOrgStatus::Revoked | UserOrgStatus::Confirmed => (),
}
}

Ok(())
}
Loading

0 comments on commit e718547

Please sign in to comment.