Configure Tower in Tower:

* Add separate Django app for configuration: awx.conf.
* Migrate from existing main.TowerSettings model to conf.Setting.
* Add settings wrapper to allow get/set/del via django.conf.settings.
* Update existing references to tower_settings to use django.conf.settings.
* Add a settings registry to allow for each Django app to register configurable settings.
* Support setting validation and conversion using Django REST Framework fields.
* Add /api/v1/settings/ to display a list of setting categories.
* Add /api/v1/settings/<slug>/ to display all settings in a category as a single object.
* Allow PUT/PATCH to update setting singleton, DELETE to reset to defaults.
* Add "all" category to display all settings across categories.
* Add "changed" category to display only settings configured in the database.
* Support per-user settings via "user" category (/api/v1/settings/user/).
* Support defaults for user settings via "user-defaults" category (/api/v1/settings/user-defaults/).
* Update serializer metadata to support category, category_slug and placeholder on OPTIONS responses.
* Update serializer metadata to handle child fields of a list/dict.
* Hide raw data form in browsable API for OPTIONS and DELETE.
* Combine existing licensing code into single "TaskEnhancer" class.
* Move license helper functions from awx.api.license into awx.conf.license.
* Update /api/v1/config/ to read/verify/update license using TaskEnhancer and settings wrapper.
* Add support for caching settings accessed via settings wrapper.
* Invalidate cached settings when Setting model changes or is deleted.
* Preload all database settings into cache on first access via settings wrapper.
* Add support for read-only settings than can update their value depending on other settings.
* Use setting_changed signal whenever a setting changes.
* Register configurable authentication, jobs, system and ui settings.
* Register configurable LDAP, RADIUS and social auth settings.
* Add custom fields and validators for URL, LDAP, RADIUS and social auth settings.
* Rewrite existing validator for Credential ssh_private_key to support validating private keys, certs or combinations of both.
* Get all unit/functional tests working with above changes.
* Add "migrate_to_database_settings" command to determine settings to be migrated into the database and comment them out when set in Python settings files.
* Add support for migrating license key from file to database.
* Remove database-configuable settings from local_settings.py example files.
* Update setup role to no longer install files for database-configurable settings.

f 94ff6ee More settings work.
f af4c4e0 Even more db settings stuff.
f 96ea9c0 More settings, attempt at singleton serializer for settings.
f 937c760 More work on singleton/category views in API, add code to comment out settings in Python files, work on command to migrate settings to database.
f 425b0d3 Minor fixes for sprint demo.
f ea402a4 Add support for read-only settings, cleanup license engine, get license support working with DB settings.
f ec289e4 Rename migration, minor fixmes, update setup role.
f 603640b Rewrite key/cert validator, finish adding social auth fields, hook up signals for setting_changed, use None to imply a setting is not set.
f 67d1b5a Get functional/unit tests passing.
f 2919b62 Flake8 fixes.
f e62f421 Add redbaron to requirements, get file to database migration working (except for license).
f c564508 Add support for migrating license file.
f 982f767 Add support for regex in social map fields.
This commit is contained in:
Chris Church
2016-09-26 22:14:47 -04:00
parent 609a3e6f2f
commit 6ebe45b1bd
92 changed files with 4401 additions and 1791 deletions

View File

@@ -19,3 +19,6 @@ def xmlsec_initialize(*args, **kwargs):
xmlsec_initialized = True
dm.xmlsec.binding.initialize = xmlsec_initialize
default_app_config = 'awx.sso.apps.SSOConfig'

9
awx/sso/apps.py Normal file
View File

@@ -0,0 +1,9 @@
# Django
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
class SSOConfig(AppConfig):
name = 'awx.sso'
verbose_name = _('Single Sign-On')

View File

@@ -3,11 +3,13 @@
# Python
import logging
import uuid
# Django
from django.dispatch import receiver
from django.contrib.auth.models import User
from django.conf import settings as django_settings
from django.core.signals import setting_changed
# django-auth-ldap
from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings
@@ -23,7 +25,7 @@ from social.backends.saml import SAMLAuth as BaseSAMLAuth
from social.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvider
# Ansible Tower
from awx.api.license import feature_enabled
from awx.conf.license import feature_enabled
logger = logging.getLogger('awx.sso.backends')
@@ -43,6 +45,20 @@ class LDAPBackend(BaseLDAPBackend):
settings_prefix = 'AUTH_LDAP_'
def __init__(self, *args, **kwargs):
self._dispatch_uid = uuid.uuid4()
super(LDAPBackend, self).__init__(*args, **kwargs)
setting_changed.connect(self._on_setting_changed, dispatch_uid=self._dispatch_uid)
def __del__(self):
setting_changed.disconnect(dispatch_uid=self._dispatch_uid)
def _on_setting_changed(self, sender, **kwargs):
# If any AUTH_LDAP_* setting changes, force settings to be reloaded for
# this backend instance.
if kwargs.get('setting', '').startswith(self.settings_prefix):
self._settings = None
def _get_settings(self):
if self._settings is None:
self._settings = LDAPSettings(self.settings_prefix)

967
awx/sso/conf.py Normal file
View File

