1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Resolve "Add ability to change mapping in SAML SSO"

This commit is contained in:
Davide Silvestri 2024-11-11 15:36:56 +00:00
parent ddd557f0dc
commit c9eaf34c71
10 changed files with 332 additions and 34 deletions
changelog/entries/unreleased/feature
enterprise
backend
web-frontend/modules/baserow_enterprise
components/admin/forms
locales
web-frontend
locales
modules/core/assets/scss/components

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add ability to change attributes in SAML response payload.",
"issue_number": 3155,
"bullet_points": [],
"created_at": "2024-11-08"
}

View file

@ -0,0 +1,43 @@
# Generated by Django 5.0.9 on 2024-11-07 21:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("baserow_enterprise", "0032_gitlabissuesdatasync"),
]
operations = [
migrations.AddField(
model_name="samlauthprovidermodel",
name="email_attr_key",
field=models.CharField(
db_default="user.email",
default="user.email",
help_text="The key in the SAML response that contains the email address of the user. If this is not set, the email will be taken from the user's profile.",
max_length=32,
),
),
migrations.AddField(
model_name="samlauthprovidermodel",
name="first_name_attr_key",
field=models.CharField(
db_default="user.first_name",
default="user.first_name",
help_text="The key in the SAML response that contains the first name of the user. If this is not set, the first name will be taken from the user's profile.",
max_length=32,
),
),
migrations.AddField(
model_name="samlauthprovidermodel",
name="last_name_attr_key",
field=models.CharField(
blank=True,
db_default="user.last_name",
default="user.last_name",
help_text="The key in the SAML response that contains the last name of the user. If this is not set, the last name will be taken from the user's profile.",
max_length=32,
),
),
]

View file

