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 May 20, 2024
1 parent 47a386b commit b613589
Show file tree
Hide file tree
Showing 14 changed files with 393 additions and 103 deletions.
10 changes: 10 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,16 @@
# SSO_ROLES_DEFAULT_TO_USER=true
## Id 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
## Id token path to read groups
# SSO_ORGANIZATIONS_TOKEN_PATH=/groups
## Organization ID mapping
# SSO_ORGANIZATIONS_ID_MAPPING="ProviderId:VaultwardenId;"
## Grant access to all the organization collections
# SSO_ORGANIZATIONS_ALL_COLLECTIONS=true
## Client cache for discovery endpoint. Duration in seconds (0 to disable).
# SSO_CLIENT_CACHE_EXPIRATION=0
## Log all the tokens, `LOG_LEVEL=debug` or `LOG_LEVEL_OVERRIDE=vaultwarden::sso=debug` need to be set
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
Goal is to help testing code for the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899).
Based on [Timshel/sso-support](https://github.com/Timshel/vaultwarden/tree/sso-support)

:warning: Branch will be rebased and forced-pushed from time to time. :warning:
#### :warning: Branch will be rebased and forced-pushed when updated. :warning:

## Additionnal features

This branch now contain additionnal features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review.
This branch now contain features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review.

### Role mapping

Expand All @@ -20,6 +20,34 @@ 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 (Use group name mapping if `SSO_ORGANIZATIONS_ID_MAPPING` is defined).
- if mail are activated 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.
- Otherwise just add the user to the Organization
- An admin will have to validate the user to confirm the user joining the org.

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_SCOPES`: Optional scope override if additionnal scopes are needed, default is `"email profile"`
- `SSO_ORGANIZATIONS_INVITE`: control if the mapping is done, default is `false`
- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token, default is `/groups`
- `SSO_ORGANIZATIONS_ID_MAPPING`: Optional, allow to map provider group to a Vaultwarden organization `uuid` (default `""`, format: `"ProviderId:VaultwardenId;"`)

### Experimental Version

Made a version which additionnaly allow to run the server without storing the master password (it's still required just not sent to the server).
Expand All @@ -31,7 +59,7 @@ Change the docker files to package both front-end from [Timshel/oidc_web_builds]
\
By default it will use the release which only make the `sso` button visible.

If you want to use the version which additionally change the default redirection to `/sso` and fix organization invitation to persist.
If you want to use the version with the additional features mentionned, default redirection to `/sso` and fix organization invitation.
You need to pass an env variable: `-e SSO_FRONTEND='override'` (cf [start.sh](docker/start.sh)).

Docker images available at:
Expand Down
4 changes: 4 additions & 0 deletions SSO.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ 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 Id token
- `SSO_ORGANIZATIONS_INVIT`: control if the mapping is done, default is `false`
- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Id token
- `SSO_ORGANIZATIONS_ID_MAPPING`: Optional, allow to map provider group to a Vaultwarden organization `uuid` (default `""`, format: `"ProviderId:VaultwardenId;"`)
- `SSO_ORGANIZATIONS_ALL_COLLECTIONS`: Grant access to all collections, default is `true`
- `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`);
- `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`, `LOG_LEVEL=debug` or `LOG_LEVEL_OVERRIDE=vaultwarden::sso=debug` need to be set)

Expand Down
21 changes: 21 additions & 0 deletions docker/keycloak/keycloak_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,31 @@ kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID/prot
-s 'config."access.token.claim"=false' \
-s 'config."userinfo.token.claim"=true'

## Create group mapping client scope
TEST_GROUPS_CLIENT_SCOPE_ID=$(kcadm.sh create -r "$TEST_REALM" client-scopes -s name=groups -s protocol=openid-connect -i)
kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_GROUPS_CLIENT_SCOPE_ID/protocol-mappers/models" \
-s name=Groups \
-s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper \
-s consentRequired=false \
-s 'config."claim.name"=groups' \
-s 'config."full.path"=false' \
-s 'config."id.token.claim"=true' \
-s 'config."access.token.claim"=true' \
-s 'config."userinfo.token.claim"=true'

TEST_GROUP_ID=$(kcadm.sh create -r "$TEST_REALM" groups -s name=Test -i)

TEST_CLIENT_ID=$(kcadm.sh create -r "$TEST_REALM" clients -s "name=VaultWarden" -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i)

## ADD Role mapping scope
kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_CLIENT_ROLES_SCOPE_ID\"]}"
kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID"

## ADD Group mapping scope
kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_GROUPS_CLIENT_SCOPE_ID\"]}"
kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_GROUPS_CLIENT_SCOPE_ID"

## CREATE TEST ROLES
kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=admin -s 'description=Admin role'
kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s 'description=Admin role'
Expand All @@ -54,11 +73,13 @@ kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s

TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i)
kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/reset-password" -s type=password -s "value=$TEST_USER_PASSWORD" -n
kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/groups/$TEST_GROUP_ID"
kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER" --cid "$TEST_CLIENT_ID" --rolename admin


TEST_USER_2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER_2" -s "email=$TEST_USER_2_MAIL" -s emailVerified=true -s enabled=true -i)
kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_2_ID/reset-password" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n
kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_2_ID/groups/$TEST_GROUP_ID"
kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER_2" --cid "$TEST_CLIENT_ID" --rolename user

touch $CANARY
Expand Down
3 changes: 3 additions & 0 deletions src/api/core/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ async fn post_set_password(data: JsonUpcase<SetPasswordData>, headers: Headers,

if CONFIG.mail_enabled() {
mail::send_set_password(&user.email.to_lowercase(), &user.name).await?;
} else if CONFIG.sso_enabled() {
// Since the user now has a password we can confirm invitations.
UserOrganization::confirm_user_invitations(&user.uuid, &mut conn).await?;
}

user.save(&mut conn).await?;
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
156 changes: 70 additions & 86 deletions src/api/core/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::{
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, PasswordOrOtpData, UpdateType,
},
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
business::organization_logic,
db::{models::*, DbConn},
error::Error,
mail,
Expand Down Expand Up @@ -307,12 +308,19 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
}

// Called during the SSO enrollment
// The `_identifier` should be the harcoded value returned by `get_org_domain_sso_details`
// The returned `Id` will then be passed to `get_policy_master_password` which will mainly ignore it
#[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": "_",
"Id": org_id,
"ResetPasswordEnabled": false, // Not implemented
})))
}
Expand Down Expand Up @@ -782,13 +790,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.to_string(),
};

Ok(Json(json!({
"organizationIdentifier": "vaultwarden",
"organizationIdentifier": identifier,
"ssoAvailable": CONFIG.sso_enabled()
})))
}
Expand Down Expand Up @@ -860,10 +880,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 @@ -886,17 +906,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 @@ -919,70 +945,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 @@ -1710,20 +1690,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 @@ -260,6 +260,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;
Loading

0 comments on commit b613589

Please sign in to comment.