@@ -0,0 +1,967 @@
# Python
import collections
import urlparse
# Django
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
# Tower
from awx.conf import register
from awx.sso import fields
from awx.main.validators import validate_private_key, validate_certificate
from awx.sso.validators import * # noqa
class SocialAuthCallbackURL(object):
def __init__(self, provider):
self.provider = provider
def __call__(self):
path = reverse('social:complete', args=(self.provider,))
return urlparse.urljoin(settings.TOWER_URL_BASE, path)
SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT = _('''\
Mapping to organization admins/users from social auth accounts. This setting
controls which users are placed into which Tower organizations based on
their username and email address. Dictionary keys are organization names.
organizations will be created if not present if the license allows for
multiple organizations, otherwise the single default organization is used
regardless of the key. Values are dictionaries defining the options for
each organization's membership. For each organization it is possible to
specify which users are automatically users of the organization and also
which users can administer the organization.
- admins: None, True/False, string or list/tuple of strings.
If None, organization admins will not be updated.
If True, all users using social auth will automatically be added as admins
of the organization.
If False, no social auth users will be automatically added as admins of
the organiation.
If a string or list of strings, specifies the usernames and emails for
users who will be added to the organization. Strings in the format
"/<pattern>/<flags>" will be interpreted as regular expressions and may also
be used instead of string literals; only "i" and "m" are supported for flags.
- remove_admins: True/False. Defaults to False.
If True, a user who does not match will be removed from the organization's
administrative list.
- users: None, True/False, string or list/tuple of strings. Same rules apply
as for admins.
- remove_users: True/False. Defaults to False. Same rules as apply for
remove_admins.\
''')
# FIXME: /regex/gim (flags)
SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER = collections.OrderedDict([
('Default', collections.OrderedDict([
('users', True),
])),
('Test Org', collections.OrderedDict([
('admins', ['admin@example.com']),
('users', True),
])),
('Test Org 2', collections.OrderedDict([
('admins', ['admin@example.com', r'/^tower-[^@]+*?@.*$/']),
('remove_admins', True),
('users', r'/^[^@].*?@example\.com$/i'),
('remove_users', True),
])),
])
SOCIAL_AUTH_TEAM_MAP_HELP_TEXT = _('''\
Mapping of team members (users) from social auth accounts. Keys are team
names (will be created if not present). Values are dictionaries of options
for each team's membership, where each can contain the following parameters:
- organization: string. The name of the organization to which the team
belongs. The team will be created if the combination of organization and
team name does not exist. The organization will first be created if it
does not exist. If the license does not allow for multiple organizations,
the team will always be assigned to the single default organization.
- users: None, True/False, string or list/tuple of strings.
If None, team members will not be updated.
If True/False, all social auth users will be added/removed as team
members.
If a string or list of strings, specifies expressions used to match users.
User will be added as a team member if the username or email matches.
Strings in the format "/<pattern>/<flags>" will be interpreted as regular
expressions and may also be used instead of string literals; only "i" and "m"
are supported for flags.
- remove: True/False. Defaults to False. If True, a user who does not match
the rules above will be removed from the team.\
''')
SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER = collections.OrderedDict([
('My Team', collections.OrderedDict([
('organization', 'Test Org'),
('users', [r'/^[^@]+?@test\.example\.com$/']),
('remove', True),
])),
('Other Team', collections.OrderedDict([
('organization', 'Test Org 2'),
('users', r'/^[^@]+?@test2\.example\.com$/i'),
('remove', False),
])),
])
###############################################################################
# AUTHENTICATION BACKENDS DYNAMIC SETTING
###############################################################################
register(
'AUTHENTICATION_BACKENDS',
field_class=fields.AuthenticationBackendsField,
label=_('Authentication Backends'),
help_text=_('List of authentication backends that are enabled based on '
'license features and other authentication settings.'),
read_only=True,
depends_on=fields.AuthenticationBackendsField.get_all_required_settings(),
category=_('Authentication'),
category_slug='authentication',
)
register(
'SOCIAL_AUTH_ORGANIZATION_MAP',
field_class=fields.SocialOrganizationMapField,
default={},
label=_('Social Auth Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('Authentication'),
category_slug='authentication',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_TEAM_MAP',
field_class=fields.SocialTeamMapField,
default={},
label=_('Social Auth Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('Authentication'),
category_slug='authentication',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_USER_FIELDS',
field_class=fields.StringListField,
allow_null=True,
default=None,
label=_('Social Auth User Fields'),
help_text=_('When set to an empty list `[]`, this setting prevents new user '
'accounts from being created. Only users who have previously '
'logged in using social auth or have a user account with a '
'matching email address will be able to login.'),
category=_('Authentication'),
category_slug='authentication',
placeholder=['username', 'email'],
)
###############################################################################
# LDAP AUTHENTICATION SETTINGS
###############################################################################
register(
'AUTH_LDAP_SERVER_URI',
field_class=fields.URLField,
schemes=('ldap', 'ldaps'),
allow_blank=True,
label=_('LDAP Server URI'),
help_text=_('URI to connect to LDAP server, such as "ldap://ldap.example.com:389" '
'(non-SSL) or "ldaps://ldap.example.com:636" (SSL). LDAP authentication '
'is disabled if this parameter is empty or your license does not '
'enable LDAP support.'),
category=_('LDAP'),
category_slug='ldap',
placeholder='ldaps://ldap.example.com:636',
)
register(
'AUTH_LDAP_BIND_DN',
field_class=fields.CharField,
allow_blank=True,
default='',
validators=[validate_ldap_bind_dn],
label=_('LDAP Bind DN'),
help_text=_('DN (Distinguished Name) of user to bind for all search queries. '
'Normally in the format "CN=Some User,OU=Users,DC=example,DC=com" '
'but may also be specified as "DOMAIN\username" for Active Directory. '
'This is the system user account we will use to login to query LDAP '
'for other user information.'),
category=_('LDAP'),
category_slug='ldap',
)
register(
'AUTH_LDAP_BIND_PASSWORD',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('LDAP Bind Password'),
help_text=_('Password used to bind LDAP user account.'),
category=_('LDAP'),
category_slug='ldap',
)
register(
'AUTH_LDAP_START_TLS',
field_class=fields.BooleanField,
default=False,
label=_('LDAP Start TLS'),
help_text=_('Whether to enable TLS when the LDAP connection is not using SSL.'),
category=_('LDAP'),
category_slug='ldap',
)
register(
'AUTH_LDAP_CONNECTION_OPTIONS',
field_class=fields.LDAPConnectionOptionsField,
default={'OPT_REFERRALS': 0},
label=_('LDAP Connection Options'),
help_text=_('Additional options to set for the LDAP connection. LDAP '
'referrals are disabled by default (to prevent certain LDAP '
'queries from hanging with AD). Option names should be strings '
'(e.g. "OPT_REFERRALS"). Refer to '
'https://www.python-ldap.org/doc/html/ldap.html#options for '
'possible options and values that can be set.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict([
('OPT_REFERRALS', 0),
]),
)
register(
'AUTH_LDAP_USER_SEARCH',
field_class=fields.LDAPSearchUnionField,
default=[],
label=_('LDAP User Search'),
help_text=_('LDAP search query to find users. Any user that matches the given '
'pattern will be able to login to Tower. The user should also be '
'mapped into an Tower organization (as defined in the '
'AUTH_LDAP_ORGANIZATION_MAP setting). If multiple search queries '
'need to be supported use of "LDAPUnion" is possible. See '
'python-ldap documentation as linked at the top of this section.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=(
'OU=Users,DC=example,DC=com',
'SCOPE_SUBTREE',
'(sAMAccountName=%(user)s)',
),
)
register(
'AUTH_LDAP_USER_DN_TEMPLATE',
field_class=fields.LDAPDNWithUserField,
allow_blank=True,
default='',
label=_('LDAP User DN Template'),
help_text=_('Alternative to user search, if user DNs are all of the same '
'format. This approach will be more efficient for user lookups than '
'searching if it is usable in your organizational environment. If '
'this setting has a value it will be used instead of '
'AUTH_LDAP_USER_SEARCH.'),
category=_('LDAP'),
category_slug='ldap',
placeholder='uid=%(user)s,OU=Users,DC=example,DC=com',
)
register(
'AUTH_LDAP_USER_ATTR_MAP',
field_class=fields.LDAPUserAttrMapField,
default={},
label=_('LDAP User Attribute Map'),
help_text=_('Mapping of LDAP user schema to Tower API user atrributes (key is '
'user attribute name, value is LDAP attribute name). The default '
'setting is valid for ActiveDirectory but users with other LDAP '
'configurations may need to change the values (not the keys) of '
'the dictionary/hash-table.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict([
('first_name', 'givenName'),
('last_name', 'sn'),
('email', 'mail'),
]),
)
register(
'AUTH_LDAP_GROUP_SEARCH',
field_class=fields.LDAPSearchField,
default=[],
label=_('LDAP Group Search'),
help_text=_('Users in Tower are mapped to organizations based on their '
'membership in LDAP groups. This setting defines the LDAP search '
'query to find groups. Note that this, unlike the user search '
'above, does not support LDAPSearchUnion.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=(
'DC=example,DC=com',
'SCOPE_SUBTREE',
'(objectClass=group)',
),
)
register(
'AUTH_LDAP_GROUP_TYPE',
field_class=fields.LDAPGroupTypeField,
label=_('LDAP Group Type'),
help_text=_('The group type may need to be changed based on the type of the '
'LDAP server. Values are listed at: '
'http://pythonhosted.org/django-auth-ldap/groups.html#types-of-groups'),
category=_('LDAP'),
category_slug='ldap',
)
register(
'AUTH_LDAP_REQUIRE_GROUP',
field_class=fields.LDAPDNField,
allow_blank=True,
default='',
label=_('LDAP Require Group'),
help_text=_('Group DN required to login. If specified, user must be a member '
'of this group to login via LDAP. If not set, everyone in LDAP '
'that matches the user search will be able to login via Tower. '
'Only one require group is supported.'),
category=_('LDAP'),
category_slug='ldap',
placeholder='CN=Tower Users,OU=Users,DC=example,DC=com',
)
register(
'AUTH_LDAP_DENY_GROUP',
field_class=fields.LDAPDNField,
allow_blank=True,
default='',
label=_('LDAP Deny Group'),
help_text=_('Group DN denied from login. If specified, user will not be '
'allowed to login if a member of this group. Only one deny group '
'is supported.'),
category=_('LDAP'),
category_slug='ldap',
placeholder='CN=Disabled Users,OU=Users,DC=example,DC=com',
)
register(
'AUTH_LDAP_USER_FLAGS_BY_GROUP',
field_class=fields.LDAPUserFlagsField,
default={},
label=_('LDAP User Flags By Group'),
help_text=_('User profile flags updated from group membership (key is user '
'attribute name, value is group DN). These are boolean fields '
'that are matched based on whether the user is a member of the '
'given group. So far only is_superuser is settable via this '
'method. This flag is set both true and false at login time '
'based on current LDAP settings.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict([
('is_superuser', 'CN=Domain Admins,CN=Users,DC=example,DC=com'),
]),
)
register(
'AUTH_LDAP_ORGANIZATION_MAP',
field_class=fields.LDAPOrganizationMapField,
default={},
label=_('LDAP Organization Map'),
help_text=_('Mapping between organization admins/users and LDAP groups. This '
'controls what users are placed into what Tower organizations '
'relative to their LDAP group memberships. Keys are organization '
'names. Organizations will be created if not present. Values are '
'dictionaries defining the options for each organization\'s '
'membership. For each organization it is possible to specify '
'what groups are automatically users of the organization and also '
'what groups can administer the organization.\n\n'
' - admins: None, True/False, string or list of strings.\n'
' If None, organization admins will not be updated based on '
'LDAP values.\n'
' If True, all users in LDAP will automatically be added as '
'admins of the organization.\n'
' If False, no LDAP users will be automatically added as admins '
'of the organiation.\n'
' If a string or list of strings, specifies the group DN(s) '
'that will be added of the organization if they match any of the '
'specified groups.\n'
' - remove_admins: True/False. Defaults to True.\n'
' If True, a user who is not an member of the given groups will '
'be removed from the organization\'s administrative list.\n'
' - users: None, True/False, string or list/tuple of strings. '
'Same rules apply as for admins.\n'
' - remove_users: True/False. Defaults to True. Same rules apply '
'as for remove_admins.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict([
('Test Org', collections.OrderedDict([
('admins', 'CN=Domain Admins,CN=Users,DC=example,DC=com'),
('users', ['CN=Domain Users,CN=Users,DC=example,DC=com']),
('remove_users', True),
('remove_admins', True),
])),
('Test Org 2', collections.OrderedDict([
('admins', 'CN=Administrators,CN=Builtin,DC=example,DC=com'),
('users', True),
('remove_users', True),
('remove_admins', True),
])),
]),
)
register(
'AUTH_LDAP_TEAM_MAP',
field_class=fields.LDAPTeamMapField,
default={},
label=_('LDAP Team Map'),
help_text=_('Mapping between team members (users) and LDAP groups. Keys are '
'team names (will be created if not present). Values are '
'dictionaries of options for each team\'s membership, where each '
'can contain the following parameters:\n\n'
' - organization: string. The name of the organization to which '
'the team belongs. The team will be created if the combination of '
'organization and team name does not exist. The organization will '
'first be created if it does not exist.\n'
' - users: None, True/False, string or list/tuple of strings.\n'
' If None, team members will not be updated.\n'
' If True/False, all LDAP users will be added/removed as team '
'members.\n'
' If a string or list of strings, specifies the group DN(s). '
'User will be added as a team member if the user is a member of '
'ANY of these groups.\n'
'- remove: True/False. Defaults to False. If True, a user who is '
'not a member of the given groups will be removed from the team.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict([
('My Team', collections.OrderedDict([
('organization', 'Test Org'),
('users', ['CN=Domain Users,CN=Users,DC=example,DC=com']),
('remove', True),
])),
('Other Team', collections.OrderedDict([
('organization', 'Test Org 2'),
('users', 'CN=Other Users,CN=Users,DC=example,DC=com'),
('remove', False),
])),
]),
)
###############################################################################
# RADIUS AUTHENTICATION SETTINGS
###############################################################################
register(
'RADIUS_SERVER',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('RADIUS Server'),
help_text=_('Hostname/IP of RADIUS server. RADIUS authentication will be '
'disabled if this setting is empty.'),
category=_('RADIUS'),
category_slug='radius',
placeholder='radius.example.com',
)
register(
'RADIUS_PORT',
field_class=fields.IntegerField,
min_value=1,
max_value=65535,
default=1812,
label=_('RADIUS Port'),
help_text=_('Port of RADIUS server.'),
category=_('RADIUS'),
category_slug='radius',
)
register(
'RADIUS_SECRET',
field_class=fields.RADIUSSecretField,
allow_blank=True,
default='',
label=_('RADIUS Secret'),
help_text=_('Shared secret for authenticating to RADIUS server.'),
category=_('RADIUS'),
category_slug='radius',
)
###############################################################################
# GOOGLE OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('google-oauth2'),
label=_('Google OAuth2 Callback URL'),
help_text=_('Create a project at https://console.developers.google.com/ to '
'obtain an OAuth2 key and secret for a web application. Ensure '
'that the Google+ API is enabled. Provide this URL as the '
'callback URL for your application.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
)
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY',
field_class=fields.CharField,
allow_blank=True,
label=_('Google OAuth2 Key'),
help_text=_('The OAuth2 key from your web application at https://console.developers.google.com/.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder='528620852399-gm2dt4hrl2tsj67fqamk09k1e0ad6gd8.apps.googleusercontent.com',
)
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET',
field_class=fields.CharField,
allow_blank=True,
label=_('Google OAuth2 Secret'),
help_text=_('The OAuth2 secret from your web application at https://console.developers.google.com/.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder='q2fMVCmEregbg-drvebPp8OW',
)
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS',
field_class=fields.StringListField,
default=[],
label=_('Google OAuth2 Whitelisted Domains'),
help_text=_('Update this setting to restrict the domains who are allowed to '
'login using Google OAuth2.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder=['example.com'],
)
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS',
field_class=fields.DictField,
default={},
label=_('Google OAuth2 Extra Arguments'),
help_text=_('Extra arguments for Google OAuth2 login. When only allowing a '
'single domain to authenticate, set to `{"hd": "yourdomain.com"}` '
'and Google will not display any other accounts even if the user '
'is logged in with multiple Google accounts.'),
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder={'hd': 'example.com'},
)
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP',
field_class=fields.SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('Google OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP',
field_class=fields.SocialTeamMapField,
allow_null=True,
default=None,
label=_('Google OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('Google OAuth2'),
category_slug='google-oauth2',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# GITHUB OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GITHUB_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('github'),
label=_('GitHub OAuth2 Callback URL'),
help_text=_('Create a developer application at '
'https://github.com/settings/developers to obtain an OAuth2 '
'key (Client ID) and secret (Client Secret). Provide this URL '
'as the callback URL for your application.'),
category=_('GitHub OAuth2'),
category_slug='github',
)
register(
'SOCIAL_AUTH_GITHUB_KEY',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub OAuth2 Key'),
help_text=_('The OAuth2 key (Client ID) from your GitHub developer application.'),
category=_('GitHub OAuth2'),
category_slug='github',
)
register(
'SOCIAL_AUTH_GITHUB_SECRET',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub OAuth2 Secret'),
help_text=_('The OAuth2 secret (Client Secret) from your GitHub developer application.'),
category=_('GitHub OAuth2'),
category_slug='github',
)
register(
'SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP',
field_class=fields.SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('GitHub OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('GitHub OAuth2'),
category_slug='github',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GITHUB_TEAM_MAP',
field_class=fields.SocialTeamMapField,
allow_null=True,
default=None,
label=_('GitHub OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('GitHub OAuth2'),
category_slug='github',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# GITHUB ORG OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('github-org'),
label=_('GitHub Organization OAuth2 Callback URL'),
help_text=_('Create an organization-owned application at '
'https://github.com/organizations/<yourorg>/settings/applications '
'and obtain an OAuth2 key (Client ID) and secret (Client Secret). '
'Provide this URL as the callback URL for your application.'),
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
)
register(
'SOCIAL_AUTH_GITHUB_ORG_KEY',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub Organization OAuth2 Key'),
help_text=_('The OAuth2 key (Client ID) from your GitHub organization application.'),
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
)
register(
'SOCIAL_AUTH_GITHUB_ORG_SECRET',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub Organization OAuth2 Secret'),
help_text=_('The OAuth2 secret (Client Secret) from your GitHub organization application.'),
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
)
register(
'SOCIAL_AUTH_GITHUB_ORG_NAME',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub Organization Name'),
help_text=_('The name of your GitHub organization, as used in your '
'organization\'s URL: https://github.com/<yourorg>/.'),
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
)
register(
'SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP',
field_class=fields.SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('GitHub Organization OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP',
field_class=fields.SocialTeamMapField,
allow_null=True,
default=None,
label=_('GitHub Organization OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('GitHub Organization OAuth2'),
category_slug='github-org',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# GITHUB TEAM OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
register(
'SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('github-team'),
label=_('GitHub Team OAuth2 Callback URL'),
help_text=_('Create an organization-owned application at '
'https://github.com/organizations/<yourorg>/settings/applications '
'and obtain an OAuth2 key (Client ID) and secret (Client Secret). '
'Provide this URL as the callback URL for your application.'),
category=_('GitHub Team OAuth2'),
category_slug='github-team',
)
register(
'SOCIAL_AUTH_GITHUB_TEAM_KEY',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub Team OAuth2 Key'),
help_text=_('The OAuth2 key (Client ID) from your GitHub organization application.'),
category=_('GitHub Team OAuth2'),
category_slug='github-team',
)
register(
'SOCIAL_AUTH_GITHUB_TEAM_SECRET',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub Team OAuth2 Secret'),
help_text=_('The OAuth2 secret (Client Secret) from your GitHub organization application.'),
category=_('GitHub Team OAuth2'),
category_slug='github-team',
)
register(
'SOCIAL_AUTH_GITHUB_TEAM_ID',
field_class=fields.CharField,
allow_blank=True,
label=_('GitHub Team ID'),
help_text=_('Find the numeric team ID using the Github API: '
'http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
category=_('GitHub Team OAuth2'),
category_slug='github-team',
)
register(
'SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP',
field_class=fields.SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('GitHub Team OAuth2 Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('GitHub Team OAuth2'),
category_slug='github-team',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP',
field_class=fields.SocialTeamMapField,
allow_null=True,
default=None,
label=_('GitHub Team OAuth2 Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('GitHub Team OAuth2'),
category_slug='github-team',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)
###############################################################################
# SAML AUTHENTICATION SETTINGS
###############################################################################
def get_saml_metadata_url():
return urlparse.urljoin(settings.TOWER_URL_BASE, reverse('sso:saml_metadata'))
register(
'SOCIAL_AUTH_SAML_CALLBACK_URL',
field_class=fields.CharField,
read_only=True,
default=SocialAuthCallbackURL('saml'),
label=_('SAML Service Provider Callback URL'),
help_text=_('Register Tower as a service provider (SP) with each identity '
'provider (IdP) you have configured. Provide your SP Entity ID '
'and this callback URL for your application.'),
category=_('SAML'),
category_slug='saml',
)
register(
'SOCIAL_AUTH_SAML_METADATA_URL',
field_class=fields.CharField,
read_only=True,
default=get_saml_metadata_url,
label=_('SAML Service Provider Metadata URL'),
help_text=_('If your identity provider (IdP) allows uploading an XML '
'metadata file, you can download one from this URL.'),
category=_('SAML'),
category_slug='saml',
)
register(
'SOCIAL_AUTH_SAML_SP_ENTITY_ID',
field_class=fields.URLField,
schemes=('http', 'https'),
allow_blank=True,
default='',
label=_('SAML Service Provider Entity ID'),
help_text=_('Set to a URL for a domain name you own (does not need to be a '
'valid URL; only used as a unique ID).'),
category=_('SAML'),
category_slug='saml',
)
register(
'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT',
field_class=fields.CharField,
allow_blank=True,
default='',
validators=[validate_certificate],
label=_('SAML Service Provider Public Certificate'),
help_text=_('Create a keypair for Tower to use as a service provider (SP) '
'and include the certificate content here.'),
category=_('SAML'),
category_slug='saml',
)
register(
'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY',
field_class=fields.CharField,
allow_blank=True,
default='',
validators=[validate_private_key],
label=_('SAML Service Provider Private Key'),
help_text=_('Create a keypair for Tower to use as a service provider (SP) '
'and include the private key content here.'),
category=_('SAML'),
category_slug='saml',
)
register(
'SOCIAL_AUTH_SAML_ORG_INFO',
field_class=fields.SAMLOrgInfoField,
default={},
label=_('SAML Service Provider Organization Info'),
help_text=_('Configure this setting with information about your app.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([
('en-US', collections.OrderedDict([
('name', 'example'),
('displayname', 'Example'),
('url', 'http://www.example.com'),
])),
]),
)
register(
'SOCIAL_AUTH_SAML_TECHNICAL_CONTACT',
field_class=fields.SAMLContactField,
default={},
label=_('SAML Service Provider Technical Contact'),
help_text=_('Configure this setting with your contact information.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([
('givenName', 'Technical Contact'),
('emailAddress', 'techsup@example.com'),
]),
)
register(
'SOCIAL_AUTH_SAML_SUPPORT_CONTACT',
field_class=fields.SAMLContactField,
default={},
label=_('SAML Service Provider Support Contact'),
help_text=_('Configure this setting with your contact information.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([
('givenName', 'Support Contact'),
('emailAddress', 'support@example.com'),
]),
)
register(
'SOCIAL_AUTH_SAML_ENABLED_IDPS',
field_class=fields.SAMLEnabledIdPsField,
default={},
label=_('SAML Enabled Identity Providers'),
help_text=_('Configure the Entity ID, SSO URL and certificate for each '
'identity provider (IdP) in use. Multiple SAML IdPs are supported. '
'Some IdPs may provide user data using attribute names that differ '
'from the default OIDs '
'(https://github.com/omab/python-social-auth/blob/master/social/backends/saml.py#L16). '
'Attribute names may be overridden for each IdP.'),
category=_('SAML'),
category_slug='saml',
placeholder=collections.OrderedDict([
('Okta', collections.OrderedDict([
('entity_id', 'http://www.okta.com/HHniyLkaxk9e76wD0Thh'),
('url', 'https://dev-123456.oktapreview.com/app/ansibletower/HHniyLkaxk9e76wD0Thh/sso/saml'),
('x509cert', 'MIIDpDCCAoygAwIBAgIGAVVZ4rPzMA0GCSqGSIb3...'),
('attr_user_permanent_id', 'username'),
('attr_first_name', 'first_name'),
('attr_last_name', 'last_name'),
('attr_username', 'username'),
('attr_email', 'email'),
])),
('OneLogin', collections.OrderedDict([
('entity_id', 'https://app.onelogin.com/saml/metadata/123456'),
('url', 'https://example.onelogin.com/trust/saml2/http-post/sso/123456'),
('x509cert', 'MIIEJjCCAw6gAwIBAgIUfuSD54OPSBhndDHh3gZo...'),
('attr_user_permanent_id', 'name_id'),
('attr_first_name', 'User.FirstName'),
('attr_last_name', 'User.LastName'),
('attr_username', 'User.email'),
('attr_email', 'User.email'),
])),
]),
)
register(
'SOCIAL_AUTH_SAML_ORGANIZATION_MAP',
field_class=fields.SocialOrganizationMapField,
allow_null=True,
default=None,
label=_('SAML Organization Map'),
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
category=_('SAML'),
category_slug='saml',
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
)
register(
'SOCIAL_AUTH_SAML_TEAM_MAP',
field_class=fields.SocialTeamMapField,
allow_null=True,
default=None,
label=_('SAML Team Map'),
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
category=_('SAML'),
category_slug='saml',
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
)

598
awx/sso/fields.py Normal file
View File

@@ -0,0 +1,598 @@
# Python LDAP
import ldap
# Django
from django.utils.translation import ugettext_lazy as _
# Django Auth LDAP
import django_auth_ldap.config
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
# Tower
from awx.conf import fields
from awx.conf.fields import * # noqa
from awx.conf.license import feature_enabled
from awx.main.validators import validate_certificate
from awx.sso.validators import * # noqa
def get_subclasses(cls):
for subclass in cls.__subclasses__():
for subsubclass in get_subclasses(subclass):
yield subsubclass
yield subclass
class AuthenticationBackendsField(fields.StringListField):
# Mapping of settings that must be set in order to enable each
# authentication backend.
REQUIRED_BACKEND_SETTINGS = collections.OrderedDict([
('awx.sso.backends.LDAPBackend', [
'AUTH_LDAP_SERVER_URI',
]),
('awx.sso.backends.RADIUSBackend', [
'RADIUS_SERVER',
]),
('social.backends.google.GoogleOAuth2', [
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY',
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET',
]),
('social.backends.github.GithubOAuth2', [
'SOCIAL_AUTH_GITHUB_KEY',
'SOCIAL_AUTH_GITHUB_SECRET',
]),
('social.backends.github.GithubOrganizationOAuth2', [
'SOCIAL_AUTH_GITHUB_ORG_KEY',
'SOCIAL_AUTH_GITHUB_ORG_SECRET',
'SOCIAL_AUTH_GITHUB_ORG_NAME',
]),
('social.backends.github.GithubTeamOAuth2', [
'SOCIAL_AUTH_GITHUB_TEAM_KEY',
'SOCIAL_AUTH_GITHUB_TEAM_SECRET',
'SOCIAL_AUTH_GITHUB_TEAM_ID',
]),
('awx.sso.backends.SAMLAuth', [
'SOCIAL_AUTH_SAML_SP_ENTITY_ID',
'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT',
'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY',
'SOCIAL_AUTH_SAML_ORG_INFO',
'SOCIAL_AUTH_SAML_TECHNICAL_CONTACT',
'SOCIAL_AUTH_SAML_SUPPORT_CONTACT',
'SOCIAL_AUTH_SAML_ENABLED_IDPS',
]),
('django.contrib.auth.backends.ModelBackend', []),
])
REQUIRED_BACKEND_FEATURE = {
'awx.sso.backends.LDAPBackend': 'ldap',
'awx.sso.backends.RADIUSBackend': 'enterprise_auth',
'awx.sso.backends.SAMLAuth': 'enterprise_auth',
}
@classmethod
def get_all_required_settings(cls):
all_required_settings = set(['LICENSE'])
for required_settings in cls.REQUIRED_BACKEND_SETTINGS.values():
all_required_settings.update(required_settings)
return all_required_settings
def __init__(self, *args, **kwargs):
kwargs.setdefault('default', self._default_from_required_settings)
super(AuthenticationBackendsField, self).__init__(*args, **kwargs)
def _default_from_required_settings(self):
from django.conf import settings
try:
backends = settings._awx_conf_settings._get_default('AUTHENTICATION_BACKENDS')
except AttributeError:
backends = self.REQUIRED_BACKEND_SETTINGS.keys()
# Filter which authentication backends are enabled based on their
# required settings being defined and non-empty. Also filter available
# backends based on license features.
for backend, required_settings in self.REQUIRED_BACKEND_SETTINGS.items():
if backend not in backends:
continue
required_feature = self.REQUIRED_BACKEND_FEATURE.get(backend, '')
if not required_feature or feature_enabled(required_feature):
if all([getattr(settings, rs, None) for rs in required_settings]):
continue
backends = filter(lambda x: x != backend, backends)
return backends
class LDAPConnectionOptionsField(fields.DictField):
default_error_messages = {
'invalid_options': _('Invalid connection option(s): {invalid_options}.'),
}
def to_representation(self, value):
value = value or {}
opt_names = ldap.OPT_NAMES_DICT
# Convert integer options to their named constants.
repr_value = {}
for opt, opt_value in value.items():
if opt in opt_names:
repr_value[opt_names[opt]] = opt_value
return repr_value
def to_internal_value(self, data):
data = super(LDAPConnectionOptionsField, self).to_internal_value(data)
valid_options = dict([(v, k) for k, v in ldap.OPT_NAMES_DICT.items()])
invalid_options = set(data.keys()) - set(valid_options.keys())
if invalid_options:
options_display = json.dumps(list(invalid_options)).lstrip('[').rstrip(']')
self.fail('invalid_options', invalid_options=options_display)
# Convert named options to their integer constants.
internal_data = {}
for opt_name, opt_value in data.items():
internal_data[valid_options[opt_name]] = opt_value
return internal_data
class LDAPDNField(fields.CharField):
def __init__(self, **kwargs):
super(LDAPDNField, self).__init__(**kwargs)
self.validators.append(validate_ldap_dn)
class LDAPDNWithUserField(fields.CharField):
def __init__(self, **kwargs):
super(LDAPDNWithUserField, self).__init__(**kwargs)
self.validators.append(validate_ldap_dn_with_user)
class LDAPFilterField(fields.CharField):
def __init__(self, **kwargs):
super(LDAPFilterField, self).__init__(**kwargs)
self.validators.append(validate_ldap_filter)
class LDAPFilterWithUserField(fields.CharField):
def __init__(self, **kwargs):
super(LDAPFilterWithUserField, self).__init__(**kwargs)
self.validators.append(validate_ldap_filter_with_user)
class LDAPScopeField(fields.ChoiceField):
def __init__(self, choices=None, **kwargs):
choices = choices or [
('SCOPE_BASE', _('Base')),
('SCOPE_ONELEVEL', _('One Level')),
('SCOPE_SUBTREE', _('Subtree')),
]
super(LDAPScopeField, self).__init__(choices, **kwargs)
def to_representation(self, value):
for choice in self.choices.keys():
if value == getattr(ldap, choice):
return choice
return super(LDAPScopeField, self).to_representation(value)
def to_internal_value(self, data):
value = super(LDAPScopeField, self).to_internal_value(data)
return getattr(ldap, value)
class LDAPSearchField(fields.ListField):
default_error_messages = {
'invalid_length': _('Expected a list of three items but got {length} instead.'),
'type_error': _('Expected an instance of LDAPSearch but got {input_type} instead.'),
}
ldap_filter_field_class = LDAPFilterField
def to_representation(self, value):
if not value:
return []
if not isinstance(value, LDAPSearch):
self.fail('type_error', input_type=type(value))
return [
LDAPDNField().to_representation(value.base_dn),
LDAPScopeField().to_representation(value.scope),
self.ldap_filter_field_class().to_representation(value.filterstr),
]
def to_internal_value(self, data):
data = super(LDAPSearchField, self).to_internal_value(data)
if len(data) == 0:
return None
if len(data) != 3:
self.fail('invalid_length', length=len(data))
return LDAPSearch(
LDAPDNField().run_validation(data[0]),
LDAPScopeField().run_validation(data[1]),
self.ldap_filter_field_class().run_validation(data[2]),
)
class LDAPSearchWithUserField(LDAPSearchField):
ldap_filter_field_class = LDAPFilterWithUserField
class LDAPSearchUnionField(fields.ListField):
default_error_messages = {
'type_error': _('Expected an instance of LDAPSearch or LDAPSearchUnion but got {input_type} instead.'),
}
ldap_search_field_class = LDAPSearchWithUserField
def to_representation(self, value):
if not value:
return []
elif isinstance(value, LDAPSearchUnion):
return [self.ldap_search_field_class().to_representation(s) for s in value.searches]
elif isinstance(value, LDAPSearch):
return self.ldap_search_field_class().to_representation(value)
else:
self.fail('type_error', input_type=type(value))
def to_internal_value(self, data):
data = super(LDAPSearchUnionField, self).to_internal_value(data)
if len(data) == 0:
return None
if len(data) == 3 and isinstance(data[0], basestring):
return self.ldap_search_field_class().run_validation(data)
else:
return LDAPSearchUnion(*[self.ldap_search_field_class().run_validation(x) for x in data])
class LDAPUserAttrMapField(fields.DictField):
default_error_messages = {
'invalid_attrs': _('Invalid user attribute(s): {invalid_attrs}.'),
}
valid_user_attrs = {'first_name', 'last_name', 'email'}
child = fields.CharField()
def to_internal_value(self, data):
data = super(LDAPUserAttrMapField, self).to_internal_value(data)
invalid_attrs = (set(data.keys()) - self.valid_user_attrs)
if invalid_attrs:
attrs_display = json.dumps(list(invalid_attrs)).lstrip('[').rstrip(']')
self.fail('invalid_attrs', invalid_attrs=attrs_display)
return data
class LDAPGroupTypeField(fields.ChoiceField):
default_error_messages = {
'type_error': _('Expected an instance of LDAPGroupType but got {input_type} instead.'),
}
def __init__(self, choices=None, **kwargs):
group_types = get_subclasses(django_auth_ldap.config.LDAPGroupType)
choices = choices or [(x.__name__, x.__name__) for x in group_types]
super(LDAPGroupTypeField, self).__init__(choices, **kwargs)
def to_representation(self, value):
if not value:
return ''
if not isinstance(value, django_auth_ldap.config.LDAPGroupType):
self.fail('type_error', input_type=type(value))
return value.__class__.__name__
def to_internal_value(self, data):
data = super(LDAPGroupTypeField, self).to_internal_value(data)
if not data:
return None
return getattr(django_auth_ldap.config, data)()
class LDAPUserFlagsField(fields.DictField):
default_error_messages = {
'invalid_flag': _('Invalid user flag: "{invalid_flag}".'),
}
valid_user_flags = {'is_superuser'}
child = LDAPDNField()
def to_internal_value(self, data):
data = super(LDAPUserFlagsField, self).to_internal_value(data)
invalid_flags = (set(data.keys()) - self.valid_user_flags)
if invalid_flags:
self.fail('invalid_flag', invalid_flag=list(invalid_flags)[0])
return data
class LDAPDNMapField(fields.ListField):
default_error_messages = {
'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.'),
}
child = LDAPDNField()
def to_representation(self, value):
if isinstance(value, (list, tuple)):
return super(LDAPDNMapField, self).to_representation(value)
elif value in fields.NullBooleanField.TRUE_VALUES:
return True
elif value in fields.NullBooleanField.FALSE_VALUES:
return False
elif value in fields.NullBooleanField.NULL_VALUES:
return None
elif isinstance(value, basestring):
return self.child.to_representation(value)
else:
self.fail('type_error', input_type=type(value))
def to_internal_value(self, data):
if isinstance(data, (list, tuple)):
return super(LDAPDNMapField, self).to_internal_value(data)
elif data in fields.NullBooleanField.TRUE_VALUES:
return True
elif data in fields.NullBooleanField.FALSE_VALUES:
return False
elif data in fields.NullBooleanField.NULL_VALUES:
return None
elif isinstance(data, basestring):
return self.child.run_validation(data)
else:
self.fail('type_error', input_type=type(data))
class BaseDictWithChildField(fields.DictField):
default_error_messages = {
'missing_keys': _('Missing key(s): {missing_keys}.'),
'invalid_keys': _('Invalid key(s): {invalid_keys}.'),
}
child_fields = {
# 'key': fields.ChildField(),
}
allow_unknown_keys = False
def to_representation(self, value):
value = super(BaseDictWithChildField, self).to_representation(value)
for k, v in value.items():
child_field = self.child_fields.get(k, None)
if child_field:
value[k] = child_field.to_representation(v)
elif allow_unknown_keys:
value[k] = v
return value
def to_internal_value(self, data):
data = super(BaseDictWithChildField, self).to_internal_value(data)
missing_keys = set()
for key, child_field in self.child_fields.items():
if not child_field.required:
continue
elif key not in data:
missing_keys.add(key)
if missing_keys:
keys_display = json.dumps(list(missing_keys)).lstrip('[').rstrip(']')
self.fail('missing_keys', missing_keys=keys_display)
if not self.allow_unknown_keys:
invalid_keys = set(data.keys()) - set(self.child_fields.keys())
if invalid_keys:
keys_display = json.dumps(list(invalid_keys)).lstrip('[').rstrip(']')
self.fail('invalid_keys', invalid_keys=keys_display)
for k, v in data.items():
child_field = self.child_fields.get(k, None)
if child_field:
data[k] = child_field.run_validation(v)
elif self.allow_unknown_keys:
data[k] = v
return data
class LDAPSingleOrganizationMapField(BaseDictWithChildField):
default_error_messages = {
'invalid_keys': _('Invalid key(s) for organization map: {invalid_keys}.'),
}
child_fields = {
'admins': LDAPDNMapField(allow_null=True, required=False),
'users': LDAPDNMapField(allow_null=True, required=False),
'remove_admins': fields.BooleanField(required=False),
'remove_users': fields.BooleanField(required=False),
}
class LDAPOrganizationMapField(fields.DictField):
child = LDAPSingleOrganizationMapField()
class LDAPSingleTeamMapField(BaseDictWithChildField):
default_error_messages = {
'missing_keys': _('Missing required key for team map: {invalid_keys}.'),
'invalid_keys': _('Invalid key(s) for team map: {invalid_keys}.'),
}
child_fields = {
'organization': fields.CharField(),
'users': LDAPDNMapField(allow_null=True, required=False),
'remove': fields.BooleanField(required=False),
}
class LDAPTeamMapField(fields.DictField):
child = LDAPSingleTeamMapField()
class RADIUSSecretField(fields.CharField):
def to_internal_value(self, value):
value = super(RADIUSSecretField, self).to_internal_value(value)
if isinstance(value, unicode):
value = value.encode('utf-8')
return value
class SocialMapStringRegexField(fields.CharField):
def to_representation(self, value):
if isinstance(value, type(re.compile(''))):
flags = []
if value.flags & re.I:
flags.append('i')
if value.flags & re.M:
flags.append('m')
return '/{}/{}'.format(value.pattern, ''.join(flags))
else:
return super(SocialMapStringRegexField, self).to_representation(value)
def to_internal_value(self, data):
data = super(SocialMapStringRegexField, self).to_internal_value(data)
match = re.match(r'^/(?P<pattern>.*)/(?P<flags>[im]+)?$', data)
if match:
flags = 0
if match.group('flags'):
if 'i' in match.group('flags'):
flags |= re.I
if 'm' in match.group('flags'):
flags |= re.M
try:
return re.compile(match.group('pattern'), flags)
except re.error as e:
raise ValidationError('{}: {}'.format(e, data))
return data
class SocialMapField(fields.ListField):
default_error_messages = {
'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.'),
}
child = SocialMapStringRegexField()
def to_representation(self, value):
if isinstance(value, (list, tuple)):
return super(SocialMapField, self).to_representation(value)
elif value in fields.NullBooleanField.TRUE_VALUES:
return True
elif value in fields.NullBooleanField.FALSE_VALUES:
return False
elif value in fields.NullBooleanField.NULL_VALUES:
return None
elif isinstance(value, (basestring, type(re.compile('')))):
return self.child.to_representation(value)
else:
self.fail('type_error', input_type=type(value))
def to_internal_value(self, data):
if isinstance(data, (list, tuple)):
return super(SocialMapField, self).to_internal_value(data)
elif data in fields.NullBooleanField.TRUE_VALUES:
return True
elif data in fields.NullBooleanField.FALSE_VALUES:
return False
elif data in fields.NullBooleanField.NULL_VALUES:
return None
elif isinstance(data, basestring):
return self.child.run_validation(data)
else:
self.fail('type_error', input_type=type(data))
class SocialSingleOrganizationMapField(BaseDictWithChildField):
default_error_messages = {
'invalid_keys': _('Invalid key(s) for organization map: {invalid_keys}.'),
}
child_fields = {
'admins': SocialMapField(allow_null=True, required=False),
'users': SocialMapField(allow_null=True, required=False),
'remove_admins': fields.BooleanField(required=False),
'remove_users': fields.BooleanField(required=False),
}
class SocialOrganizationMapField(fields.DictField):
child = SocialSingleOrganizationMapField()
class SocialSingleTeamMapField(BaseDictWithChildField):
default_error_messages = {
'missing_keys': _('Missing required key for team map: {missing_keys}.'),
'invalid_keys': _('Invalid key(s) for team map: {invalid_keys}.'),
}
child_fields = {
'organization': fields.CharField(),
'users': SocialMapField(allow_null=True, required=False),
'remove': fields.BooleanField(required=False),
}
class SocialTeamMapField(fields.DictField):
child = SocialSingleTeamMapField()
class SAMLOrgInfoValueField(BaseDictWithChildField):
default_error_messages = {
'missing_keys': _('Missing required key(s) for org info record: {missing_keys}.'),
}
child_fields = {
'name': fields.CharField(),
'displayname': fields.CharField(),
'url': fields.URLField(),
}
allow_unknown_keys = True
class SAMLOrgInfoField(fields.DictField):
default_error_messages = {
'invalid_lang_code': _('Invalid language code(s) for org info: {invalid_lang_codes}.'),
}
child = SAMLOrgInfoValueField()
def to_internal_value(self, data):
data = super(SAMLOrgInfoField, self).to_internal_value(data)
invalid_keys = set()
for key in data.keys():
if not re.match(r'^[a-z]{2}(?:-[a-z]{2})??$', key, re.I):
invalid_keys.add(key)
if invalid_keys:
keys_display = json.dumps(list(invalid_keys)).lstrip('[').rstrip(']')
self.fail('invalid_lang_code', invalid_lang_codes=keys_display)
return data
class SAMLContactField(BaseDictWithChildField):
default_error_messages = {
'missing_keys': _('Missing required key(s) for contact: {missing_keys}.'),
}
child_fields = {
'givenName': fields.CharField(),
'emailAddress': fields.EmailField(),
}
allow_unknown_keys = True
class SAMLIdPField(BaseDictWithChildField):
default_error_messages = {
'missing_keys': _('Missing required key(s) for IdP: {missing_keys}.'),
}
child_fields = {
'entity_id': fields.URLField(),
'url': fields.URLField(),
'x509cert': fields.CharField(validators=[validate_certificate]),
'attr_user_permanent_id': fields.CharField(required=False),
'attr_first_name': fields.CharField(required=False),
'attr_last_name': fields.CharField(required=False),
'attr_username': fields.CharField(required=False),
'attr_email': fields.CharField(required=False),
}
allow_unknown_keys = True
class SAMLEnabledIdPsField(fields.DictField):
child = SAMLIdPField()

View File

@@ -8,7 +8,7 @@ import re
from social.exceptions import AuthException
# Tower
from awx.api.license import feature_enabled
from awx.conf.license import feature_enabled
class AuthNotFound(AuthException):

60
awx/sso/validators.py Normal file
View File

@@ -0,0 +1,60 @@
# Python
import re
# Python-LDAP
import ldap
# Django
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
__all__ = ['validate_ldap_dn', 'validate_ldap_dn_with_user',
'validate_ldap_bind_dn', 'validate_ldap_filter',
'validate_ldap_filter_with_user']
def validate_ldap_dn(value, with_user=False):
if with_user:
if '%(user)s' not in value:
raise ValidationError(_('DN must include "%%(user)s" placeholder for username: %s') % value)
dn_value = value.replace('%(user)s', 'USER')
else:
dn_value = value
try:
ldap.dn.str2dn(dn_value)
except ldap.DECODING_ERROR:
raise ValidationError(_('Invalid DN: %s') % value)
def validate_ldap_dn_with_user(value):
validate_ldap_dn(value, with_user=True)
def validate_ldap_bind_dn(value):
if not re.match(r'^[A-Za-z][A-Za-z0-9._-]*?\\[A-Za-z0-9 ._-]+?$', value.strip()):
validate_ldap_dn(value)
def validate_ldap_filter(value, with_user=False):
value = value.strip()
if not value:
return
if with_user:
if '%(user)s' not in value:
raise ValidationError(_('DN must include "%%(user)s" placeholder for username: %s') % value)
dn_value = value.replace('%(user)s', 'USER')
else:
dn_value = value
if re.match(r'^\([A-Za-z0-9]+?=[^()]+?\)$', dn_value):
return
elif re.match(r'^\([&|!]\(.*?\)\)$', dn_value):
try:
map(validate_ldap_filter, ['(%s)' % x for x in dn_value[3:-2].split(')(')])
return
except ValidationError:
pass
raise ValidationError(_('Invalid filter: %s') % value)
def validate_ldap_filter_with_user(value):
validate_ldap_filter(value, with_user=True)