@ -40,8 +40,19 @@ class SamlAuthProviderType(AuthProviderType):
"enabled",
"metadata",
"is_verified",
"email_attr_key",
"first_name_attr_key",
"last_name_attr_key",
]
serializer_field_names = [
"domain",
"metadata",
"enabled",
"is_verified",
"email_attr_key",
"first_name_attr_key",
"last_name_attr_key",
]
serializer_field_names = ["domain", "metadata", "enabled", "is_verified"]
serializer_field_overrides = {
"domain": serializers.CharField(
validators=[validate_domain],
@ -62,6 +73,26 @@ class SamlAuthProviderType(AuthProviderType):
read_only=True,
help_text="Whether or not a user sign in correctly with this SAML provider.",
),
"email_attr_key": serializers.CharField(
required=False,
help_text=(
"The key in the SAML response that contains the email address of the user."
),
),
"first_name_attr_key": serializers.CharField(
required=False,
help_text=(
"The key in the SAML response that contains the first name of the user."
),
),
"last_name_attr_key": serializers.CharField(
required=False,
allow_blank=True,
help_text=(
"The key in the SAML response that contains the last name of the user. "
"If this is not set, the first name attr will be used as full name."
),
),
}
api_exceptions_map: ExceptionMappingType = {
SamlProviderForDomainAlreadyExists: ERROR_SAML_PROVIDER_FOR_DOMAIN_ALREADY_EXISTS

View file

@ -130,6 +130,7 @@ class SamlAuthProviderHandler:
@classmethod
def get_user_info_from_authn_user_identity(
cls,
saml_auth_provider: SamlAuthProviderModel,
authn_identity: Dict[str, str],
saml_request_data: Optional[Dict[str, str]] = None,
) -> UserInfo:
@ -139,6 +140,7 @@ class SamlAuthProviderHandler:
SAML request (e.g. language and group invitation token) to
create/retrieve the correct user.
:param saml_auth_provider: The related saml auth provider model instance.
:param authn_identity: The dict returned by
`authn_response.get_identity()` that contains the user identity
information.
@ -148,17 +150,22 @@ class SamlAuthProviderHandler:
create/retrieve the correct user.
"""
email_key = saml_auth_provider.email_attr_key
first_name_key = saml_auth_provider.first_name_attr_key
last_name_key = saml_auth_provider.last_name_attr_key
saml_request_data = saml_request_data or {}
email = authn_identity["user.email"][0]
if "user.name" in authn_identity:
name = authn_identity["user.name"][0]
elif "user.first_name" in authn_identity:
first_name = authn_identity["user.first_name"][0]
if "user.last_name" in authn_identity:
last_name = authn_identity["user.last_name"][0]
email = authn_identity[email_key][0]
if first_name_key in authn_identity:
first_name = authn_identity[first_name_key][0]
if last_name_key in authn_identity:
last_name = authn_identity[last_name_key][0]
else:
last_name = ""
name = f"{first_name} {last_name}".strip()
elif "user.name" in authn_identity:
# kept for backward compatibility
name = authn_identity["user.name"][0]
else:
name = email
return UserInfo(email, name, **saml_request_data)
@ -233,7 +240,7 @@ class SamlAuthProviderHandler:
cls.check_authn_response_is_valid_or_raise(authn_response)
authn_user_identity = authn_response.get_identity()
idp_provided_user_info = cls.get_user_info_from_authn_user_identity(
authn_user_identity, saml_request_data
saml_auth_provider, authn_user_identity, saml_request_data
)
except (InvalidSamlConfiguration, InvalidSamlResponse) as exc:
logger.exception(exc)
@ -242,10 +249,11 @@ class SamlAuthProviderHandler:
logger.exception(exc)
raise InvalidSamlResponse(str(exc))
saml_provider_type = saml_auth_provider.get_type()
(
user,
_,
) = saml_auth_provider.get_type().get_or_create_user_and_sign_in(
) = saml_provider_type.get_or_create_user_and_sign_in(
saml_auth_provider, idp_provided_user_info
)

View file

@ -17,6 +17,34 @@ class SamlAuthProviderModel(AuthProviderModel):
"make SAML the only authentication provider. "
),
)
email_attr_key = models.CharField(
max_length=32,
default="user.email",
db_default="user.email",
help_text=(
"The key in the SAML response that contains the email address of the user. "
"If this is not set, the email will be taken from the user's profile."
),
)
first_name_attr_key = models.CharField(
max_length=32,
default="user.first_name",
db_default="user.first_name",
help_text=(
"The key in the SAML response that contains the first name of the user. "
"If this is not set, the first name will be taken from the user's profile."
),
)
last_name_attr_key = models.CharField(
max_length=32,
default="user.last_name",
db_default="user.last_name",
blank=True,
help_text=(
"The key in the SAML response that contains the last name of the user. "
"If this is not set, the last name will be taken from the user's profile."
),
)
@receiver(pre_save, sender=SamlAuthProviderModel)

View file

@ -7,29 +7,48 @@ from baserow_enterprise.sso.saml.handler import SamlAuthProviderHandler
@pytest.mark.django_db()
@pytest.mark.parametrize(
"email_attr_key,first_name_attr_key,last_name_attr_key",
[
("user.email", "user.first_name", "user.last_name"),
("user.email", "user.first_name", ""),
("email", "name", "giveName"),
("email", "name", ""),
],
)
@override_settings(DEBUG=True)
def test_get_user_info_from_authn_user_identity():
def test_get_user_info_from_authn_user_identity(
email_attr_key, first_name_attr_key, last_name_attr_key, enterprise_data_fixture
):
auth_provider = enterprise_data_fixture.create_saml_auth_provider(
domain="test1.com",
email_attr_key=email_attr_key,
first_name_attr_key=first_name_attr_key,
last_name_attr_key=last_name_attr_key,
)
user_info = SamlAuthProviderHandler.get_user_info_from_authn_user_identity(
{"user.email": ["some@email.com"], "user.name": ["John"]}
auth_provider, {email_attr_key: ["some@email.com"], "user.name": ["John"]}
)
assert user_info.email == "some@email.com"
assert user_info.name == "John"
user_info = SamlAuthProviderHandler.get_user_info_from_authn_user_identity(
auth_provider,
{
"user.email": ["some@email.com"],
"user.first_name": ["John"],
}
email_attr_key: ["some@email.com"],
first_name_attr_key: ["John"],
},
)
assert user_info.email == "some@email.com"
assert user_info.name == "John"
user_info = SamlAuthProviderHandler.get_user_info_from_authn_user_identity(
auth_provider,
{
"user.email": ["some@email.com"],
"user.first_name": ["John"],
"user.last_name": ["Doe"],
}
email_attr_key: ["some@email.com"],
first_name_attr_key: ["John"],
last_name_attr_key: ["Doe"],
},
)
assert user_info.email == "some@email.com"
assert user_info.name == "John Doe"
@ -37,13 +56,14 @@ def test_get_user_info_from_authn_user_identity():
assert user_info.workspace_invitation_token is None
user_info = SamlAuthProviderHandler.get_user_info_from_authn_user_identity(
{"user.email": ["some@email.com"]}
auth_provider, {email_attr_key: ["some@email.com"]}
)
assert user_info.email == "some@email.com"
assert user_info.name == "some@email.com"
user_info = SamlAuthProviderHandler.get_user_info_from_authn_user_identity(
{"user.email": ["some@email.com"], "user.name": ["John Doe"]},
auth_provider,
{email_attr_key: ["some@email.com"], "user.name": ["John Doe"]},
{"language": "it", "workspace_invitation_token": "1234"},
)
assert user_info.email == "some@email.com"

View file

@ -3,17 +3,19 @@
<FormGroup
small-label
required
:label="$t('samlSettingsForm.domain')"
:error="fieldHasErrors('domain')"
class="margin-bottom-2"
>
<img
v-if="authProvider && authProvider.is_verified"
class="control__label-right-icon"
:alt="$t('samlSettingsForm.providerIsVerified')"
:title="$t('samlSettingsForm.providerIsVerified')"
:src="getVerifiedIcon()"
/>
<template #label>
{{ $t('samlSettingsForm.domain') }}
<img
v-if="authProvider && authProvider.is_verified"
class="control__label-right-icon"
:alt="$t('samlSettingsForm.providerIsVerified')"
:title="$t('samlSettingsForm.providerIsVerified')"
:src="getVerifiedIcon()"
/>
</template>
<FormInput
ref="domain"
@ -24,7 +26,6 @@
@input="serverErrors.domain = null"
@blur="$v.values.domain.$touch()"
></FormInput>
<template #error>
<span v-if="$v.values.domain.$dirty && !$v.values.domain.required">
{{ $t('error.requiredField') }}
@ -90,14 +91,112 @@
<code>{{ getAcsUrl() }}</code>
</FormGroup>
<Expandable card class="margin-bottom-2">
<template #header="{ toggle, expanded }">
<div class="flex flex-100 justify-content-space-between">
<div>
<div>
<a @click="toggle">
{{ $t('samlSettingsForm.samlResponseSettings') }}
<i
:class="
expanded
? 'iconoir-nav-arrow-down'
: 'iconoir-nav-arrow-right'
"
></i>
</a>
</div>
</div>
<div>
{{
usingDefaultAttrs()
? $t('samlSettingsForm.defaultAttrs')
: $t('samlSettingsForm.customAttrs')
}}
</div>
</div>
</template>
<template #default>
<FormGroup
small-label
required
:error="fieldHasErrors('email_attr_key')"
:label="$t('samlSettingsForm.emailAttrKey')"
class="margin-bottom-2"
>
<FormInput
ref="email_attr_key"
v-model="values.email_attr_key"
:error="fieldHasErrors('email_attr_key')"
:placeholder="defaultAttrs.email_attr_key"
@blur="$v.values.email_attr_key.$touch()"
></FormInput>
<template #helper>
{{ $t('samlSettingsForm.emailAttrKeyHelper') }}
</template>
<template #error>{{ getFieldErrorMsg('email_attr_key') }}</template>
</FormGroup>
<FormGroup
small-label
required
:error="fieldHasErrors('first_name_attr_key')"
:label="$t('samlSettingsForm.firstNameAttrKey')"
class="margin-bottom-2"
>
<FormInput
ref="firstNameAttrKey"
v-model="values.first_name_attr_key"
:error="fieldHasErrors('first_name_attr_key')"
:placeholder="defaultAttrs.first_name_attr_key"
@blur="$v.values.first_name_attr_key.$touch()"
></FormInput>
<template #helper>
{{ $t('samlSettingsForm.firstNameAttrKeyHelper') }}
</template>
<template #error>{{
getFieldErrorMsg('first_name_attr_key')
}}</template>
</FormGroup>
<FormGroup
small-label
required
:error="fieldHasErrors('last_name_attr_key')"
:label="$t('samlSettingsForm.lastNameAttrKey')"
class="margin-bottom-2"
>
<FormInput
ref="lastNameAttrKey"
v-model="values.last_name_attr_key"
:error="fieldHasErrors('last_name_attr_key')"
:placeholder="defaultAttrs.last_name_attr_key"
@blur="$v.values.last_name_attr_key.$touch()"
></FormInput>
<template #helper>
{{ $t('samlSettingsForm.lastNameAttrKeyHelper') }}
</template>
<template #error>{{
getFieldErrorMsg('last_name_attr_key')
}}</template>
</FormGroup>
</template>
</Expandable>
<slot></slot>
</form>
</template>
<script>
import { required } from 'vuelidate/lib/validators'
import { maxLength, required, helpers } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
const alphanumericDotDashUnderscore = helpers.regex(
'alphanumericDotDashUnderscore',
/^[a-zA-Z0-9._-]*$/
)
export default {
name: 'SamlSettingsForm',
mixins: [form],
@ -120,6 +219,9 @@ export default {
values: {
domain: '',
metadata: '',
email_attr_key: '',
first_name_attr_key: '',
last_name_attr_key: '',
},
}
},
@ -134,15 +236,52 @@ export default {
)
.map((authProvider) => authProvider.domain)
},
defaultAttrs() {
return {
email_attr_key: 'user.email',
first_name_attr_key: 'user.first_name',
last_name_attr_key: 'user.last_name',
}
},
type() {
return this.authProviderType || this.authProvider.type
},
},
methods: {
usingDefaultAttrs() {
return (
this.values.email_attr_key === this.defaultAttrs.email_attr_key &&
this.values.first_name_attr_key ===
this.defaultAttrs.first_name_attr_key &&
this.values.last_name_attr_key === this.defaultAttrs.last_name_attr_key
)
},
getDefaultValues() {
const authProviderAttrs = {
email_attr_key: this.authProvider.email_attr_key,
first_name_attr_key: this.authProvider.first_name_attr_key,
last_name_attr_key: this.authProvider.last_name_attr_key,
}
const samlAttrs = this.authProvider.id
? authProviderAttrs
: this.defaultAttrs
return {
domain: this.authProvider.domain || '',
metadata: this.authProvider.metadata || '',
...samlAttrs,
}
},
getFieldErrorMsg(fieldName) {
if (!this.$v.values[fieldName].$dirty) {
return ''
} else if (!this.$v.values[fieldName].maxLength) {
return this.$t('error.maxLength', {
max: this.$v.values[fieldName].$params.maxLength.max,
})
} else if (!this.$v.values[fieldName].invalid) {
return this.$t('error.invalidCharacters')
} else if (this.$v.values[fieldName].required === false) {
return this.$t('error.requiredField')
}
},
getRelayStateUrl() {
@ -179,6 +318,20 @@ export default {
values: {
domain: { required, mustHaveUniqueDomain: this.mustHaveUniqueDomain },
metadata: { required },
email_attr_key: {
required,
maxLength: maxLength(32),
invalid: alphanumericDotDashUnderscore,
},
first_name_attr_key: {
required,
maxLength: maxLength(32),
invalid: alphanumericDotDashUnderscore,
},
last_name_attr_key: {
maxLength: maxLength(32),
invalid: alphanumericDotDashUnderscore,
},
},
}
},

View file

@ -111,7 +111,16 @@
"invalidMetadata": "Invalid XML metadata for the Identity Provider",
"relayStateUrl": "Default Relay State URL",
"acsUrl": "Single Sign On URL",
"providerIsVerified": "The provider has been verified"
"providerIsVerified": "The provider has been verified",
"samlResponseSettings": "SAML Response Attributes",
"defaultAttrs": "Default",
"customAttrs": "Custom",
"emailAttrKey": "Email",
"emailAttrKeyHelper": "The email address that will be used as username for the user.",
"firstNameAttrKey": "First name",
"firstNameAttrKeyHelper": "The first name of the user or the full name if 'Last name' is not provided.",
"lastNameAttrKey": "Last name",
"lastNameAttrKeyHelper": "Last name of the user (optional). Clear this value to provide the full name via 'First name'."
},
"createSettingsAuthProviderModal": {
"title": "Add a new {type}"

View file

@ -88,6 +88,7 @@
"minLength": "A minimum of {min} characters is required here.",
"maxLength": "A maximum of {max} characters is allowed here.",
"minMaxLength": "A minimum of {min} and a maximum of {max} characters is allowed here.",
"invalidCharacters": "This field contains invalid characters.",
"requiredField": "This field is required.",
"integerField": "The field must be an integer.",
"decimalField": "The field must be a decimal.",

View file

@ -93,9 +93,7 @@
}
.control__label-right-icon {
margin-left: 6px;
height: 16px;
top: 3px;
position: relative;
}