basic ldap support (#842)
This commit is contained in:
parent
c0784b40e0
commit
79d12578c7
|
@ -3,7 +3,7 @@ RUN apt-get update
|
||||||
RUN apt-get install -y make python-software-properties curl
|
RUN apt-get install -y make python-software-properties curl
|
||||||
RUN curl -sL https://deb.nodesource.com/setup_7.x | bash -
|
RUN curl -sL https://deb.nodesource.com/setup_7.x | bash -
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y nodejs
|
RUN apt-get install -y nodejs libldap2-dev libsasl2-dev libldap2-dev libssl-dev
|
||||||
RUN pip install -U setuptools
|
RUN pip install -U setuptools
|
||||||
RUN pip install coveralls bandit
|
RUN pip install coveralls bandit
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
@ -251,7 +251,108 @@ Lemur supports sending certification expiration notifications through SES and SM
|
||||||
|
|
||||||
Authentication Options
|
Authentication Options
|
||||||
----------------------
|
----------------------
|
||||||
Lemur currently supports Basic Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
|
Lemur currently supports Basic Authentication, LDAP Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily.
|
||||||
|
|
||||||
|
LDAP Specific Options
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Lemur supports the use of an LDAP server in conjunction with Basic Authentication. Lemur local users can still be defined and take precedence over LDAP users. If a local user does not exist, LDAP will be queried for authentication. Only simple ldap binding with or without TLS is supported.
|
||||||
|
|
||||||
|
LDAP support requires the pyldap python library, which also depends on the following openldap packages.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ sudo apt-get update
|
||||||
|
$ sudo apt-get install libldap2-dev libsasl2-dev libldap2-dev libssl-dev
|
||||||
|
|
||||||
|
|
||||||
|
To configure the use of an LDAP server, the following settings must be defined.
|
||||||
|
|
||||||
|
.. data:: LDAP_AUTH
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
This enables the use of LDAP
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_AUTH = True
|
||||||
|
|
||||||
|
.. data:: LDAP_BIND_URI
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Specifies the LDAP server connection string
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_BIND_URI = 'ldaps://hostname'
|
||||||
|
|
||||||
|
.. data:: LDAP_BIND_URI
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Specifies the LDAP server connection string
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_BIND_URI = 'ldaps://hostname'
|
||||||
|
|
||||||
|
.. data:: LDAP_BASE_DN
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Specifies the LDAP distinguished name location to search for users
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_BASE_DN = 'DC=Users,DC=Evilcorp,DC=com'
|
||||||
|
|
||||||
|
.. data:: LDAP_EMAIL_DOMAIN
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
The email domain used by users in your directory. This is used to build the userPrincipalName to search with.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_EMAIL_DOMAIN = 'evilcorp.com'
|
||||||
|
|
||||||
|
The following LDAP options are not required, however TLS is always recommended.
|
||||||
|
|
||||||
|
.. data:: LDAP_USE_TLS
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Enables the use of TLS when connecting to the LDAP server. Ensure the LDAP_BIND_URI is using ldaps scheme.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_USE_TLS = True
|
||||||
|
|
||||||
|
.. data:: LDAP_CACERT_FILE
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Specify a Certificate Authority file containing PEM encoded trusted issuer certificates. This can be used if your LDAP server is using certificates issued by a private CA. (ie Microsoft)
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_CACERT_FILE = '/path/to/cacert/file'
|
||||||
|
|
||||||
|
.. data:: LDAP_REQUIRED_GROUP
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Lemur has pretty open permissions. You can define an LDAP group to specify who can access Lemur. Only members of this group will be able to login.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_REQUIRED_GROUP = 'Lemur LDAP Group Name'
|
||||||
|
|
||||||
|
.. data:: LDAP_GROUPS_TO_ROLES
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
You can also define a dictionary of ldap groups mapped to lemur roles. This allows you to use ldap groups to manage access to owner/creator roles in Lemur
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
LDAP_GROUPS_TO_ROLES = {'lemur_admins': 'admin', 'Lemur Team DL Group': 'team@example.com'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
If you are not using an authentication provider you do not need to configure any of these options.
|
If you are not using an authentication provider you do not need to configure any of these options.
|
||||||
|
|
||||||
For more information about how to use social logins, see: `Satellizer <https://github.com/sahat/satellizer>`_
|
For more information about how to use social logins, see: `Satellizer <https://github.com/sahat/satellizer>`_
|
||||||
|
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
.. module: lemur.auth.ldap
|
||||||
|
:platform: Unix
|
||||||
|
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||||
|
:license: Apache, see LICENSE for more details.
|
||||||
|
.. moduleauthor:: Ian Stahnke <ian.stahnke@myob.com>
|
||||||
|
"""
|
||||||
|
import ldap
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from lemur.users import service as user_service
|
||||||
|
from lemur.roles import service as role_service
|
||||||
|
from lemur.common.utils import validate_conf, get_psuedo_random_string
|
||||||
|
|
||||||
|
|
||||||
|
class LdapPrincipal():
|
||||||
|
"""
|
||||||
|
Provides methods for authenticating against an LDAP server.
|
||||||
|
"""
|
||||||
|
def __init__(self, args):
|
||||||
|
self._ldap_validate_conf()
|
||||||
|
# setup ldap config
|
||||||
|
if not args['username']:
|
||||||
|
raise Exception("missing ldap username")
|
||||||
|
if not args['password']:
|
||||||
|
self.error_message = "missing ldap password"
|
||||||
|
raise Exception("missing ldap password")
|
||||||
|
self.ldap_principal = args['username']
|
||||||
|
self.ldap_email_domain = current_app.config.get("LDAP_EMAIL_DOMAIN", None)
|
||||||
|
if '@' not in self.ldap_principal:
|
||||||
|
self.ldap_principal = '%s@%s' % (self.ldap_principal, self.ldap_email_domain)
|
||||||
|
self.ldap_username = args['username']
|
||||||
|
if '@' in self.ldap_username:
|
||||||
|
self.ldap_username = args['username'].split("@")[0]
|
||||||
|
self.ldap_password = args['password']
|
||||||
|
self.ldap_server = current_app.config.get('LDAP_BIND_URI', None)
|
||||||
|
self.ldap_base_dn = current_app.config.get("LDAP_BASE_DN", None)
|
||||||
|
self.ldap_use_tls = current_app.config.get("LDAP_USE_TLS", False)
|
||||||
|
self.ldap_cacert_file = current_app.config.get("LDAP_CACERT_FILE", None)
|
||||||
|
self.ldap_default_role = current_app.config.get("LEMUR_DEFAULT_ROLE", None)
|
||||||
|
self.ldap_required_group = current_app.config.get("LDAP_REQUIRED_GROUP", None)
|
||||||
|
self.ldap_groups_to_roles = current_app.config.get("LDAP_GROUPS_TO_ROLES", None)
|
||||||
|
self.ldap_attrs = ['memberOf']
|
||||||
|
self.ldap_client = None
|
||||||
|
self.ldap_groups = None
|
||||||
|
|
||||||
|
def _update_user(self, roles):
|
||||||
|
"""
|
||||||
|
create or update a local user instance.
|
||||||
|
"""
|
||||||
|
# try to get user from local database
|
||||||
|
user = user_service.get_by_email(self.ldap_principal)
|
||||||
|
|
||||||
|
# create them a local account
|
||||||
|
if not user:
|
||||||
|
user = user_service.create(
|
||||||
|
self.ldap_username,
|
||||||
|
get_psuedo_random_string(),
|
||||||
|
self.ldap_principal,
|
||||||
|
True,
|
||||||
|
'', # thumbnailPhotoUrl
|
||||||
|
list(roles)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# we add 'lemur' specific roles, so they do not get marked as removed
|
||||||
|
for ur in user.roles:
|
||||||
|
if ur.authority_id:
|
||||||
|
roles.add(ur)
|
||||||
|
|
||||||
|
# update any changes to the user
|
||||||
|
user_service.update(
|
||||||
|
user.id,
|
||||||
|
self.ldap_username,
|
||||||
|
self.ldap_principal,
|
||||||
|
user.active,
|
||||||
|
user.profile_picture,
|
||||||
|
list(roles)
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _authorize(self):
|
||||||
|
"""
|
||||||
|
check groups and roles to confirm access.
|
||||||
|
return a list of roles if ok.
|
||||||
|
raise an exception on error.
|
||||||
|
"""
|
||||||
|
if not self.ldap_principal:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.ldap_required_group:
|
||||||
|
# ensure the user has the required group in their group list
|
||||||
|
if self.ldap_required_group not in self.ldap_groups:
|
||||||
|
return None
|
||||||
|
|
||||||
|
roles = set()
|
||||||
|
if self.ldap_default_role:
|
||||||
|
role = role_service.get_by_name(self.ldap_default_role)
|
||||||
|
if role:
|
||||||
|
roles.add(role)
|
||||||
|
|
||||||
|
# update their 'roles'
|
||||||
|
role = role_service.get_by_name(self.ldap_principal)
|
||||||
|
if not role:
|
||||||
|
description = "auto generated role based on owner: {0}".format(self.ldap_principal)
|
||||||
|
role = role_service.create(self.ldap_principal, description=description)
|
||||||
|
roles.add(role)
|
||||||
|
if not self.ldap_groups_to_roles:
|
||||||
|
return roles
|
||||||
|
|
||||||
|
for ldap_group_name, role_name in self.ldap_groups_to_roles.items():
|
||||||
|
role = role_service.get_by_name(role_name)
|
||||||
|
if role:
|
||||||
|
if ldap_group_name in self.ldap_groups:
|
||||||
|
current_app.logger.debug("assigning role {0} to ldap user {1}".format(self.ldap_principal, role))
|
||||||
|
roles.add(role)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
"""
|
||||||
|
orchestrate the ldap login.
|
||||||
|
raise an exception on error.
|
||||||
|
"""
|
||||||
|
self._bind()
|
||||||
|
roles = self._authorize()
|
||||||
|
if not roles:
|
||||||
|
raise Exception('ldap authorization failed')
|
||||||
|
return self._update_user(roles)
|
||||||
|
|
||||||
|
def _bind(self):
|
||||||
|
"""
|
||||||
|
authenticate an ldap user.
|
||||||
|
list groups for a user.
|
||||||
|
raise an exception on error.
|
||||||
|
"""
|
||||||
|
if '@' not in self.ldap_principal:
|
||||||
|
self.ldap_principal = '%s@%s' % (self.ldap_principal, self.ldap_email_domain)
|
||||||
|
ldap_filter = 'userPrincipalName=%s' % self.ldap_principal
|
||||||
|
|
||||||
|
# query ldap for auth
|
||||||
|
try:
|
||||||
|
# build a client
|
||||||
|
if not self.ldap_client:
|
||||||
|
self.ldap_client = ldap.initialize(self.ldap_server)
|
||||||
|
# perform a synchronous bind
|
||||||
|
self.ldap_client.set_option(ldap.OPT_REFERRALS, 0)
|
||||||
|
if self.ldap_use_tls:
|
||||||
|
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||||
|
self.ldap_client.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||||
|
self.ldap_client.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
|
||||||
|
self.ldap_client.set_option(ldap.OPT_X_TLS_DEMAND, True)
|
||||||
|
self.ldap_client.set_option(ldap.OPT_DEBUG_LEVEL, 255)
|
||||||
|
if self.ldap_cacert_file:
|
||||||
|
self.ldap_client.set_option(ldap.OPT_X_TLS_CACERTFILE, self.ldap_cacert_file)
|
||||||
|
self.ldap_client.simple_bind_s(self.ldap_principal, self.ldap_password)
|
||||||
|
except ldap.INVALID_CREDENTIALS:
|
||||||
|
self.ldap_client.unbind()
|
||||||
|
raise Exception('The supplied ldap credentials are invalid')
|
||||||
|
except ldap.SERVER_DOWN:
|
||||||
|
raise Exception('ldap server unavailable')
|
||||||
|
except ldap.LDAPError as e:
|
||||||
|
raise Exception("ldap error: {0}".format(e))
|
||||||
|
|
||||||
|
lgroups = self.ldap_client.search_s(self.ldap_base_dn,
|
||||||
|
ldap.SCOPE_SUBTREE, ldap_filter, self.ldap_attrs)[0][1]['memberOf']
|
||||||
|
# lgroups is a list of utf-8 encoded strings
|
||||||
|
# convert to a single string of groups to allow matching
|
||||||
|
self.ldap_groups = b''.join(lgroups).decode('ascii')
|
||||||
|
self.ldap_client.unbind()
|
||||||
|
|
||||||
|
def _ldap_validate_conf(self):
|
||||||
|
"""
|
||||||
|
Confirms required ldap config settings exist.
|
||||||
|
"""
|
||||||
|
required_vars = [
|
||||||
|
'LDAP_BIND_URI',
|
||||||
|
'LDAP_BASE_DN',
|
||||||
|
'LDAP_EMAIL_DOMAIN',
|
||||||
|
]
|
||||||
|
validate_conf(current_app, required_vars)
|
|
@ -21,6 +21,7 @@ from lemur.common.utils import get_psuedo_random_string
|
||||||
from lemur.users import service as user_service
|
from lemur.users import service as user_service
|
||||||
from lemur.roles import service as role_service
|
from lemur.roles import service as role_service
|
||||||
from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
|
from lemur.auth.service import create_token, fetch_token_header, get_rsa_public_key
|
||||||
|
import lemur.auth.ldap as ldap
|
||||||
|
|
||||||
|
|
||||||
mod = Blueprint('auth', __name__)
|
mod = Blueprint('auth', __name__)
|
||||||
|
@ -94,6 +95,7 @@ class Login(Resource):
|
||||||
else:
|
else:
|
||||||
user = user_service.get_by_username(args['username'])
|
user = user_service.get_by_username(args['username'])
|
||||||
|
|
||||||
|
# default to local authentication
|
||||||
if user and user.check_password(args['password']) and user.active:
|
if user and user.check_password(args['password']) and user.active:
|
||||||
# Tell Flask-Principal the identity changed
|
# Tell Flask-Principal the identity changed
|
||||||
identity_changed.send(current_app._get_current_object(),
|
identity_changed.send(current_app._get_current_object(),
|
||||||
|
@ -102,6 +104,24 @@ class Login(Resource):
|
||||||
metrics.send('successful_login', 'counter', 1)
|
metrics.send('successful_login', 'counter', 1)
|
||||||
return dict(token=create_token(user))
|
return dict(token=create_token(user))
|
||||||
|
|
||||||
|
# try ldap login
|
||||||
|
if current_app.config.get("LDAP_AUTH"):
|
||||||
|
try:
|
||||||
|
ldap_principal = ldap.LdapPrincipal(args)
|
||||||
|
user = ldap_principal.authenticate()
|
||||||
|
if user and user.active:
|
||||||
|
# Tell Flask-Principal the identity changed
|
||||||
|
identity_changed.send(current_app._get_current_object(),
|
||||||
|
identity=Identity(user.id))
|
||||||
|
metrics.send('successful_login', 'counter', 1)
|
||||||
|
return dict(token=create_token(user))
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error("ldap error: {0}".format(e))
|
||||||
|
ldap_message = 'ldap error: %s' % e
|
||||||
|
metrics.send('invalid_login', 'counter', 1)
|
||||||
|
return dict(message=ldap_message), 403
|
||||||
|
|
||||||
|
# if not valid user - no certificates for you
|
||||||
metrics.send('invalid_login', 'counter', 1)
|
metrics.send('invalid_login', 'counter', 1)
|
||||||
return dict(message='The supplied credentials are invalid'), 403
|
return dict(message='The supplied credentials are invalid'), 403
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-or">
|
<div class="login-or" ng-if="providers.length > 0">
|
||||||
<hr class="hr-or">
|
<hr class="hr-or">
|
||||||
<span class="span-or">or</span>
|
<span class="span-or">or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -177,3 +177,10 @@ ACME_URL = 'https://acme-v01.api.letsencrypt.org'
|
||||||
ACME_EMAIL = 'jim@example.com'
|
ACME_EMAIL = 'jim@example.com'
|
||||||
ACME_TEL = '4088675309'
|
ACME_TEL = '4088675309'
|
||||||
ACME_DIRECTORY_URL = 'https://acme-v01.api.letsencrypt.org'
|
ACME_DIRECTORY_URL = 'https://acme-v01.api.letsencrypt.org'
|
||||||
|
|
||||||
|
LDAP_AUTH = True
|
||||||
|
LDAP_BIND_URI = 'ldap://localhost'
|
||||||
|
LDAP_BASE_DN = 'dc=example,dc=com'
|
||||||
|
LDAP_EMAIL_DOMAIN = 'example.com'
|
||||||
|
LDAP_REQUIRED_GROUP = 'Lemur Access'
|
||||||
|
LDAP_DEFAULT_ROLE = 'role1'
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
import pytest
|
||||||
|
from lemur.auth.ldap import * # noqa
|
||||||
|
from mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
class LdapPrincipalTester(LdapPrincipal):
|
||||||
|
|
||||||
|
def __init__(self, args):
|
||||||
|
super().__init__(args)
|
||||||
|
self.ldap_server = 'ldap://localhost'
|
||||||
|
|
||||||
|
def bind_test(self):
|
||||||
|
groups = [('user', {'memberOf': ['CN=Lemur Access,OU=Groups,DC=example,DC=com'.encode('utf-8'),
|
||||||
|
'CN=Pen Pushers,OU=Groups,DC=example,DC=com'.encode('utf-8')]})]
|
||||||
|
self.ldap_client = MagicMock()
|
||||||
|
self.ldap_client.search_s.return_value = groups
|
||||||
|
self._bind()
|
||||||
|
|
||||||
|
def authorize_test_groups_to_roles_admin(self):
|
||||||
|
self.ldap_groups = ''.join(['CN=Pen Pushers,OU=Groups,DC=example,DC=com',
|
||||||
|
'CN=Lemur Admins,OU=Groups,DC=example,DC=com',
|
||||||
|
'CN=Lemur Read Only,OU=Groups,DC=example,DC=com'])
|
||||||
|
self.ldap_required_group = None
|
||||||
|
self.ldap_groups_to_roles = {'Lemur Admins': 'admin', 'Lemur Read Only': 'read-only'}
|
||||||
|
return self._authorize()
|
||||||
|
|
||||||
|
def authorize_test_required_group(self, group):
|
||||||
|
self.ldap_groups = ''.join(['CN=Lemur Access,OU=Groups,DC=example,DC=com',
|
||||||
|
'CN=Pen Pushers,OU=Groups,DC=example,DC=com'])
|
||||||
|
self.ldap_required_group = group
|
||||||
|
return self._authorize()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def principal(session):
|
||||||
|
args = {'username': 'user', 'password': 'p4ssw0rd'}
|
||||||
|
yield LdapPrincipalTester(args)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLdapPrincipal:
|
||||||
|
|
||||||
|
@patch('ldap.initialize')
|
||||||
|
def test_bind(self, app, principal):
|
||||||
|
self.test_ldap_user = principal
|
||||||
|
self.test_ldap_user.bind_test()
|
||||||
|
group = 'Pen Pushers'
|
||||||
|
assert group in self.test_ldap_user.ldap_groups
|
||||||
|
assert self.test_ldap_user.ldap_principal == 'user@example.com'
|
||||||
|
|
||||||
|
def test_authorize_groups_to_roles_admin(self, app, principal):
|
||||||
|
self.test_ldap_user = principal
|
||||||
|
roles = self.test_ldap_user.authorize_test_groups_to_roles_admin()
|
||||||
|
assert any(x.name == "admin" for x in roles)
|
||||||
|
|
||||||
|
def test_authorize_required_group_missing(self, app, principal):
|
||||||
|
self.test_ldap_user = principal
|
||||||
|
roles = self.test_ldap_user.authorize_test_required_group('Not Allowed')
|
||||||
|
assert not roles
|
||||||
|
|
||||||
|
def test_authorize_required_group_access(self, session, principal):
|
||||||
|
self.test_ldap_user = principal
|
||||||
|
roles = self.test_ldap_user.authorize_test_required_group('Lemur Access')
|
||||||
|
assert len(roles) >= 1
|
||||||
|
assert any(x.name == "user@example.com" for x in roles)
|
4
setup.py
4
setup.py
|
@ -64,6 +64,7 @@ install_requires = [
|
||||||
'pem==17.1.0',
|
'pem==17.1.0',
|
||||||
'raven[flask]==6.1.0',
|
'raven[flask]==6.1.0',
|
||||||
'jinja2==2.9.6',
|
'jinja2==2.9.6',
|
||||||
|
'pyldap==2.4.36', # required by ldap auth provider
|
||||||
'paramiko==2.2.1' # required for lemur_linuxdst plugin
|
'paramiko==2.2.1' # required for lemur_linuxdst plugin
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -76,7 +77,8 @@ tests_require = [
|
||||||
'fake-factory==0.7.2',
|
'fake-factory==0.7.2',
|
||||||
'pytest-flask==0.10.0',
|
'pytest-flask==0.10.0',
|
||||||
'freezegun==0.3.9',
|
'freezegun==0.3.9',
|
||||||
'requests-mock==1.3.0'
|
'requests-mock==1.3.0',
|
||||||
|
'pytest-mock'
|
||||||
]
|
]
|
||||||
|
|
||||||
docs_require = [
|
docs_require = [
|
||||||
|
|
Loading…
Reference in New Issue