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:
parent
ddd557f0dc
commit
c9eaf34c71
10 changed files with 332 additions and 34 deletions
changelog/entries/unreleased/feature
enterprise
backend
src/baserow_enterprise
migrations
sso/saml
tests/baserow_enterprise_tests/sso/saml
web-frontend/modules/baserow_enterprise
web-frontend
|
@ -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"
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -93,9 +93,7 @@
|
|||
}
|
||||
|
||||
.control__label-right-icon {
|
||||
margin-left: 6px;
|
||||
height: 16px;
|
||||
top: 3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue