diff --git a/awx/sso/tests/conftest.py b/awx/sso/tests/conftest.py index bcbde4cdf3..825640e902 100644 --- a/awx/sso/tests/conftest.py +++ b/awx/sso/tests/conftest.py @@ -42,7 +42,7 @@ def test_radius_config(settings): @pytest.fixture -def test_saml_config(settings): +def basic_saml_config(settings): settings.SAML_SECURITY_CONFIG = { "wantNameId": True, "signMetadata": False, @@ -70,6 +70,29 @@ def test_saml_config(settings): } } + settings.SOCIAL_AUTH_SAML_TEAM_ATTR = { + "remove": False, + "saml_attr": "group_name", + "team_org_map": [ + {"team": "internal:unix:domain:admins", "team_alias": "Administrators", "organization": "Default"}, + {"team": "East Coast", "organization": "North America"}, + {"team": "developers", "organization": "North America"}, + {"team": "developers", "organization": "South America"}, + ], + } + + settings.SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR = { + "is_superuser_role": ["wilma"], + "is_superuser_attr": "friends", + "is_superuser_value": ["barney", "fred"], + "remove_superusers": False, + "is_system_auditor_role": ["fred"], + "is_system_auditor_attr": "auditor", + "is_system_auditor_value": ["bamm-bamm"], + } + + settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = {"saml_attr": "member-of", "remove": True, "saml_admin_attr": "admin-of", "remove_admins": False} + @pytest.fixture def test_tacacs_config(settings): @@ -79,3 +102,49 @@ def test_tacacs_config(settings): settings.TACACSPLUS_SESSION_TIMEOUT = 10 settings.TACACSPLUS_AUTH_PROTOCOL = "pap" settings.TACACSPLUS_REM_ADDR = True + + +@pytest.fixture +def saml_config_user_flags_no_value(settings): + settings.SAML_SECURITY_CONFIG = { + "wantNameId": True, + "signMetadata": False, + "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "nameIdEncrypted": False, + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "authnRequestsSigned": False, + "logoutRequestSigned": False, + "wantNameIdEncrypted": False, + "logoutResponseSigned": False, + "wantAssertionsSigned": True, + "requestedAuthnContext": False, + "wantAssertionsEncrypted": False, + } + settings.SOCIAL_AUTH_SAML_ENABLED_IDPS = { + "example": { + "attr_email": "email", + "attr_first_name": "first_name", + "attr_last_name": "last_name", + "attr_user_permanent_id": "username", + "attr_username": "username", + "entity_id": "https://www.example.com/realms/sample", + "url": "https://www.example.com/realms/sample/protocol/saml", + "x509cert": "A" * 64 + "B" * 64 + "C" * 23, + } + } + + settings.SOCIAL_AUTH_SAML_TEAM_ATTR = { + "remove": False, + "saml_attr": "group_name", + "team_org_map": [ + {"team": "internal:unix:domain:admins", "team_alias": "Administrators", "organization": "Default"}, + {"team": "East Coast", "organization": "North America"}, + {"team": "developers", "organization": "North America"}, + {"team": "developers", "organization": "South America"}, + ], + } + + settings.SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR = { + "is_superuser_role": ["wilma"], + "is_superuser_attr": "friends", + } diff --git a/awx/sso/tests/unit/test_saml_migrator.py b/awx/sso/tests/unit/test_saml_migrator.py index 4b8ab2dc12..803db990c5 100644 --- a/awx/sso/tests/unit/test_saml_migrator.py +++ b/awx/sso/tests/unit/test_saml_migrator.py @@ -4,7 +4,7 @@ from awx.sso.utils.saml_migrator import SAMLMigrator @pytest.mark.django_db -def test_get_controller_config(test_saml_config): +def test_get_controller_config(basic_saml_config): gateway_client = MagicMock() command_obj = MagicMock() obj = SAMLMigrator(gateway_client, command_obj) @@ -16,3 +16,216 @@ def test_get_controller_config(test_saml_config): assert lines[2] == "B" * 64 assert lines[3] == "C" * 23 assert lines[-1] == '-----END CERTIFICATE-----' + + +@pytest.mark.django_db +def test_get_controller_config_with_mapper(saml_config_user_flags_no_value): + gateway_client = MagicMock() + command_obj = MagicMock() + obj = SAMLMigrator(gateway_client, command_obj) + + result = obj.get_controller_config() + expected_maps = [ + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'Default', + 'team': 'Administrators', + 'name': 'Team-Administrators-Default', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['internal:unix:domain:admins']}, 'join_condition': 'or'}}, + 'order': 1, + }, + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'North America', + 'team': 'East Coast', + 'name': 'Team-East Coast-North America', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['East Coast']}, 'join_condition': 'or'}}, + 'order': 2, + }, + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'North America', + 'team': 'developers', + 'name': 'Team-developers-North America', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['developers']}, 'join_condition': 'or'}}, + 'order': 3, + }, + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'South America', + 'team': 'developers', + 'name': 'Team-developers-South America', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['developers']}, 'join_condition': 'or'}}, + 'order': 4, + }, + { + 'map_type': 'is_superuser', + 'role': None, + 'name': 'Role-is_superuser-attr', + 'organization': None, + 'team': None, + 'revoke': True, + 'order': 5, + 'authenticator': -1, + 'triggers': {'attributes': {'friends': {}, 'join_condition': 'or'}}, + }, + { + 'map_type': 'is_superuser', + 'role': None, + 'name': 'Role-is_superuser', + 'organization': None, + 'team': None, + 'revoke': True, + 'order': 6, + 'authenticator': -1, + 'triggers': {'attributes': {'Role': {'in': ['wilma']}, 'join_condition': 'or'}}, + }, + ] + assert result[0]['team_mappers'] == expected_maps + extra_data = result[0]['settings']['configuration']['EXTRA_DATA'] + assert ['Role', 'Role'] in extra_data + assert ['friends', 'friends'] in extra_data + assert ['group_name', 'group_name'] in extra_data + + +@pytest.mark.django_db +def test_get_controller_config_with_roles(basic_saml_config): + gateway_client = MagicMock() + command_obj = MagicMock() + obj = SAMLMigrator(gateway_client, command_obj) + + result = obj.get_controller_config() + + expected_maps = [ + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'Default', + 'team': 'Administrators', + 'name': 'Team-Administrators-Default', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['internal:unix:domain:admins']}, 'join_condition': 'or'}}, + 'order': 1, + }, + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'North America', + 'team': 'East Coast', + 'name': 'Team-East Coast-North America', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['East Coast']}, 'join_condition': 'or'}}, + 'order': 2, + }, + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'North America', + 'team': 'developers', + 'name': 'Team-developers-North America', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['developers']}, 'join_condition': 'or'}}, + 'order': 3, + }, + { + 'map_type': 'team', + 'role': 'Team Member', + 'organization': 'South America', + 'team': 'developers', + 'name': 'Team-developers-South America', + 'revoke': False, + 'authenticator': -1, + 'triggers': {'attributes': {'group_name': {'in': ['developers']}, 'join_condition': 'or'}}, + 'order': 4, + }, + { + 'map_type': 'is_superuser', + 'role': None, + 'name': 'Role-is_superuser-attr', + 'organization': None, + 'team': None, + 'revoke': False, + 'order': 5, + 'authenticator': -1, + 'triggers': {'attributes': {'friends': {'in': ['barney', 'fred']}, 'join_condition': 'or'}}, + }, + { + 'map_type': 'role', + 'role': 'Platform Auditor', + 'name': 'Role-Platform Auditor-attr', + 'organization': None, + 'team': None, + 'revoke': True, + 'order': 6, + 'authenticator': -1, + 'triggers': {'attributes': {'auditor': {'in': ['bamm-bamm']}, 'join_condition': 'or'}}, + }, + { + 'map_type': 'is_superuser', + 'role': None, + 'name': 'Role-is_superuser', + 'organization': None, + 'team': None, + 'revoke': False, + 'order': 7, + 'authenticator': -1, + 'triggers': {'attributes': {'Role': {'in': ['wilma']}, 'join_condition': 'or'}}, + }, + { + 'map_type': 'role', + 'role': 'Platform Auditor', + 'name': 'Role-Platform Auditor', + 'organization': None, + 'team': None, + 'revoke': True, + 'order': 8, + 'authenticator': -1, + 'triggers': {'attributes': {'Role': {'in': ['fred']}, 'join_condition': 'or'}}, + }, + { + 'map_type': 'organization', + 'role': 'Organization Member', + 'name': 'Role-Organization Member-attr', + 'organization': "{% for_attr_value('member-of') %}", + 'team': None, + 'revoke': True, + 'order': 9, + 'authenticator': -1, + 'triggers': {'attributes': {'member-of': {}, 'join_condition': 'or'}}, + }, + { + 'map_type': 'organization', + 'role': 'Organization Admin', + 'name': 'Role-Organization Admin-attr', + 'organization': "{% for_attr_value('admin-of') %}", + 'team': None, + 'revoke': False, + 'order': 10, + 'authenticator': -1, + 'triggers': {'attributes': {'admin-of': {}, 'join_condition': 'or'}}, + }, + ] + + assert result[0]['team_mappers'] == expected_maps + extra_data = result[0]['settings']['configuration']['EXTRA_DATA'] + assert ['member-of', 'member-of'] in extra_data + assert ['admin-of', 'admin-of'] in extra_data + assert ['Role', 'Role'] in extra_data + assert ['auditor', 'auditor'] in extra_data + assert ['friends', 'friends'] in extra_data + assert ['group_name', 'group_name'] in extra_data diff --git a/awx/sso/utils/saml_migrator.py b/awx/sso/utils/saml_migrator.py index d498029e84..7a4f93dd07 100644 --- a/awx/sso/utils/saml_migrator.py +++ b/awx/sso/utils/saml_migrator.py @@ -9,6 +9,21 @@ from django.conf import settings from awx.main.utils.gateway_mapping import org_map_to_gateway_format, team_map_to_gateway_format from awx.sso.utils.base_migrator import BaseAuthenticatorMigrator +ROLE_MAPPER = { + "is_superuser_role": {"role": None, "map_type": "is_superuser", "revoke": "remove_superusers"}, + "is_system_auditor_role": {"role": "Platform Auditor", "map_type": "role", "revoke": "remove_system_auditors"}, +} + +ATTRIBUTE_VALUE_MAPPER = { + "is_superuser_attr": {"role": None, "map_type": "is_superuser", "value": "is_superuser_value", "revoke": "remove_superusers"}, + "is_system_auditor_attr": {"role": "Platform Auditor", "map_type": "role", "value": "is_system_auditor_value", "revoke": "remove_system_auditors"}, +} + +ORG_ATTRIBUTE_MAPPER = { + "saml_attr": {"role": "Organization Member", "revoke": "remove"}, + "saml_admin_attr": {"role": "Organization Admin", "revoke": "remove_admins"}, +} + def _split_chunks(data: str, length: int = 64) -> list[str]: return [data[i : i + length] for i in range(0, len(data), length)] @@ -29,6 +44,12 @@ class SAMLMigrator(BaseAuthenticatorMigrator): CATEGORY = "SAML" AUTH_TYPE = "ansible_base.authentication.authenticator_plugins.saml" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.next_order = 1 + self.team_mappers = [] + self.dynamic_extra_data = [["Role", "Role"]] + def get_authenticator_type(self): """Get the human-readable authenticator type name.""" return "SAML" @@ -53,6 +74,7 @@ class SAMLMigrator(BaseAuthenticatorMigrator): org_map_value = self.get_social_org_map("SOCIAL_AUTH_SAML_ORGANIZATION_MAP") team_map_value = self.get_social_team_map("SOCIAL_AUTH_SAML_TEAM_MAP") extra_data = getattr(settings, "SOCIAL_AUTH_SAML_EXTRA_DATA", None) + support_contact = getattr(settings, "SOCIAL_AUTH_SAML_SUPPORT_CONTACT", {}) technical_contact = getattr(settings, "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT", {}) org_info = getattr(settings, "SOCIAL_AUTH_SAML_ORG_INFO", {}) @@ -61,9 +83,22 @@ class SAMLMigrator(BaseAuthenticatorMigrator): sp_public_cert = getattr(settings, "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT", None) sp_entity_id = getattr(settings, "SOCIAL_AUTH_SAML_SP_ENTITY_ID", None) sp_extra = getattr(settings, "SOCIAL_AUTH_SAML_SP_EXTRA", {}) + saml_team_attr = getattr(settings, "SOCIAL_AUTH_SAML_TEAM_ATTR", {}) + org_attr = getattr(settings, "SOCIAL_AUTH_SAML_ORGANIZATION_ATTR", {}) + user_flags_by_attr = getattr(settings, "SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR", {}) - org_mappers, next_order = org_map_to_gateway_format(org_map_value, start_order=1) - team_mappers, _ = team_map_to_gateway_format(team_map_value, start_order=next_order) + org_mappers, self.next_order = org_map_to_gateway_format(org_map_value, start_order=self.next_order) + self.team_mappers, self.next_order = team_map_to_gateway_format(team_map_value, start_order=self.next_order) + + self._team_attr_to_gateway_format(saml_team_attr) + self._user_flags_by_attr_value_to_gateway_format(user_flags_by_attr) + self._user_flags_by_role_to_gateway_format(user_flags_by_attr) + self._org_attr_to_gateway_format(org_attr) + + if not extra_data: + extra_data = self.dynamic_extra_data + elif isinstance(extra_data, list): + extra_data.extend(self.dynamic_extra_data) for name, value in idps.items(): config_data = { @@ -99,7 +134,7 @@ class SAMLMigrator(BaseAuthenticatorMigrator): "category": self.CATEGORY, "settings": config_data, "org_mappers": org_mappers, - "team_mappers": team_mappers, + "team_mappers": self.team_mappers, } ) return found_configs @@ -131,7 +166,136 @@ class SAMLMigrator(BaseAuthenticatorMigrator): } # CALLBACK_URL - automatically created by Gateway - ignore_keys = ["CALLBACK_URL"] + ignore_keys = ["CALLBACK_URL", "SP_PRIVATE_KEY"] # Submit the authenticator (create or update as needed) return self.submit_authenticator(gateway_config, ignore_keys, config) + + def _team_attr_to_gateway_format(self, saml_team_attr): + saml_attr = saml_team_attr.get("saml_attr") + if not saml_attr: + return + + revoke = saml_team_attr.get('remove', True) + self.dynamic_extra_data.extend([[saml_attr, saml_attr]]) + + for item in saml_team_attr["team_org_map"]: + team_list = item["team"] + if isinstance(team_list, str): + team_list = [team_list] + team = item.get("team_alias") or item["team"] + self.team_mappers.append( + { + "map_type": "team", + "role": "Team Member", + "organization": item["organization"], + "team": team, + "name": "Team" + "-" + team + "-" + item["organization"], + "revoke": revoke, + "authenticator": -1, + "triggers": {"attributes": {saml_attr: {"in": team_list}, "join_condition": "or"}}, + "order": self.next_order, + } + ) + self.next_order += 1 + + def _user_flags_by_role_to_gateway_format(self, user_flags_by_attr): + for k, v in ROLE_MAPPER.items(): + if k in user_flags_by_attr: + if v['role']: + name = f"Role-{v['role']}" + else: + name = f"Role-{v['map_type']}" + + revoke = user_flags_by_attr.get(v['revoke'], True) + self.team_mappers.append( + { + "map_type": v["map_type"], + "role": v["role"], + "name": name, + "organization": None, + "team": None, + "revoke": revoke, + "order": self.next_order, + "authenticator": -1, + "triggers": { + "attributes": { + "Role": {"in": user_flags_by_attr[k]}, + "join_condition": "or", + } + }, + } + ) + self.next_order += 1 + + def _user_flags_by_attr_value_to_gateway_format(self, user_flags_by_attr): + for k, v in ATTRIBUTE_VALUE_MAPPER.items(): + if k in user_flags_by_attr: + value = user_flags_by_attr.get(v['value']) + + if value: + if isinstance(value, list): + value = {'in': value} + else: + value = {'in': [value]} + else: + value = {} + + revoke = user_flags_by_attr.get(v['revoke'], True) + attr_name = user_flags_by_attr[k] + self.dynamic_extra_data.extend([[attr_name, attr_name]]) + + if v['role']: + name = f"Role-{v['role']}-attr" + else: + name = f"Role-{v['map_type']}-attr" + + self.team_mappers.append( + { + "map_type": v["map_type"], + "role": v["role"], + "name": name, + "organization": None, + "team": None, + "revoke": revoke, + "order": self.next_order, + "authenticator": -1, + "triggers": { + "attributes": { + attr_name: value, + "join_condition": "or", + } + }, + } + ) + self.next_order += 1 + + def _org_attr_to_gateway_format(self, org_attr): + for k, v in ORG_ATTRIBUTE_MAPPER.items(): + if k in org_attr: + attr_name = org_attr.get(k) + organization = "{% " + f"for_attr_value('{attr_name}')" + " %}" + revoke = org_attr.get(v['revoke'], True) + + self.dynamic_extra_data.extend([[attr_name, attr_name]]) + + name = f"Role-{v['role']}-attr" + self.team_mappers.append( + { + "map_type": 'organization', + "role": v['role'], + "name": name, + "organization": organization, + "team": None, + "revoke": revoke, + "order": self.next_order, + "authenticator": -1, + "triggers": { + "attributes": { + attr_name: {}, + "join_condition": "or", + } + }, + } + ) + self.next_order += 1