Merge pull request #805 from chrismeyersfsu/feature-saml_import_attr

allow for saml attributes to define team and org
This commit is contained in:
Chris Meyers 2018-01-15 11:57:05 -05:00 committed by GitHub
commit 2b1d2b2976
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 625 additions and 2 deletions

View File

@ -498,7 +498,8 @@ else:
SOCIAL_AUTH_STRATEGY = 'social_django.strategy.DjangoStrategy'
SOCIAL_AUTH_STORAGE = 'social_django.models.DjangoStorage'
SOCIAL_AUTH_USER_MODEL = AUTH_USER_MODEL # noqa
SOCIAL_AUTH_PIPELINE = (
_SOCIAL_AUTH_PIPELINE_BASE = (
'social_core.pipeline.social_auth.social_details',
'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.auth_allowed',
@ -512,6 +513,14 @@ SOCIAL_AUTH_PIPELINE = (
'awx.sso.pipeline.set_is_active_for_new_user',
'social_core.pipeline.user.user_details',
'awx.sso.pipeline.prevent_inactive_login',
)
SOCIAL_AUTH_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + (
'awx.sso.pipeline.update_user_orgs',
'awx.sso.pipeline.update_user_teams',
)
SOCIAL_AUTH_SAML_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + (
'awx.sso.pipeline.update_user_orgs_by_saml_attr',
'awx.sso.pipeline.update_user_teams_by_saml_attr',
'awx.sso.pipeline.update_user_orgs',
'awx.sso.pipeline.update_user_teams',
)
@ -559,6 +568,9 @@ SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {}
SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {}
SOCIAL_AUTH_SAML_ENABLED_IDPS = {}
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = {}
SOCIAL_AUTH_SAML_TEAM_ATTR = {}
# Any ANSIBLE_* settings will be passed to the subprocess environment by the
# celery task.

View File

@ -1164,6 +1164,64 @@ register(
feature_required='enterprise_auth',
)
register(
'SOCIAL_AUTH_SAML_ORGANIZATION_ATTR',
field_class=fields.SAMLOrgAttrField,
allow_null=True,
default=None,
label=_('SAML Organization Attribute Mapping'),
help_text=_('Used to translate user organization membership into Tower.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([
('saml_attr', 'organization'),
('remove', True),
]),
feature_required='enterprise_auth',
)
register(
'SOCIAL_AUTH_SAML_TEAM_ATTR',
field_class=fields.SAMLTeamAttrField,
allow_null=True,
default=None,
label=_('SAML Team Map'),
help_text=_('Used to translate user team membership into Tower.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([
('saml_attr', 'organization'),
('remove', True),
('team_org_map', [
collections.OrderedDict([
('team', 'Marketing'),
('organization', 'Red Hat'),
]),
collections.OrderedDict([
('team', 'Human Resources'),
('organization', 'Red Hat'),
]),
collections.OrderedDict([
('team', 'Engineering'),
('organization', 'Red Hat'),
]),
collections.OrderedDict([
('team', 'Engineering'),
('organization', 'Ansible'),
]),
collections.OrderedDict([
('team', 'Quality Engineering'),
('organization', 'Ansible'),
]),
collections.OrderedDict([
('team', 'Sales'),
('organization', 'Ansible'),
]),
]),
]),
feature_required='enterprise_auth',
)
def tacacs_validate(serializer, attrs):
if not serializer.instance or \

View File

@ -3,6 +3,7 @@ import ldap
# Django
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
# Django Auth LDAP
import django_auth_ldap.config
@ -640,7 +641,7 @@ class SAMLEnabledIdPsField(fields.DictField):
child = SAMLIdPField()
class SAMLSecurityField(fields.DictField):
class SAMLSecurityField(BaseDictWithChildField):
child_fields = {
'nameIdEncrypted': fields.BooleanField(required=False),
@ -663,3 +664,28 @@ class SAMLSecurityField(fields.DictField):
}
allow_unknown_keys = True
class SAMLOrgAttrField(BaseDictWithChildField):
child_fields = {
'remove': fields.BooleanField(required=False),
'saml_attr': fields.CharField(required=False, allow_null=True),
}
class SAMLTeamAttrTeamOrgMapField(BaseDictWithChildField):
child_fields = {
'team': fields.CharField(required=True, allow_null=False),
'organization': fields.CharField(required=True, allow_null=False),
}
class SAMLTeamAttrField(BaseDictWithChildField):
child_fields = {
'team_org_map': fields.ListField(required=False, child=SAMLTeamAttrTeamOrgMapField(), allow_null=True),
'remove': fields.BooleanField(required=False),
'saml_attr': fields.CharField(required=False, allow_null=True),
}

View File

@ -3,17 +3,22 @@
# Python
import re
import logging
# Python Social Auth
from social_core.exceptions import AuthException
# Django
from django.utils.translation import ugettext_lazy as _
from django.db.models import Q
# Tower
from awx.conf.license import feature_enabled
logger = logging.getLogger('awx.sso.pipeline')
class AuthNotFound(AuthException):
def __init__(self, backend, email_or_uid, *args, **kwargs):
@ -138,3 +143,76 @@ def update_user_teams(backend, details, user=None, *args, **kwargs):
users_expr = team_opts.get('users', None)
remove = bool(team_opts.get('remove', True))
_update_m2m_from_expression(user, team.member_role.members, users_expr, remove)
def update_user_orgs_by_saml_attr(backend, details, user=None, *args, **kwargs):
if not user:
return
from awx.main.models import Organization
from django.conf import settings
multiple_orgs = feature_enabled('multiple_organizations')
org_map = settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR
if org_map.get('saml_attr') is None:
return
attr_values = kwargs.get('response', {}).get('attributes', {}).get(org_map['saml_attr'], [])
org_ids = []
for org_name in attr_values:
if multiple_orgs:
org = Organization.objects.get_or_create(name=org_name)[0]
else:
try:
org = Organization.objects.order_by('pk')[0]
except IndexError:
continue
org_ids.append(org.id)
org.member_role.members.add(user)
if org_map.get('remove', True):
[o.member_role.members.remove(user) for o in
Organization.objects.filter(Q(member_role__members=user) & ~Q(id__in=org_ids))]
def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs):
if not user:
return
from awx.main.models import Organization, Team
from django.conf import settings
multiple_orgs = feature_enabled('multiple_organizations')
team_map = settings.SOCIAL_AUTH_SAML_TEAM_ATTR
if team_map.get('saml_attr') is None:
return
saml_team_names = set(kwargs
.get('response', {})
.get('attributes', {})
.get(team_map['saml_attr'], []))
team_ids = []
for team_name_map in team_map.get('team_org_map', []):
team_name = team_name_map.get('team', '')
if team_name in saml_team_names:
if multiple_orgs:
if not team_name_map.get('organization', ''):
# Settings field validation should prevent this.
logger.error("organization name invalid for team {}".format(team_name))
continue
org = Organization.objects.get_or_create(name=team_name_map['organization'])[0]
else:
try:
org = Organization.objects.order_by('pk')[0]
except IndexError:
continue
team = Team.objects.get_or_create(name=team_name, organization=org)[0]
team_ids.append(team.id)
team.member_role.members.add(user)
if team_map.get('remove', True):
[t.member_role.members.remove(user) for t in
Team.objects.filter(Q(member_role__members=user) & ~Q(id__in=team_ids))]

View File

