mirror of
https://github.com/ansible/awx.git
synced 2026-01-09 23:12:08 -03:30
* Adding SAML option in SAML configuration to specify system auditor and system superusers by role or attribute * Adding keycloak container and documentation on how to start keycloak alongside AWX (including configuration of both)
This commit is contained in:
parent
4de0f09c85
commit
c92468062d
1
.gitignore
vendored
1
.gitignore
vendored
@ -42,6 +42,7 @@ tools/docker-compose/_build
|
||||
tools/docker-compose/_sources
|
||||
tools/docker-compose/overrides/
|
||||
tools/docker-compose-minikube/_sources
|
||||
tools/docker-compose/keycloak.awx.realm.json
|
||||
|
||||
# Tower setup playbook testing
|
||||
setup/test/roles/postgresql
|
||||
|
||||
5
Makefile
5
Makefile
@ -13,6 +13,8 @@ COLLECTION_VERSION := $(shell $(PYTHON) setup.py --version | cut -d . -f 1-3)
|
||||
COMPOSE_TAG ?= $(GIT_BRANCH)
|
||||
COMPOSE_HOST ?= $(shell hostname)
|
||||
MAIN_NODE_TYPE ?= hybrid
|
||||
# If set to true docker-compose will also start a keycloak instance
|
||||
KEYCLOAK ?= false
|
||||
|
||||
VENV_BASE ?= /var/lib/awx/venv
|
||||
|
||||
@ -468,7 +470,8 @@ docker-compose-sources: .git/hooks/pre-commit
|
||||
-e receptor_image=$(RECEPTOR_IMAGE) \
|
||||
-e control_plane_node_count=$(CONTROL_PLANE_NODE_COUNT) \
|
||||
-e execution_node_count=$(EXECUTION_NODE_COUNT) \
|
||||
-e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP)
|
||||
-e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP) \
|
||||
-e enable_keycloak=$(KEYCLOAK)
|
||||
|
||||
|
||||
docker-compose: awx/projects docker-compose-sources
|
||||
|
||||
@ -473,6 +473,7 @@ SOCIAL_AUTH_SAML_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + (
|
||||
'awx.sso.pipeline.update_user_teams_by_saml_attr',
|
||||
'awx.sso.pipeline.update_user_orgs',
|
||||
'awx.sso.pipeline.update_user_teams',
|
||||
'awx.sso.pipeline.update_user_flags',
|
||||
)
|
||||
SAML_AUTO_CREATE_OBJECTS = True
|
||||
|
||||
@ -535,6 +536,7 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = {}
|
||||
|
||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = {}
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR = {}
|
||||
SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR = {}
|
||||
|
||||
# Any ANSIBLE_* settings will be passed to the task runner subprocess
|
||||
# environment
|
||||
|
||||
@ -292,9 +292,12 @@ class SAMLAuth(BaseSAMLAuth):
|
||||
user = super(SAMLAuth, self).authenticate(request, *args, **kwargs)
|
||||
# Comes from https://github.com/omab/python-social-auth/blob/v0.2.21/social/backends/base.py#L91
|
||||
if getattr(user, 'is_new', False):
|
||||
_decorate_enterprise_user(user, 'saml')
|
||||
enterprise_auth = _decorate_enterprise_user(user, 'saml')
|
||||
logger.debug("Created enterprise user %s from %s backend." % (user.username, enterprise_auth.get_provider_display()))
|
||||
elif user and not user.is_in_enterprise_category('saml'):
|
||||
return None
|
||||
if user:
|
||||
logger.debug("Enterprise user %s already created in Tower." % user.username)
|
||||
return user
|
||||
|
||||
def get_user(self, user_id):
|
||||
|
||||
@ -32,6 +32,7 @@ from awx.sso.fields import (
|
||||
SAMLOrgInfoField,
|
||||
SAMLSecurityField,
|
||||
SAMLTeamAttrField,
|
||||
SAMLUserFlagsAttrField,
|
||||
SocialOrganizationMapField,
|
||||
SocialTeamMapField,
|
||||
)
|
||||
@ -1523,6 +1524,25 @@ register(
|
||||
),
|
||||
)
|
||||
|
||||
register(
|
||||
'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR',
|
||||
field_class=SAMLUserFlagsAttrField,
|
||||
allow_null=True,
|
||||
default=None,
|
||||
label=_('SAML User Flags Attribute Mapping'),
|
||||
help_text=_('Used to map super users and system auditors from SAML.'),
|
||||
category=_('SAML'),
|
||||
category_slug='saml',
|
||||
placeholder=[
|
||||
('is_superuser_attr', 'saml_attr'),
|
||||
('is_superuser_value', 'value'),
|
||||
('is_superuser_role', 'saml_role'),
|
||||
('is_system_auditor_attr', 'saml_attr'),
|
||||
('is_system_auditor_value', 'value'),
|
||||
('is_system_auditor_role', 'saml_role'),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def tacacs_validate(serializer, attrs):
|
||||
if not serializer.instance or not hasattr(serializer.instance, 'TACACSPLUS_HOST') or not hasattr(serializer.instance, 'TACACSPLUS_SECRET'):
|
||||
|
||||
@ -735,3 +735,15 @@ class SAMLTeamAttrField(HybridDictField):
|
||||
saml_attr = fields.CharField(required=False, allow_null=True)
|
||||
|
||||
child = _Forbidden()
|
||||
|
||||
|
||||
class SAMLUserFlagsAttrField(HybridDictField):
|
||||
|
||||
is_superuser_attr = fields.CharField(required=False, allow_null=True)
|
||||
is_superuser_value = fields.CharField(required=False, allow_null=True)
|
||||
is_superuser_role = fields.CharField(required=False, allow_null=True)
|
||||
is_system_auditor_attr = fields.CharField(required=False, allow_null=True)
|
||||
is_system_auditor_value = fields.CharField(required=False, allow_null=True)
|
||||
is_system_auditor_role = fields.CharField(required=False, allow_null=True)
|
||||
|
||||
child = _Forbidden()
|
||||
|
||||
@ -229,3 +229,69 @@ def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs)
|
||||
|
||||
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))]
|
||||
|
||||
|
||||
def _check_flag(user, flag, attributes, user_flags_settings):
|
||||
new_flag = False
|
||||
is_role_key = "is_%s_role" % (flag)
|
||||
is_attr_key = "is_%s_attr" % (flag)
|
||||
is_value_key = "is_%s_value" % (flag)
|
||||
|
||||
# Check to see if we are respecting a role and, if so, does our user have that role?
|
||||
role_setting = user_flags_settings.get(is_role_key, None)
|
||||
if role_setting:
|
||||
# We do a 2 layer check here so that we don't spit out the else message if there is no role defined
|
||||
if role_setting in attributes.get('Role', []):
|
||||
logger.debug("User %s has %s role %s" % (user.username, flag, role_setting))
|
||||
new_flag = True
|
||||
else:
|
||||
logger.debug("User %s is missing the %s role %s" % (user.username, flag, role_setting))
|
||||
|
||||
# Next, check to see if we are respecting an attribute; this will take priority over the role if its defined
|
||||
attr_setting = user_flags_settings.get(is_attr_key, None)
|
||||
if attr_setting and attributes.get(attr_setting, None):
|
||||
# Do we have a required value for the attribute
|
||||
if user_flags_settings.get(is_value_key, None):
|
||||
# If so, check and see if the value of the attr matches the required value
|
||||
attribute_value = attributes.get(attr_setting, None)
|
||||
if isinstance(attribute_value, (list, tuple)):
|
||||
attribute_value = attribute_value[0]
|
||||
if attribute_value == user_flags_settings.get(is_value_key):
|
||||
logger.debug("Giving %s %s from attribute %s with matching value" % (user.username, flag, attr_setting))
|
||||
new_flag = True
|
||||
# if they don't match make sure that new_flag is false
|
||||
else:
|
||||
logger.debug(
|
||||
"Refusing %s for %s because attr %s (%s) did not match value '%s'"
|
||||
% (flag, user.username, attr_setting, attribute_value, user_flags_settings.get(is_value_key))
|
||||
)
|
||||
new_flag = False
|
||||
# If there was no required value then we can just allow them in because of the attribute
|
||||
else:
|
||||
logger.debug("Giving %s %s from attribute %s" % (user.username, flag, attr_setting))
|
||||
new_flag = True
|
||||
|
||||
# If the user was flagged and we are going to make them not flagged make sure there is a message
|
||||
old_value = getattr(user, "is_%s" % (flag))
|
||||
if old_value and not new_flag:
|
||||
logger.debug("Revoking %s from %s" % (flag, user.username))
|
||||
|
||||
return new_flag, old_value != new_flag
|
||||
|
||||
|
||||
def update_user_flags(backend, details, user=None, *args, **kwargs):
|
||||
if not user:
|
||||
return
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
user_flags_settings = settings.SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR
|
||||
|
||||
attributes = kwargs.get('response', {}).get('attributes', {})
|
||||
logger.debug("User attributes for %s: %s" % (user.username, attributes))
|
||||
|
||||
user.is_superuser, superuser_changed = _check_flag(user, 'superuser', attributes, user_flags_settings)
|
||||
user.is_system_auditor, auditor_changed = _check_flag(user, 'system_auditor', attributes, user_flags_settings)
|
||||
|
||||
if superuser_changed or auditor_changed:
|
||||
user.save()
|
||||
|
||||
@ -4,7 +4,7 @@ from unittest import mock
|
||||
|
||||
from django.utils.timezone import now
|
||||
|
||||
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.sso.pipeline import update_user_orgs, update_user_teams, update_user_orgs_by_saml_attr, update_user_teams_by_saml_attr, _check_flag
|
||||
|
||||
from awx.main.models import User, Team, Organization, Credential, CredentialType
|
||||
|
||||
@ -357,3 +357,73 @@ class TestSAMLAttr:
|
||||
for o in Organization.objects.all():
|
||||
assert o.galaxy_credentials.count() == 1
|
||||
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLUserFlags:
|
||||
@pytest.mark.parametrize(
|
||||
"user_flags_settings, expected",
|
||||
[
|
||||
# In this case we will pass no user flags so new_flag should be false and changed will def be false
|
||||
(
|
||||
{},
|
||||
(False, False),
|
||||
),
|
||||
# In this case we will give the user a group to make them an admin
|
||||
(
|
||||
{'is_superuser_role': 'test-role-1'},
|
||||
(True, True),
|
||||
),
|
||||
# In this case we will give the user a flag that will make then an admin
|
||||
(
|
||||
{'is_superuser_attr': 'is_superuser'},
|
||||
(True, True),
|
||||
),
|
||||
# In this case we will give the user a flag but the wrong value
|
||||
(
|
||||
{'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'},
|
||||
(False, False),
|
||||
),
|
||||
# In this case we will give the user a flag and the right value
|
||||
(
|
||||
{'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'},
|
||||
(True, True),
|
||||
),
|
||||
# In this case we will give the user a proper role and an is_superuser_attr role that they dont have, this should make them an admin
|
||||
(
|
||||
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'gibberish', 'is_superuser_value': 'true'},
|
||||
(True, True),
|
||||
),
|
||||
# In this case we will give the user a proper role and an is_superuser_attr role that they have, this should make them an admin
|
||||
(
|
||||
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'test-role-1'},
|
||||
(True, True),
|
||||
),
|
||||
# In this case we will give the user a proper role and an is_superuser_attr role that they have but a bad value, this should make them an admin
|
||||
(
|
||||
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'},
|
||||
(False, False),
|
||||
),
|
||||
# In this case we will give the user everything
|
||||
(
|
||||
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'},
|
||||
(True, True),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test__check_flag(self, user_flags_settings, expected):
|
||||
user = User()
|
||||
user.username = 'John'
|
||||
user.is_superuser = False
|
||||
|
||||
attributes = {
|
||||
'email': ['noone@nowhere.com'],
|
||||
'last_name': ['Westcott'],
|
||||
'is_superuser': ['true'],
|
||||
'username': ['test_id'],
|
||||
'first_name': ['John'],
|
||||
'Role': ['test-role-1'],
|
||||
'name_id': 'test_id',
|
||||
}
|
||||
|
||||
assert expected == _check_flag(user, 'superuser', attributes, user_flags_settings)
|
||||
|
||||
@ -3,7 +3,7 @@ from unittest import mock
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from awx.sso.fields import SAMLOrgAttrField, SAMLTeamAttrField, LDAPGroupTypeParamsField, LDAPServerURIField
|
||||
from awx.sso.fields import SAMLOrgAttrField, SAMLTeamAttrField, SAMLUserFlagsAttrField, LDAPGroupTypeParamsField, LDAPServerURIField
|
||||
|
||||
|
||||
class TestSAMLOrgAttrField:
|
||||
@ -115,6 +115,66 @@ class TestSAMLTeamAttrField:
|
||||
assert e.value.detail == expected
|
||||
|
||||
|
||||
class TestSAMLUserFlagsAttrField:
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{},
|
||||
{'is_superuser_attr': 'something'},
|
||||
{'is_superuser_value': 'value'},
|
||||
{'is_superuser_role': 'my_peeps'},
|
||||
{'is_system_auditor_attr': 'something_else'},
|
||||
{'is_system_auditor_value': 'value2'},
|
||||
{'is_system_auditor_role': 'other_peeps'},
|
||||
],
|
||||
)
|
||||
def test_internal_value_valid(self, data):
|
||||
field = SAMLUserFlagsAttrField()
|
||||
res = field.to_internal_value(data)
|
||||
assert res == data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, expected",
|
||||
[
|
||||
(
|
||||
{
|
||||
'junk': 'something',
|
||||
'is_superuser_value': 'value',
|
||||
'is_superuser_role': 'my_peeps',
|
||||
'is_system_auditor_attr': 'else',
|
||||
'is_system_auditor_value': 'value2',
|
||||
'is_system_auditor_role': 'other_peeps',
|
||||
},
|
||||
{'junk': ['Invalid field.']},
|
||||
),
|
||||
(
|
||||
{
|
||||
'junk': 'something',
|
||||
},
|
||||
{
|
||||
'junk': ['Invalid field.'],
|
||||
},
|
||||
),
|
||||
(
|
||||
{
|
||||
'junk': 'something',
|
||||
'junk2': 'else',
|
||||
},
|
||||
{
|
||||
'junk': ['Invalid field.'],
|
||||
'junk2': ['Invalid field.'],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_internal_value_invalid(self, data, expected):
|
||||
field = SAMLUserFlagsAttrField()
|
||||
with pytest.raises(ValidationError) as e:
|
||||
field.to_internal_value(data)
|
||||
print(e.value.detail)
|
||||
assert e.value.detail == expected
|
||||
|
||||
|
||||
class TestLDAPGroupTypeParamsField:
|
||||
@pytest.mark.parametrize(
|
||||
"group_type, data, expected",
|
||||
|
||||
@ -31,6 +31,7 @@ describe('<SAML />', () => {
|
||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR: {},
|
||||
SAML_AUTO_CREATE_OBJECTS: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -37,6 +37,7 @@ describe('<SAMLDetail />', () => {
|
||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR: {},
|
||||
SAML_AUTO_CREATE_OBJECTS: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -89,6 +89,9 @@ function SAMLEdit() {
|
||||
),
|
||||
SOCIAL_AUTH_SAML_TEAM_MAP: formatJson(form.SOCIAL_AUTH_SAML_TEAM_MAP),
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR: formatJson(form.SOCIAL_AUTH_SAML_TEAM_ATTR),
|
||||
SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR: formatJson(
|
||||
form.SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR
|
||||
),
|
||||
SOCIAL_AUTH_SAML_SECURITY_CONFIG: formatJson(
|
||||
form.SOCIAL_AUTH_SAML_SECURITY_CONFIG
|
||||
),
|
||||
@ -181,6 +184,10 @@ function SAMLEdit() {
|
||||
name="SOCIAL_AUTH_SAML_TEAM_ATTR"
|
||||
config={saml.SOCIAL_AUTH_SAML_TEAM_ATTR}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR"
|
||||
config={saml.SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR}
|
||||
/>
|
||||
<ObjectField
|
||||
name="SOCIAL_AUTH_SAML_SECURITY_CONFIG"
|
||||
config={saml.SOCIAL_AUTH_SAML_SECURITY_CONFIG}
|
||||
|
||||
@ -40,6 +40,7 @@ describe('<SAMLEdit />', () => {
|
||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
|
||||
requestedAuthnContext: false,
|
||||
},
|
||||
@ -180,6 +181,7 @@ describe('<SAMLEdit />', () => {
|
||||
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: 'mock_cert',
|
||||
SOCIAL_AUTH_SAML_SUPPORT_CONTACT: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR: {},
|
||||
SOCIAL_AUTH_SAML_TEAM_MAP: {},
|
||||
SOCIAL_AUTH_SAML_TECHNICAL_CONTACT: {},
|
||||
SOCIAL_AUTH_SAML_SECURITY_CONFIG: {
|
||||
|
||||
@ -3706,6 +3706,28 @@
|
||||
"required": true,
|
||||
"read_only": false
|
||||
}
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR": {
|
||||
"type": "nested object",
|
||||
"required": false,
|
||||
"label": "SAML User Flags Attribute Mapping",
|
||||
"help_text": "Used to map super users and system auditors from SAML.",
|
||||
"category": "SAML",
|
||||
"category_slug": "saml",
|
||||
"placeholder": {
|
||||
"is_superuser_attr": "saml_attr",
|
||||
"is_superuser_value": "value",
|
||||
"is_superuser_role": "saml_role",
|
||||
"is_system_auditor_attr": "saml_attr",
|
||||
"is_system_auditor_value": "value",
|
||||
"is_system_auditor_role": "saml_role"
|
||||
},
|
||||
"default": {},
|
||||
"child": {
|
||||
"type": "field",
|
||||
"required": true,
|
||||
"read_only": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"GET": {
|
||||
@ -6305,6 +6327,17 @@
|
||||
"type": "field"
|
||||
}
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR": {
|
||||
"type": "nested object",
|
||||
"label": "SAML User Flags Attribute Mapping",
|
||||
"help_text": "Used to map super users and system auditors from SAML.",
|
||||
"category": "SAML",
|
||||
"category_slug": "saml",
|
||||
"defined_in_file": false,
|
||||
"child": {
|
||||
"type": "field"
|
||||
}
|
||||
},
|
||||
"NAMED_URL_FORMATS": {
|
||||
"type": "nested object",
|
||||
"label": "Formats of all available named urls",
|
||||
|
||||
@ -247,6 +247,7 @@
|
||||
"SOCIAL_AUTH_SAML_TEAM_MAP":null,
|
||||
"SOCIAL_AUTH_SAML_ORGANIZATION_ATTR":{},
|
||||
"SOCIAL_AUTH_SAML_TEAM_ATTR":{},
|
||||
"SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR":{},
|
||||
"NAMED_URL_FORMATS":{
|
||||
"organizations":"<name>",
|
||||
"teams":"<name>++<organization.name>",
|
||||
|
||||
@ -27,7 +27,7 @@ Here are the main `make` targets:
|
||||
|
||||
Notable files:
|
||||
|
||||
- `tools/docker-compose/inventory` file - used to configure the local AWX development deploymen
|
||||
- `tools/docker-compose/inventory` file - used to configure the local AWX development deployment
|
||||
- `migrate.yml` - playbook for migrating data from Local Docker to the Development Environment
|
||||
|
||||
### Prerequisites
|
||||
@ -241,6 +241,9 @@ $ make docker-compose
|
||||
- [Start a shell](#start-a-shell)
|
||||
- [Start AWX from the container shell](#start-awx-from-the-container-shell)
|
||||
- [Using Logstash](./docs/logstash.md)
|
||||
- [Start a Cluster](#start-a-cluster)
|
||||
- [Start with Minikube](#start-with-minikube)
|
||||
- [Keycloak Integration](#keycloak-integration)
|
||||
|
||||
### Start a Shell
|
||||
|
||||
@ -311,3 +314,81 @@ If you want to clean all things once your are done, you can do:
|
||||
```bash
|
||||
(host)$ make docker-compose-container-group-clean
|
||||
```
|
||||
|
||||
### Keycloak Integration
|
||||
Keycloak is a SAML provider and can be used to test AWX social auth. This section describes how to build a reference Keycloak instance and plumb it with AWX for testing purposes.
|
||||
|
||||
First, be sure that you have the awx.awx collection installed by running `make install_collection`.
|
||||
Next, make sure you have your containers running by running `make docker-compose`.
|
||||
|
||||
Note: The following instructions assume we are using the built-in postgres database container. If you are not using the internal database you can use this guide as a reference, updating the database fields as required for your connection.
|
||||
|
||||
We are now ready to run two one time commands to build and pre-populate the Keycloak database.
|
||||
|
||||
The first one time command will be creating a Keycloak database in your postgres database by running:
|
||||
```bash
|
||||
docker exec tools_postgres_1 /usr/bin/psql -U awx --command "create database keycloak with encoding 'UTF8';"
|
||||
```
|
||||
|
||||
After running this commenad the following message should appear and you should be returned to your prompt:
|
||||
```base
|
||||
CREATE DATABASE
|
||||
```
|
||||
|
||||
The second one time command will be to start a Keycloak container to build our admin user; be sure to set pg_username and pg_password to work for you installation. Note: the command below set the username as admin with a password of admin, you can change this if you want. Also, if you are using your own container or have changed the pg_username please update the command accordingly.
|
||||
```bash
|
||||
PG_PASSWORD=`cat tools/docker-compose/_sources/secrets/pg_password.yml | cut -f 2 -d \'`
|
||||
docker run -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin --net=_sources_default \
|
||||
-e DB_VENDOR=postgres -e DB_ADDR=postgres -e DB_DATABASE=keycloak -e DB_USER=awx -e DB_PASSWORD=${PG_PASSWORD} \
|
||||
quay.io/keycloak/keycloak:15.0.2
|
||||
```
|
||||
|
||||
Once you see a message like: `WFLYSRV0051: Admin console listening on http://127.0.0.1:9990` you can stop the container.
|
||||
|
||||
Now that we have performed the one time setup anytime you want to run a Keycloak instance alongside AWX we can start docker-compose with the KEYCLOAK option to get a Keycloak instance with the command:
|
||||
```bash
|
||||
KEYCLOAK=true make docker-compose
|
||||
```
|
||||
|
||||
Go ahead and stop your existing docker-compose run and restart with Keycloak before proceeding to the next steps.
|
||||
|
||||
Once the containers come up a new port (8443) should be exposed and the Keycloak interface should be running on that port. Connect to this through a url like `https://localhost:8443` to confirm that Keycloak has stared. If you wanted to login and look at Keycloak itself you could select the "Administration console" link and log into the UI the username/password set in the previous `docker run` command. For more information about Keycloak and links to their documentation see their project at https://github.com/keycloak/keycloak.
|
||||
|
||||
Now we are ready to configure and plumb Keycloak with AWX. To do this we have provided a playbook which will:
|
||||
* Create a certificate for data exchange between Keycloak and AWX.
|
||||
* Create a realm in Keycloak with a client for AWX and 3 users.
|
||||
* Backup and configure the SMAL adapter in AWX. NOTE: the private key of any existing SAML adapters can not be backed up through the API, you need a DB backup to recover this.
|
||||
|
||||
Before we can run the playbook we need to understand that SAML works by sending redirects between AWX and Keycloak through the browser. Because of this we have to tell both AWX and Keycloak how they will construct the redirect URLs. On the Keycloak side, this is done within the realm configuration and on the AWX side its done through the SAML settings. The playbook requires a variable called `container_reference` to be set. The container_reference variable needs to be how your browser will be able to talk to the running containers. Here are some examples of how to choose a proper container_reference.
|
||||
* If you develop on a mac which runs a Fedora VM which has AWX running within that and the browser you use to access AWX runs on the mac. The the VM with the container has its own IP that is mapped to a name like `tower.home.net`. In this scenario your "container_reference" could be either the IP of the VM or the tower.home.net friendly name.
|
||||
* If you are on a Fedora work station running AWX and also using a browser on your workstation you could use localhost, your work stations IP or hostname as the container_reference.
|
||||
|
||||
In addition to container_reference, there are some additional variables which you can override if you need/choose to do so. Here are their names and default values:
|
||||
```yaml
|
||||
keycloak_user: admin
|
||||
keycloak_pass: admin
|
||||
cert_subject: "/C=US/ST=NC/L=Durham/O=awx/CN="
|
||||
```
|
||||
|
||||
* keycloak_(user|pass) need to change if you modified the user when starting the initial container above.
|
||||
* cert_subject will be the subject line of the certificate shared between AWX and keycloak you can change this if you like or just use the defaults.
|
||||
|
||||
To override any of the variables above you can add more `-e` arguments to the playbook run below. For example, if you simply need to change the `keycloak_pass` add the argument `-r keycloak_pass=my_secret_pass` to the next command.
|
||||
|
||||
In addition, you may need to override the username or password to get into your AWX instance. We log into AWX in order to read and write the SAML settings. This can be done in several ways because we are using the awx.awx collection. The easiest way is to set environment variables such as `CONTROLLER_USERNAME`. See the awx.awx documentation for more information on setting environment variables. In the example provided below we are showing an example of specifying a username/password for authentication.
|
||||
|
||||
Now that we have all of our variables covered we can run the playbook like:
|
||||
```bash
|
||||
export CONTROLLER_USERNAME=<your username>
|
||||
export CONTROLLER_PASSWORD=<your password>
|
||||
ansible-playbook tools/docker-compose/ansible/plumb_keycloak.yml -e container_reference=<your container_reference here>
|
||||
```
|
||||
|
||||
Once the playbook is done running SAML should now be setup in your development environment. This realm has three users with the following username/passwords:
|
||||
1. awx_unpriv:unpriv123
|
||||
2. awx_admin:admin123
|
||||
3. awx_auditor:audit123
|
||||
|
||||
The first account is a normal user. The second account has the attribute is_superuser set in Keycloak so will be a super user in AWX. The third account has the is_system_auditor attribute in Keycloak so it will be a system auditor in AWX. To log in with one of these Keycloak users go to the AWX login screen and click the small "Sign In With SAML Keycloak" button at the bottom of the login box.
|
||||
|
||||
|
||||
|
||||
81
tools/docker-compose/ansible/plumb_keycloak.yml
Normal file
81
tools/docker-compose/ansible/plumb_keycloak.yml
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
- name: Plumb a keycloak instance
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: False
|
||||
vars:
|
||||
private_key_file: ../_sources/keycloak.key
|
||||
public_key_file: ../_sources/keycloak.cert
|
||||
awx_host: "https://localhost:8043"
|
||||
keycloak_realm_template: ../_sources/keycloak.awx.realm.json
|
||||
keycloak_user: admin
|
||||
keycloak_pass: admin
|
||||
cert_subject: "/C=US/ST=NC/L=Durham/O=awx/CN="
|
||||
tasks:
|
||||
- name: Generate certificates for keycloak
|
||||
command: 'openssl req -new -x509 -days 365 -nodes -out {{ public_key_file }} -keyout {{ private_key_file }} -subj "{{ cert_subject }}"'
|
||||
args:
|
||||
creates: "{{ public_key_file }}"
|
||||
|
||||
- name: Load certs, existing and new SAML settings
|
||||
set_fact:
|
||||
private_key: "{{ private_key_content }}"
|
||||
public_key: "{{ public_key_content }}"
|
||||
public_key_trimmed: "{{ public_key_content | regex_replace('-----BEGIN CERTIFICATE-----\\\\n', '') | regex_replace('\\\\n-----END CERTIFICATE-----', '') }}"
|
||||
existing_saml: "{{ lookup('awx.awx.controller_api', 'settings/saml', host=awx_host, verify_ssl=false) }}"
|
||||
new_saml: "{{ lookup('template', 'saml_settings.json.j2') }}"
|
||||
vars:
|
||||
# We add the extra \\ in here so that when jinja is templating out the files we end up with \n in the strings.
|
||||
public_key_content: "{{ lookup('file', public_key_file) | regex_replace('\n', '\\\\n') }}"
|
||||
private_key_content: "{{ lookup('file', private_key_file) | regex_replace('\n', '\\\\n') }}"
|
||||
|
||||
- name: Displauy existing SAML configuration
|
||||
debug:
|
||||
msg:
|
||||
- "Here is your existing SAML configuration for reference:"
|
||||
- "{{ existing_saml }}"
|
||||
|
||||
- pause:
|
||||
prompt: "Continuing to run this will replace your existing saml settings (displayed above). They will all be captured except for your private key. Be sure that is backed up before continuing"
|
||||
|
||||
- name: Write out the existing content
|
||||
copy:
|
||||
dest: "../_sources/existing_saml_adapter_settings.json"
|
||||
content: "{{ existing_saml }}"
|
||||
|
||||
- name: Configure AWX SAML adapter
|
||||
awx.awx.settings:
|
||||
settings: "{{ new_saml }}"
|
||||
controller_host: "{{ awx_host }}"
|
||||
validate_certs: False
|
||||
|
||||
- name: Get a keycloak token
|
||||
uri:
|
||||
url: "https://localhost:8443/auth/realms/master/protocol/openid-connect/token"
|
||||
method: POST
|
||||
body_format: form-urlencoded
|
||||
body:
|
||||
client_id: "admin-cli"
|
||||
username: "{{ keycloak_user }}"
|
||||
password: "{{ keycloak_pass }}"
|
||||
grant_type: "password"
|
||||
validate_certs: False
|
||||
register: keycloak_response
|
||||
|
||||
- name: Template the AWX realm
|
||||
template:
|
||||
src: keycloak.awx.realm.json.j2
|
||||
dest: "{{ keycloak_realm_template }}"
|
||||
|
||||
- name: Create the AWX realm
|
||||
uri:
|
||||
url: "https://localhost:8443/auth/admin/realms"
|
||||
method: POST
|
||||
body_format: json
|
||||
body: "{{ lookup('file', keycloak_realm_template) }}"
|
||||
validate_certs: False
|
||||
headers:
|
||||
Authorization: "Bearer {{ keycloak_response.json.access_token }}"
|
||||
status_code: 201
|
||||
register: realm_creation
|
||||
changed_when: True
|
||||
@ -16,3 +16,5 @@ receptor_work_sign_reconfigure: false
|
||||
work_sign_key_dir: '../_sources/receptor'
|
||||
work_sign_private_keyfile: "{{ work_sign_key_dir }}/work_private_key.pem"
|
||||
work_sign_public_keyfile: "{{ work_sign_key_dir }}/work_public_key.pem"
|
||||
|
||||
enable_keycloak: false
|
||||
|
||||
@ -79,6 +79,23 @@ services:
|
||||
{% set container_postfix = loop.index %}
|
||||
- "awx_{{ container_postfix }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if enable_keycloak|bool %}
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:15.0.2
|
||||
container_name: tools_keycloak_1
|
||||
hostname: keycloak
|
||||
user: "{{ ansible_user_uid }}"
|
||||
ports:
|
||||
- "8443:8443"
|
||||
environment:
|
||||
DB_VENDOR: postgres
|
||||
DB_ADDR: postgres
|
||||
DB_DATABASE: keycloak
|
||||
DB_USER: {{ pg_username }}
|
||||
DB_PASSWORD: {{ pg_password }}
|
||||
depends_on:
|
||||
- postgres
|
||||
{% endif %}
|
||||
# A useful container that simply passes through log messages to the console
|
||||
# helpful for testing awx/tower logging
|
||||
|
||||
1770
tools/docker-compose/ansible/templates/keycloak.awx.realm.json.j2
Normal file
1770
tools/docker-compose/ansible/templates/keycloak.awx.realm.json.j2
Normal file
File diff suppressed because it is too large
Load Diff
51
tools/docker-compose/ansible/templates/saml_settings.json.j2
Normal file
51
tools/docker-compose/ansible/templates/saml_settings.json.j2
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"SAML_AUTO_CREATE_OBJECTS": true,
|
||||
"SOCIAL_AUTH_SAML_SP_ENTITY_ID": "{{ container_reference }}:8043",
|
||||
"SOCIAL_AUTH_SAML_SP_PUBLIC_CERT": "{{ public_key_content | regex_replace('\\n', '') }}",
|
||||
"SOCIAL_AUTH_SAML_SP_PRIVATE_KEY": "{{ private_key_content | regex_replace('\\n', '') }}",
|
||||
"SOCIAL_AUTH_SAML_ORG_INFO": {
|
||||
"en-US": {
|
||||
"url": "https://{{ container_reference }}:8443",
|
||||
"name": "Keycloak",
|
||||
"displayname": "Keycloak Solutions Engineering"
|
||||
}
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_TECHNICAL_CONTACT": {
|
||||
"givenName": "Me Myself",
|
||||
"emailAddress": "noone@nowhere.com"
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_SUPPORT_CONTACT": {
|
||||
"givenName": "Me Myself",
|
||||
"emailAddress": "noone@nowhere.com"
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_ENABLED_IDPS": {
|
||||
"Keycloak": {
|
||||
"attr_user_permanent_id": "name_id",
|
||||
"entity_id": "https://{{ container_reference }}:8443/auth/realms/awx",
|
||||
"attr_groups": "groups",
|
||||
"url": "https://{{ container_reference }}:8443/auth/realms/awx/protocol/saml",
|
||||
"attr_first_name": "first_name",
|
||||
"x509cert": "{{ public_key_content | regex_replace('\\n', '') }}",
|
||||
"attr_email": "email",
|
||||
"attr_last_name": "last_name",
|
||||
"attr_username": "username"
|
||||
}
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_SECURITY_CONFIG": {
|
||||
"requestedAuthnContext": false
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_SP_EXTRA": null,
|
||||
"SOCIAL_AUTH_SAML_EXTRA_DATA": null,
|
||||
"SOCIAL_AUTH_SAML_ORGANIZATION_MAP": {
|
||||
"Default": {
|
||||
"users": true
|
||||
}
|
||||
},
|
||||
"SOCIAL_AUTH_SAML_TEAM_MAP": null,
|
||||
"SOCIAL_AUTH_SAML_ORGANIZATION_ATTR": {},
|
||||
"SOCIAL_AUTH_SAML_TEAM_ATTR": {},
|
||||
"SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR": {
|
||||
"is_superuser_attr": "is_superuser",
|
||||
"is_system_auditor_attr": "is_system_auditor"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user