Skip to content

Commit

Permalink
Allow an LDAP group to be used as a user whitelist for authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
mprahl committed Sep 16, 2019
1 parent 440206e commit 478209f
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 4 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,32 @@ Get a resource from Neo4j.

## Code Documentation
To document new files, please check [here](https://github.com/release-engineering/estuary-api/tree/master/docs).


## Authorization

If authentication is enabled, Estuary can authorize users based on their employee type and a user
whitelist configured through the membership of an LDAP group.

### Employee Type

You may set the list of valid employee types with the configuration item `EMPLOYEE_TYPES`. These
employee types map to the `employeeType` LDAP attribute of the user that is added to the OpenID
Connect token received by Estuary.

### Configuring the Whitelist

To configure a whitelist of users, they must be part of an LDAP group configured with Estuary. The
following configuration items are required:

* `LDAP_URI` - the URI to the LDAP server to connect to in the format of
`ldaps://server.domain.local`.
* `LDAP_EXCEPTIONS_GROUP_DN` - the distinguished name to the LDAP group acting as the whitelist.

The following configuration items are optional:

* `LDAP_CA_CERTIFICATE` - the path to the CA certificate that signed the certificate used by the
LDAP server. This only applies if you are using LDAPS. This defaults to
`/etc/pki/tls/certs/ca-bundle.crt`.
* `LDAP_GROUP_MEMBERSHIP_ATTRIBUTE` - the LDAP attribute that represents a user in the group. This
defaults to `uniqueMember`.
1 change: 1 addition & 0 deletions docker/Dockerfile-api
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ RUN dnf -y install \
python3-flask \
python3-flask-oidc \
python3-gunicorn \
python3-ldap3 \
python3-neomodel \
python3-prometheus_client \
python3-six \
Expand Down
1 change: 1 addition & 0 deletions docker/Dockerfile-scrapers
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RUN dnf -y install \
--setopt=tsflags=nodocs \
python3-beautifulsoup4 \
python3-flask \
python3-ldap3 \
python3-neomodel \
python3-psycopg2 \
python3-PyYAML \
Expand Down
1 change: 1 addition & 0 deletions docker/Dockerfile-tests
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ RUN dnf -y install \
python3-flake8 \
python3-flask \
python3-flask-oidc \
python3-ldap3 \
python3-mock \
python3-neomodel \
python3-prometheus_client \
Expand Down
80 changes: 77 additions & 3 deletions estuary/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,91 @@

from __future__ import unicode_literals

import ssl

from flask import current_app
from werkzeug.exceptions import InternalServerError

from estuary import log


def is_user_authorized(employee_type):
def is_user_authorized(username, employee_type):
"""
Verify the user is authorized to access the application.
:param str username: the username from the user's token
:param str employee_type: the employee type from the user's token
:return: a boolean that determines if the user is authorized
:rtype: bool
"""
employee_types = current_app.config.get('EMPLOYEE_TYPES', [])
# If the application is configured to only allow employees, perform the verification
return not employee_types or employee_type in employee_types
if employee_type in employee_types:
log.debug('The user %s is an employee', username)
return True

ldap_group_dn = current_app.config.get('LDAP_EXCEPTIONS_GROUP_DN')
if ldap_group_dn and username in _get_exception_users():
log.debug('The user %s is not considered an employee but is an exception', username)
return True

return False


def _get_exception_users():
"""
Get the list of users that are explicitly whitelisted.
If the LDAP search fails, an empty set is returned.
:return: a set of usernames
:rtype: set
:raise InternalServerError: if a required configuration value is not set or the connection to
the LDAP server fails
"""
# Import this here so it's not required for deployments with auth disabled
import ldap3

base_error = '%s is not set in the server configuration'
ldap_uri = current_app.config.get('LDAP_URI')
if not ldap_uri:
log.error(base_error, 'LDAP_URI')
raise InternalServerError()

ldap_group_dn = current_app.config.get('LDAP_EXCEPTIONS_GROUP_DN')
if not ldap_group_dn:
log.error(base_error, 'LDAP_EXCEPTIONS_GROUP_DN')
raise InternalServerError()

if ldap_uri.startswith('ldaps://'):
ca = current_app.config['LDAP_CA_CERTIFICATE']
log.debug('Connecting to %s using SSL and the CA %s', ldap_uri, ca)
tls = ldap3.Tls(ca_certs_file=ca, validate=ssl.CERT_REQUIRED)
server = ldap3.Server(ldap_uri, use_ssl=True, tls=tls)
else:
log.debug('Connecting to %s without SSL', ldap_uri)
server = ldap3.Server(ldap_uri)

connection = ldap3.Connection(server)
try:
connection.open()
except ldap3.core.exceptions.LDAPSocketOpenError:
log.exception('The connection to %s failed', ldap_uri)
raise InternalServerError()

membership_attr = current_app.config['LDAP_GROUP_MEMBERSHIP_ATTRIBUTE']
log.debug('Searching for the attribute %s on %s', ldap_group_dn, membership_attr)
# Set the scope to base so only the group from LDAP_GROUP_DN is returned
success = connection.search(
ldap_group_dn, '(cn=*)', search_scope=ldap3.BASE, attributes=[membership_attr])
if not success:
log.error(
'The user exceptions list could not be determined because the search for the attribute '
'%s on %s failed with %r',
membership_attr, ldap_group_dn, connection.response,
)
return set()

return set([
dn.split('=')[1].split(',')[0]
for dn in connection.response[0]['attributes'][membership_attr]
])
2 changes: 2 additions & 0 deletions estuary/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class Config(object):
OIDC_CLIENT_ID = None
OIDC_CLIENT_SECRET = None
EMPLOYEE_TYPES = []
LDAP_CA_CERTIFICATE = '/etc/pki/tls/certs/ca-bundle.crt'
LDAP_GROUP_MEMBERSHIP_ATTRIBUTE = 'uniqueMember'


class ProdConfig(Config):
Expand Down
3 changes: 2 additions & 1 deletion estuary/utils/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ def wrapper(*args, **kwargs):
raise Unauthorized(validity)

token_info = current_app.oidc._get_token_info(token)
username = token_info.get('username')
employee_type = token_info.get('employeeType')
if not is_user_authorized(employee_type):
if not is_user_authorized(username, employee_type):
raise Unauthorized('You must be an employee to access this service')
return f(*args, **kwargs)
return wrapper
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@
packages=find_packages(exclude=['tests']),
include_package_data=True,
install_requires=requirements,
extras_require={
'auth': ['flask_oidc', 'ldap3'],
}
)
111 changes: 111 additions & 0 deletions tests/api/test_authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# SPDX-License-Identifier: GPL-3.0+

from __future__ import unicode_literals

import ldap3
from mock import Mock, patch
import pytest
from werkzeug.exceptions import InternalServerError

from estuary.app import create_app
from estuary.authorization import is_user_authorized, _get_exception_users


@pytest.mark.parametrize('employeeType, authorized', (
('Employee', True),
('Contractor', False),
))
def test_is_user_authorized_with_employee(employeeType, authorized):
"""Test that only employees are authorized."""
app = create_app('estuary.config.TestAuthConfig')
with app.app_context():
assert is_user_authorized('jlennon', employeeType) is authorized


@pytest.mark.parametrize('users, authorized', (
({'jlennon', 'pmccartney'}, True),
({'pmccartney'}, False),
))
@patch('estuary.authorization._get_exception_users')
def test_is_user_authorized_exception(mock_geu, users, authorized):
"""Test that a non-employee that is in the exceptions users is authorized."""
mock_geu.return_value = users
app = create_app('estuary.config.TestAuthConfig')
app.config['LDAP_EXCEPTIONS_GROUP_DN'] = 'cn=something,dc=domain,dc=local'
with app.app_context():
assert is_user_authorized('jlennon', 'Contractor') is authorized


@pytest.mark.parametrize('ldap_uri', ('ldaps://domain.local', 'ldap://domain.local'))
def test_get_exception_users(ldap_uri):
"""Test that the exceptions list can be retrieved from LDAP."""
app = create_app('estuary.config.TestAuthConfig')
app.config['LDAP_URI'] = ldap_uri
app.config['LDAP_EXCEPTIONS_GROUP_DN'] = 'cn=estuary-exceptions,cn=something,dc=domain,dc=local'
# Create the mock LDAP instance
server = ldap3.Server('ldaps://test.domain.local')
connection = ldap3.Connection(server, client_strategy=ldap3.MOCK_SYNC)
estuary_exceptions_group_attrs = {
app.config['LDAP_GROUP_MEMBERSHIP_ATTRIBUTE']: [
'uid=mprahl,ou=users,dc=domain,dc=local',
'uid=tbrady,ou=users,dc=domain,dc=local',
],
'cn': ['estuary-exceptions'],
'gidNumber': 1234,
'objectClass': ['top', 'groupOfUniqueNames', 'rhatRoverGroup'],
}
connection.strategy.add_entry(
app.config['LDAP_EXCEPTIONS_GROUP_DN'],
estuary_exceptions_group_attrs,
)

with app.app_context():
with patch.object(ldap3, 'Tls', Mock(wraps=ldap3.Tls)) as mock_ldap_tls:
with patch('ldap3.Connection') as mock_ldap:
mock_ldap.return_value = connection
assert _get_exception_users() == {'mprahl', 'tbrady'}

if ldap_uri.startswith('ldaps'):
mock_ldap_tls.assert_called_once()
else:
mock_ldap_tls.assert_not_called()


@pytest.mark.parametrize('config', (
{},
{'LDAP_URI': 'ldaps://domain.local'},
))
def test_get_exception_users_invalid_config(config):
"""Test that an exception is raised when configuration values are missing."""
app = create_app('estuary.config.TestAuthConfig')
app.config.update(config)
with app.app_context():
with pytest.raises(InternalServerError):
_get_exception_users()


@patch('ldap3.Connection')
def test_connection_error(mock_connection):
"""Test that an exception is raised when the LDAP connection fails."""
mock_connection.return_value.open.side_effect = ldap3.core.exceptions.LDAPSocketOpenError()
app = create_app('estuary.config.TestAuthConfig')
app.config['LDAP_URI'] = 'ldaps://domain.local'
app.config['LDAP_EXCEPTIONS_GROUP_DN'] = 'cn=estuary-exceptions,dc=domain,dc=local'
with app.app_context():
with pytest.raises(InternalServerError):
_get_exception_users()

mock_connection.return_value.open.assert_called_once()


@patch('ldap3.Connection')
def test_search_failed(mock_connection):
"""Test that an empty set is returned when the search fails."""
mock_connection.return_value.search.return_value = False
app = create_app('estuary.config.TestAuthConfig')
app.config['LDAP_URI'] = 'ldaps://domain.local'
app.config['LDAP_EXCEPTIONS_GROUP_DN'] = 'cn=estuary-exceptions,dc=domain,dc=local'
with app.app_context():
assert _get_exception_users() == set()

mock_connection.return_value.search.assert_called_once()

0 comments on commit 478209f

Please sign in to comment.