@ -0,0 +1,282 @@
import pytest
import mock
import re
from awx.sso.pipeline import (
update_user_orgs,
update_user_teams,
update_user_orgs_by_saml_attr,
update_user_teams_by_saml_attr,
)
from awx.main.models import (
User,
Team,
Organization
)
@pytest.fixture
def users():
u1 = User.objects.create(username='user1@foo.com', last_name='foo', first_name='bar', email='user1@foo.com')
u2 = User.objects.create(username='user2@foo.com', last_name='foo', first_name='bar', email='user2@foo.com')
u3 = User.objects.create(username='user3@foo.com', last_name='foo', first_name='bar', email='user3@foo.com')
return (u1, u2, u3)
@pytest.mark.django_db
class TestSAMLMap():
@pytest.fixture
def backend(self):
class Backend:
s = {
'ORGANIZATION_MAP': {
'Default': {
'remove': True,
'admins': 'foobar',
'remove_admins': True,
'users': 'foo',
'remove_users': True,
}
},
'TEAM_MAP': {
'Blue': {
'organization': 'Default',
'remove': True,
'users': '',
},
'Red': {
'organization': 'Default',
'remove': True,
'users': '',
}
}
}
def setting(self, key):
return self.s[key]
return Backend()
@pytest.fixture
def org(self):
return Organization.objects.create(name="Default")
def test_update_user_orgs(self, org, backend, users):
u1, u2, u3 = users
# Test user membership logic with regular expressions
backend.setting('ORGANIZATION_MAP')['Default']['admins'] = re.compile('.*')
backend.setting('ORGANIZATION_MAP')['Default']['users'] = re.compile('.*')
update_user_orgs(backend, None, u1)
update_user_orgs(backend, None, u2)
update_user_orgs(backend, None, u3)
assert org.admin_role.members.count() == 3
assert org.member_role.members.count() == 3
# Test remove feature enabled
backend.setting('ORGANIZATION_MAP')['Default']['admins'] = ''
backend.setting('ORGANIZATION_MAP')['Default']['users'] = ''
backend.setting('ORGANIZATION_MAP')['Default']['remove_admins'] = True
backend.setting('ORGANIZATION_MAP')['Default']['remove_users'] = True
update_user_orgs(backend, None, u1)
assert org.admin_role.members.count() == 2
assert org.member_role.members.count() == 2
# Test remove feature disabled
backend.setting('ORGANIZATION_MAP')['Default']['remove_admins'] = False
backend.setting('ORGANIZATION_MAP')['Default']['remove_users'] = False
update_user_orgs(backend, None, u2)
assert org.admin_role.members.count() == 2
assert org.member_role.members.count() == 2
def test_update_user_teams(self, backend, users):
u1, u2, u3 = users
# Test user membership logic with regular expressions
backend.setting('TEAM_MAP')['Blue']['users'] = re.compile('.*')
backend.setting('TEAM_MAP')['Red']['users'] = re.compile('.*')
update_user_teams(backend, None, u1)
update_user_teams(backend, None, u2)
update_user_teams(backend, None, u3)
assert Team.objects.get(name="Red").member_role.members.count() == 3
assert Team.objects.get(name="Blue").member_role.members.count() == 3
# Test remove feature enabled
backend.setting('TEAM_MAP')['Blue']['remove'] = True
backend.setting('TEAM_MAP')['Red']['remove'] = True
backend.setting('TEAM_MAP')['Blue']['users'] = ''
backend.setting('TEAM_MAP')['Red']['users'] = ''
update_user_teams(backend, None, u1)
assert Team.objects.get(name="Red").member_role.members.count() == 2
assert Team.objects.get(name="Blue").member_role.members.count() == 2
# Test remove feature disabled
backend.setting('TEAM_MAP')['Blue']['remove'] = False
backend.setting('TEAM_MAP')['Red']['remove'] = False
update_user_teams(backend, None, u2)
assert Team.objects.get(name="Red").member_role.members.count() == 2
assert Team.objects.get(name="Blue").member_role.members.count() == 2
@pytest.mark.django_db
class TestSAMLAttr():
@pytest.fixture
def kwargs(self):
return {
'username': u'cmeyers@redhat.com',
'uid': 'idp:cmeyers@redhat.com',
'request': {
u'SAMLResponse': [],
u'RelayState': [u'idp']
},
'is_new': False,
'response': {
'session_index': '_0728f0e0-b766-0135-75fa-02842b07c044',
'idp_name': u'idp',
'attributes': {
'memberOf': ['Default1', 'Default2'],
'groups': ['Blue', 'Red'],
'User.email': ['cmeyers@redhat.com'],
'User.LastName': ['Meyers'],
'name_id': 'cmeyers@redhat.com',
'User.FirstName': ['Chris'],
'PersonImmutableID': []
}
},
#'social': <UserSocialAuth: cmeyers@redhat.com>,
'social': None,
#'strategy': <awx.sso.strategies.django_strategy.AWXDjangoStrategy object at 0x8523a10>,
'strategy': None,
'new_association': False
}
@pytest.fixture
def orgs(self):
o1 = Organization.objects.create(name='Default1')
o2 = Organization.objects.create(name='Default2')
o3 = Organization.objects.create(name='Default3')
return (o1, o2, o3)
@pytest.fixture
def mock_settings(self):
class MockSettings():
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = {
'saml_attr': 'memberOf',
'remove': True,
}
SOCIAL_AUTH_SAML_TEAM_ATTR = {
'saml_attr': 'groups',
'remove': True,
'team_org_map': [
{'team': 'Blue', 'organization': 'Default1'},
{'team': 'Blue', 'organization': 'Default2'},
{'team': 'Blue', 'organization': 'Default3'},
{'team': 'Red', 'organization': 'Default1'},
{'team': 'Green', 'organization': 'Default1'},
{'team': 'Green', 'organization': 'Default3'},
]
}
return MockSettings()
def test_update_user_orgs_by_saml_attr(self, orgs, users, kwargs, mock_settings):
with mock.patch('django.conf.settings', mock_settings):
o1, o2, o3 = orgs
u1, u2, u3 = users
# Test getting orgs from attribute
update_user_orgs_by_saml_attr(None, None, u1, **kwargs)
update_user_orgs_by_saml_attr(None, None, u2, **kwargs)
update_user_orgs_by_saml_attr(None, None, u3, **kwargs)
assert o1.member_role.members.count() == 3
assert o2.member_role.members.count() == 3
assert o3.member_role.members.count() == 0
# Test remove logic enabled
kwargs['response']['attributes']['memberOf'] = ['Default3']
update_user_orgs_by_saml_attr(None, None, u1, **kwargs)
assert o1.member_role.members.count() == 2
assert o2.member_role.members.count() == 2
assert o3.member_role.members.count() == 1
# Test remove logic disabled
mock_settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR['remove'] = False
kwargs['response']['attributes']['memberOf'] = ['Default1', 'Default2']
update_user_orgs_by_saml_attr(None, None, u1, **kwargs)
assert o1.member_role.members.count() == 3
assert o2.member_role.members.count() == 3
assert o3.member_role.members.count() == 1
def test_update_user_teams_by_saml_attr(self, orgs, users, kwargs, mock_settings):
with mock.patch('django.conf.settings', mock_settings):
o1, o2, o3 = orgs
u1, u2, u3 = users
# Test getting teams from attribute with team->org mapping
kwargs['response']['attributes']['groups'] = ['Blue', 'Red', 'Green']
# Ensure basic functionality
update_user_teams_by_saml_attr(None, None, u1, **kwargs)
update_user_teams_by_saml_attr(None, None, u2, **kwargs)
update_user_teams_by_saml_attr(None, None, u3, **kwargs)
assert Team.objects.get(name='Blue', organization__name='Default1').member_role.members.count() == 3
assert Team.objects.get(name='Blue', organization__name='Default2').member_role.members.count() == 3
assert Team.objects.get(name='Blue', organization__name='Default3').member_role.members.count() == 3
assert Team.objects.get(name='Red', organization__name='Default1').member_role.members.count() == 3
assert Team.objects.get(name='Green', organization__name='Default1').member_role.members.count() == 3
assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3
# Test remove logic
kwargs['response']['attributes']['groups'] = ['Green']
update_user_teams_by_saml_attr(None, None, u1, **kwargs)
update_user_teams_by_saml_attr(None, None, u2, **kwargs)
update_user_teams_by_saml_attr(None, None, u3, **kwargs)
assert Team.objects.get(name='Blue', organization__name='Default1').member_role.members.count() == 0
assert Team.objects.get(name='Blue', organization__name='Default2').member_role.members.count() == 0
assert Team.objects.get(name='Blue', organization__name='Default3').member_role.members.count() == 0
assert Team.objects.get(name='Red', organization__name='Default1').member_role.members.count() == 0
assert Team.objects.get(name='Green', organization__name='Default1').member_role.members.count() == 3
assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3
# Test remove logic disabled
mock_settings.SOCIAL_AUTH_SAML_TEAM_ATTR['remove'] = False
kwargs['response']['attributes']['groups'] = ['Blue']
update_user_teams_by_saml_attr(None, None, u1, **kwargs)
update_user_teams_by_saml_attr(None, None, u2, **kwargs)
update_user_teams_by_saml_attr(None, None, u3, **kwargs)
assert Team.objects.get(name='Blue', organization__name='Default1').member_role.members.count() == 3
assert Team.objects.get(name='Blue', organization__name='Default2').member_role.members.count() == 3
assert Team.objects.get(name='Blue', organization__name='Default3').member_role.members.count() == 3
assert Team.objects.get(name='Red', organization__name='Default1').member_role.members.count() == 0
assert Team.objects.get(name='Green', organization__name='Default1').member_role.members.count() == 3
assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3

