Skip to content

Commit

Permalink
Add support for role mappings on custom SSO providers. (#920)
Browse files Browse the repository at this point in the history
This is done using the `role_mappings` property.
Roles to be mapped are gotten from the 'warp_groups` oidc claim:
```sso_providers: 
  - name: custom_sso
    label: Custom SSO
    provider:
      type: custom
      client_id: <client_id>
      client_secret: <client_secret>
      issuer_url: <issuer_url>
      scopes: ["email", "profile", "openid", "warp_groups"] #warp_groups is scope name to request for my demo case, which adds a "warpgate_groups" claim to the userinfo
      role_mappings: 
        - ["warpgate:admin", "warpgate:admin"]
```
This maps the `warpgate:admin` group from OIDC to the `warpgate:admin`
role.

This [video on YouTube](https://youtu.be/XCYSGGCgk9Q) demonstrates the
functionality

---------

Co-authored-by: Eugene <[email protected]>
  • Loading branch information
Skyler84 and Eugeny authored Mar 23, 2024
1 parent 1395d64 commit 916d51a
Show file tree
Hide file tree
Showing 16 changed files with 310 additions and 42 deletions.
37 changes: 19 additions & 18 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions tests/oidc-mock/clients-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[
{
"ClientId": "client-credentials-mock-client",
"ClientSecrets": ["client-credentials-mock-client-secret"],
"Description": "Client for client credentials flow",
"AllowedGrantTypes": ["client_credentials", "authorization_code"],
"AllowedScopes": ["openid", "profile", "email", "warpgate-scope"],
"RedirectUris": ["https://127.0.0.1:8888/@warpgate/api/sso/return"],
"ClientClaimsPrefix": "",
"Claims": [
{
"Type": "string_claim",
"Value": "string_claim_value",
"ValueType": "string"
},
{
"Type": "json_claim",
"Value": "[\"value1\", \"value2\"]",
"ValueType": "json"
}
]
}
]
73 changes: 73 additions & 0 deletions tests/oidc-mock/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
version: '3'
services:
oidc-server-mock:
container_name: oidc-server-mock
image: ghcr.io/soluto/oidc-server-mock:latest
ports:
- '4011:80'
environment:
ASPNETCORE_ENVIRONMENT: Development
SERVER_OPTIONS_INLINE: |
{
"AccessTokenJwtType": "JWT",
"Discovery": {
"ShowKeySet": true
},
"Authentication": {
"CookieSameSiteMode": "Lax",
"CheckSessionCookieSameSiteMode": "Lax"
}
}
LOGIN_OPTIONS_INLINE: |
{
"AllowRememberLogin": false
}
LOGOUT_OPTIONS_INLINE: |
{
"AutomaticRedirectAfterSignOut": true
}
API_SCOPES_INLINE: |
- Name: some-app-scope-1
- Name: some-app-scope-2
IDENTITY_RESOURCES_INLINE: |
- Name: warpgate-scope
ClaimTypes:
- warpgate_groups
# API_RESOURCES_INLINE: |
# - Name: wapgate_groups
# Scopes:
# - warpgate
USERS_CONFIGURATION_INLINE: |
[
{
"SubjectId":"1",
"Username":"User1",
"Password":"pwd",
"Claims": [
{
"Type": "name",
"Value": "Sam Tailor",
"ValueType": "string"
},
{
"Type": "email",
"Value": "[email protected]",
"ValueType": "string"
},
{
"Type": "warpgate_groups",
"Value": "[\"qa\", \"unknown\"]",
"ValueType": "json"
}
]
}
]
CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json
ASPNET_SERVICES_OPTIONS_INLINE: |
{
"ForwardedHeadersOptions": {
"ForwardedHeaders" : "All"
}
}
volumes:
- .:/tmp/config:ro
6 changes: 4 additions & 2 deletions warpgate-common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ pub enum WarpgateError {
InvalidCredentialType,
#[error(transparent)]
Other(Box<dyn Error + Send + Sync>),
#[error("user not found")]
UserNotFound,
#[error("user {0} not found")]
UserNotFound(String),
#[error("role {0} not found")]
RoleNotFound(String),
#[error("failed to parse URL: {0}")]
UrlParse(#[from] url::ParseError),
#[error("deserialization failed: {0}")]
Expand Down
2 changes: 1 addition & 1 deletion warpgate-core/src/auth_state_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ impl AuthStateStore {
.get_credential_policy(username, supported_credential_types)
.await?;
let Some(policy) = policy else {
return Err(WarpgateError::UserNotFound);
return Err(WarpgateError::UserNotFound(username.into()))
};

let state = AuthState::new(
Expand Down
69 changes: 67 additions & 2 deletions warpgate-core/src/config_providers/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use std::sync::Arc;

use async_trait::async_trait;
use data_encoding::BASE64;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, QueryOrder};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter,
QueryOrder, Set,
};
use tokio::sync::Mutex;
use tracing::*;
use warpgate_common::auth::{
Expand All @@ -17,7 +20,7 @@ use warpgate_common::{
UserPasswordCredential, UserPublicKeyCredential, UserSsoCredential, UserTotpCredential,
WarpgateError,
};
use warpgate_db_entities::{Role, Target, User};
use warpgate_db_entities::{Role, Target, User, UserRoleAssignment};

use super::ConfigProvider;

Expand Down Expand Up @@ -294,4 +297,66 @@ impl ConfigProvider for DatabaseConfigProvider {

Ok(intersect)
}

async fn apply_sso_role_mappings(
&mut self,
username: &str,
managed_role_names: Option<Vec<String>>,
assigned_role_names: Vec<String>,
) -> Result<(), WarpgateError> {
let db = self.db.lock().await;

let user = User::Entity::find()
.filter(User::Column::Username.eq(username))
.one(&*db)
.await
.map_err(WarpgateError::from)?
.ok_or_else(|| WarpgateError::UserNotFound(username.into()))?;

let managed_role_names = match managed_role_names {
Some(x) => x,
None => Role::Entity::find()
.all(&*db)
.await?
.into_iter()
.map(|x| x.name)
.collect(),
};

for role_name in managed_role_names.into_iter() {
let role = Role::Entity::find()
.filter(Role::Column::Name.eq(role_name.clone()))
.one(&*db)
.await
.map_err(WarpgateError::from)?
.ok_or_else(|| WarpgateError::RoleNotFound(role_name.clone()))?;

let assignment = UserRoleAssignment::Entity::find()
.filter(UserRoleAssignment::Column::UserId.eq(user.id))
.filter(UserRoleAssignment::Column::RoleId.eq(role.id))
.one(&*db)
.await
.map_err(WarpgateError::from)?;

match (assignment, assigned_role_names.contains(&role_name)) {
(None, true) => {
info!("Adding role {role_name} for user {username} (from SSO)");
let values = UserRoleAssignment::ActiveModel {
user_id: Set(user.id),
role_id: Set(role.id),
..Default::default()
};

values.insert(&*db).await.map_err(WarpgateError::from)?;
}
(Some(assignment), false) => {
info!("Removing role {role_name} for user {username} (from SSO)");
assignment.delete(&*db).await.map_err(WarpgateError::from)?;
}
_ => (),
}
}

Ok(())
}
}
9 changes: 9 additions & 0 deletions warpgate-core/src/config_providers/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,13 @@ impl ConfigProvider for FileConfigProvider {

Ok(intersect)
}

async fn apply_sso_role_mappings(
&mut self,
_username: &str,
_managed_role_names: Option<Vec<String>>,
_assigned_role_names: Vec<String>,
) -> Result<(), WarpgateError> {
Ok(())
}
}
7 changes: 7 additions & 0 deletions warpgate-core/src/config_providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ pub trait ConfigProvider {
client_credential: &AuthCredential,
) -> Result<Option<String>, WarpgateError>;

async fn apply_sso_role_mappings(
&mut self,
username: &str,
managed_role_names: Option<Vec<String>>,
active_role_names: Vec<String>,
) -> Result<(), WarpgateError>;

async fn get_credential_policy(
&mut self,
username: &str,
Expand Down
2 changes: 1 addition & 1 deletion warpgate-protocol-http/src/api/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ impl Api {
)
.await
{
Err(WarpgateError::UserNotFound) => {
Err(WarpgateError::UserNotFound(_)) => {
return Ok(LoginResponse::Failure(Json(LoginFailureResponse {
state: ApiAuthState::Failed,
})))
Expand Down
33 changes: 33 additions & 0 deletions warpgate-protocol-http/src/api/sso_provider_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ impl Api {

info!("SSO login as {email}");

let provider = context.provider.clone();
let cred = AuthCredential::Sso {
provider: context.provider,
email: email.clone(),
Expand Down Expand Up @@ -210,6 +211,38 @@ impl Api {
authorize_session(req, username).await?;
}

let providers_config = services.config.lock().await.store.sso_providers.clone();
let mut iter = providers_config.iter();
let Some(provider_config) = iter.find(|x| x.name == provider) else {
return Ok(Err(format!("No provider matching {provider}")));
};

let mappings = provider_config.provider.role_mappings();
if let Some(remote_groups) = response.groups {
// If mappings is not set, all groups are subject to sync
// and names won't be remapped
let managed_role_names = mappings
.as_ref()
.map(|m| m.iter().map(|x| x.1.clone()).collect::<Vec<_>>());

let active_role_names: Vec<_> = remote_groups
.iter()
.filter_map({
|r| {
if let Some(ref mappings) = mappings {
mappings.get(r).cloned()
} else {
Some(r.clone())
}
}
})
.collect();

debug!("SSO role mappings for {username}: active={active_role_names:?}, managed={managed_role_names:?}");
cp.apply_sso_role_mappings(&username, managed_role_names, active_role_names)
.await?;
}

Ok(Ok(context
.next_url
.as_deref()
Expand Down
Loading

0 comments on commit 916d51a

Please sign in to comment.