View File

@ -0,0 +1,82 @@
import pytest
from rest_framework.exceptions import ValidationError
from awx.sso.fields import (
SAMLOrgAttrField,
SAMLTeamAttrField,
)
class TestSAMLOrgAttrField():
@pytest.mark.parametrize("data, expected", [
({}, {}),
({'remove': True, 'saml_attr': 'foobar'}, {'remove': True, 'saml_attr': 'foobar'}),
({'remove': True, 'saml_attr': 1234}, {'remove': True, 'saml_attr': '1234'}),
({'remove': True, 'saml_attr': 3.14}, {'remove': True, 'saml_attr': '3.14'}),
({'saml_attr': 'foobar'}, {'saml_attr': 'foobar'}),
({'remove': True}, {'remove': True}),
])
def test_internal_value_valid(self, data, expected):
field = SAMLOrgAttrField()
res = field.to_internal_value(data)
assert res == expected
@pytest.mark.parametrize("data, expected", [
({'remove': 'blah', 'saml_attr': 'foobar'},
ValidationError('"blah" is not a valid boolean.')),
({'remove': True, 'saml_attr': False},
ValidationError('Not a valid string.')),
({'remove': True, 'saml_attr': False, 'foo': 'bar', 'gig': 'ity'},
ValidationError('Invalid key(s): "gig", "foo".')),
])
def test_internal_value_invalid(self, data, expected):
field = SAMLOrgAttrField()
with pytest.raises(type(expected)) as e:
field.to_internal_value(data)
assert str(e.value) == str(expected)
class TestSAMLTeamAttrField():
@pytest.mark.parametrize("data", [
{},
{'remove': True, 'saml_attr': 'foobar', 'team_org_map': []},
{'remove': True, 'saml_attr': 'foobar', 'team_org_map': [
{'team': 'Engineering', 'organization': 'Ansible'}
]},
{'remove': True, 'saml_attr': 'foobar', 'team_org_map': [
{'team': 'Engineering', 'organization': 'Ansible'},
{'team': 'Engineering', 'organization': 'Ansible2'},
{'team': 'Engineering2', 'organization': 'Ansible'},
]},
{'remove': True, 'saml_attr': 'foobar', 'team_org_map': [
{'team': 'Engineering', 'organization': 'Ansible'},
{'team': 'Engineering', 'organization': 'Ansible2'},
{'team': 'Engineering2', 'organization': 'Ansible'},
]},
])
def test_internal_value_valid(self, data):
field = SAMLTeamAttrField()
res = field.to_internal_value(data)
assert res == data
@pytest.mark.parametrize("data, expected", [
({'remove': True, 'saml_attr': 'foobar', 'team_org_map': [
{'team': 'foobar', 'not_a_valid_key': 'blah', 'organization': 'Ansible'},
]}, ValidationError('Invalid key(s): "not_a_valid_key".')),
({'remove': False, 'saml_attr': 'foobar', 'team_org_map': [
{'organization': 'Ansible'},
]}, ValidationError('Missing key(s): "team".')),
({'remove': False, 'saml_attr': 'foobar', 'team_org_map': [
{},
]}, ValidationError('Missing key(s): "organization", "team".')),
])
def test_internal_value_invalid(self, data, expected):
field = SAMLTeamAttrField()
with pytest.raises(type(expected)) as e:
field.to_internal_value(data)
assert str(e.value) == str(expected)

85
docs/auth/saml.md Normal file
View File

@ -0,0 +1,85 @@
# SAML
Security Assertion Markup Language, or SAML, is an open standard for exchanging authentication and/or authorization data between an identity provider (i.e. LDAP) and a service provider (i.e. AWX). More concretely, AWX can be configured to talk with SAML in order to authenticate (create/login/logout) users of AWX. User Team and Organization membership can be embedded in the SAML response to AWX.
# Configure SAML Authentication
Please see the Tower documentation as well as Ansible blog posts for basic SAML configuration.
# Configure SAML for Team and Organization Membership
AWX can be configured to look for particular attributes that contain AWX Team and Organization membership to associate with users when they login to AWX. The attribute names are defined in AWX settings. Specifically, the authentication settings tab and SAML sub category fields *SAML Team Map* and *SAML Organization Attribute Mapping*. The meaning and usefulness of these settings is best motivated through example.
**Example SAML Organization Attribute Mapping**
Below is an example SAML attribute that embeds user organization membership in the attribute *member-of*.
```
<saml2:AttributeStatement>
<saml2:Attribute FriendlyName="member-of" Name="member-of" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml2:AttributeValue>Engineering</saml2:AttributeValue>
<saml2:AttributeValue>IT</saml2:AttributeValue>
<saml2:AttributeValue>HR</saml2:AttributeValue>
<saml2:AttributeValue>Sales</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
```
Below, the corresponding AWX configuration.
```
{
"saml_attr": "member-of",
"remove": true
}
```
**saml_attr:** The saml attribute name where the organization array can be found.
**remove:** True to remove user from all organizations before adding the user to the list of Organizations. False to keep the user in whatever Organization(s) they are in while adding the user to the Organization(s) in the SAML attribute.
**Example SAML Team Map**
Below is another example of a SAML attribute that contains a Team membership in a list.
```
<saml:AttributeStatement>
<saml:Attribute
xmlns:x500="urn:oasis:names:tc:SAML:2.0:profiles:attribute:X500"
x500:Encoding="LDAP"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1"
FriendlyName="eduPersonAffiliation">
<saml:AttributeValue
xsi:type="xs:string">member</saml:AttributeValue>
<saml:AttributeValue
xsi:type="xs:string">staff</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
```
```
{
"saml_attr": "eduPersonAffiliation",
"remove": true,
"team_org_map": [
{
"team": "Blue",
"organization": "Default1"
},
{
"team": "Blue",
"organization": "Default2"
},
{
"team": "Blue",
"organization": "Default3"
},
{
"team": "Red",
"organization": "Default1"
},
{
"team": "Green",
"organization": "Default1"
},
{
"team": "Green",
"organization": "Default3"
}
]
}
```
**saml_attr:** The saml attribute name where the team array can be found.
**remove:** True to remove user from all Teams before adding the user to the list of Teams. False to keep the user in whatever Team(s) they are in while adding the user to the Team(s) in the SAML attribute.
**team_org_map:** An array of dictionaries of the form `{ "team": "<AWX Team Name>", "organization": "<AWX Org Name>" }` that defines mapping from AWX Team -> AWX Organization. This is needed because the same named Team can exist in multiple Organizations in Tower. The organization to which a team listed in a SAML attribute belongs to would be ambiguous without this mapping.