From 8d0f5a1c179dbd05f87959a0b8741c0726801638 Mon Sep 17 00:00:00 2001 From: sayali Date: Fri, 3 Dec 2021 15:13:31 -0800 Subject: [PATCH 1/8] Domain authorization - cert issuance --- lemur/api_keys/models.py | 1 + lemur/auth/service.py | 2 + lemur/certificates/service.py | 17 +++++ lemur/certificates/views.py | 21 +++--- lemur/domains/service.py | 27 +++++++ lemur/migrations/versions/e2d406ada25c_.py | 22 ++++++ lemur/plugins/bases/__init__.py | 4 +- lemur/plugins/bases/authorization.py | 45 +++++++++++ lemur/tests/test_certificates.py | 87 +++++++++++++++++++++- 9 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 lemur/migrations/versions/e2d406ada25c_.py create mode 100644 lemur/plugins/bases/authorization.py diff --git a/lemur/api_keys/models.py b/lemur/api_keys/models.py index fbcc3e4401..b03231554d 100644 --- a/lemur/api_keys/models.py +++ b/lemur/api_keys/models.py @@ -19,6 +19,7 @@ class ApiKey(db.Model): ttl = Column(BigInteger) issued_at = Column(BigInteger) revoked = Column(Boolean) + application_name = Column(String, nullable=True) def __repr__(self): return "ApiKey(name={name}, user_id={user_id}, ttl={ttl}, issued_at={iat}, revoked={revoked})".format( diff --git a/lemur/auth/service.py b/lemur/auth/service.py index 6ce9a5b617..1c57db7554 100644 --- a/lemur/auth/service.py +++ b/lemur/auth/service.py @@ -120,6 +120,8 @@ def decorated_function(*args, **kwargs): expired_time = datetime.fromtimestamp(access_key.issued_at) + timedelta(days=access_key.ttl) if current_time >= expired_time: return dict(message="Token has expired"), 403 + if access_key.application_name: + g.caller_application = access_key.application_name user = user_service.get(payload["sub"]) diff --git a/lemur/certificates/service.py b/lemur/certificates/service.py index 9cc04f8bab..f8a9303936 100644 --- a/lemur/certificates/service.py +++ b/lemur/certificates/service.py @@ -28,6 +28,7 @@ from lemur.constants import SUCCESS_METRIC_STATUS, FAILURE_METRIC_STATUS from lemur.destinations.models import Destination from lemur.domains.models import Domain +from lemur.domains.service import is_authorized_for_domain from lemur.endpoints import service as endpoint_service from lemur.extensions import metrics, signals from lemur.notifications.messaging import send_revocation_notification @@ -1240,3 +1241,19 @@ def is_valid_owner(email): # expecting owner to be an existing team DL return user_membership_provider.does_group_exist(email) + + +def allowed_issuance_for_domain(common_name, extensions): + check_permission_for_cn = True if common_name else False + + # authorize issuance for every x509.DNSName SAN + if extensions and extensions.get("sub_alt_names"): + for san in extensions["sub_alt_names"]["names"]: + if isinstance(san, x509.DNSName): + if san.value == common_name: + check_permission_for_cn = False + is_authorized_for_domain(san.value) + + # lemur UI copies CN as SAN (x509.DNSName). Permission check for CN might already be covered above. + if check_permission_for_cn: + is_authorized_for_domain(common_name) diff --git a/lemur/certificates/views.py b/lemur/certificates/views.py index a841749c12..07dc4b4374 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -10,6 +10,7 @@ from flask import Blueprint, make_response, jsonify, g, current_app from flask_restful import reqparse, Api, inputs +from lemur.plugins.bases.authorization import UnauthorizedError from sentry_sdk import capture_exception from lemur.common.schema import validate_schema @@ -511,23 +512,21 @@ def post(self, data=None): roles.append(role) authority_permission = AuthorityPermission(data["authority"].id, roles) - if authority_permission.can(): - data["creator"] = g.user + if not authority_permission.can(): + return dict(message=f"You are not authorized to use the authority: {data['authority'].name}"), 403 + + data["creator"] = g.user + try: + service.allowed_issuance_for_domain(data["common_name"], data["extensions"]) + except UnauthorizedError as e: + return dict(message=str(e)), 403 + else: cert = service.create(**data) if isinstance(cert, Certificate): # only log if created, not pending log_service.create(g.user, "create_cert", certificate=cert) return cert - return ( - dict( - message="You are not authorized to use the authority: {0}".format( - data["authority"].name - ) - ), - 403, - ) - class CertificatesUpload(AuthenticatedResource): """ Defines the 'certificates' upload endpoint """ diff --git a/lemur/domains/service.py b/lemur/domains/service.py index 1944d9dbb5..cf0e386f24 100644 --- a/lemur/domains/service.py +++ b/lemur/domains/service.py @@ -8,9 +8,12 @@ """ from sqlalchemy import and_ +from flask import current_app, g + from lemur import database from lemur.certificates.models import Certificate from lemur.domains.models import Domain +from lemur.plugins.base import plugins def get(domain_id): @@ -57,6 +60,30 @@ def is_domain_sensitive(name): return database.find_all(query, Domain, {}).all() +def is_authorized_for_domain(name): + """ + If authorization plugin is available, perform the check to see if current user can issue certificate for a give + domain. + Return True if authorized, False otherwise. + If authorization plugin is not available, return true by default + + :param name: domain (string) for which authorization check is being done + :return: True/False + """ + if current_app.config.get("USER_DOMAIN_AUTHORIZATION_PROVIDER") is None: + # nothing to check since USER_DOMAIN_AUTHORIZATION_PROVIDER is not configured + return + + user_domain_authorization_provider = plugins.get(current_app.config.get("USER_DOMAIN_AUTHORIZATION_PROVIDER")) + caller = g.caller_application if hasattr(g, 'caller_application') else g.user.email + # if the caller can be mapped to an application name, use that to perform authorization + # this could be true when using API key to call lemur (migration script e2d406ada25c_.py) + _, error = user_domain_authorization_provider.is_authorized(domain=name, caller=caller) + + if error: + raise error + + def create(name, sensitive): """ Create a new domain diff --git a/lemur/migrations/versions/e2d406ada25c_.py b/lemur/migrations/versions/e2d406ada25c_.py new file mode 100644 index 0000000000..8d4bf54a89 --- /dev/null +++ b/lemur/migrations/versions/e2d406ada25c_.py @@ -0,0 +1,22 @@ +"""Add column application_name to api_keys table. Null values are allowed. + +Revision ID: e2d406ada25c +Revises: 189e5fda5bf8 +Create Date: 2021-11-24 14:48:18.747487 + +""" + +# revision identifiers, used by Alembic. +revision = 'e2d406ada25c' +down_revision = '189e5fda5bf8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column("api_keys", sa.Column("application_name", sa.String(256), nullable=True)) + + +def downgrade(): + op.drop_column("api_keys", "application_name") diff --git a/lemur/plugins/bases/__init__.py b/lemur/plugins/bases/__init__.py index 0c7f986eb9..3a9b28cf81 100644 --- a/lemur/plugins/bases/__init__.py +++ b/lemur/plugins/bases/__init__.py @@ -4,4 +4,6 @@ from .notification import NotificationPlugin, ExpirationNotificationPlugin # noqa from .export import ExportPlugin # noqa from .tls import TLSPlugin # noqa -from .membership import MembershipPlugin # noqa +from .membership import MembershipPlugin # noqa +from .authorization import AuthorizationPlugin # noqa +from .authorization import DomainAuthorizationPlugin # noqa diff --git a/lemur/plugins/bases/authorization.py b/lemur/plugins/bases/authorization.py new file mode 100644 index 0000000000..b82f7976e8 --- /dev/null +++ b/lemur/plugins/bases/authorization.py @@ -0,0 +1,45 @@ +""" +.. module: lemur.plugins.bases.authorization + :platform: Unix + :copyright: (c) 2021 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Sayali Charhate +""" + +from lemur.exceptions import LemurException +from lemur.plugins.base import Plugin + + +class AuthorizationPlugin(Plugin): + """ + This is the base class for authorization providers. Check if the caller is authorized to access a resource. + """ + type = "authorization" + + def is_authorized(self, resource, caller): + raise NotImplementedError + + +class DomainAuthorizationPlugin(AuthorizationPlugin): + """ + This is the base class for domain authorization providers. Check if the caller can issue certificates for a domain. + """ + type = "domain-authorization" + + def is_authorized(self, domain, caller): + raise NotImplementedError + + +class UnauthorizedError(LemurException): + """ + Raised when user is unauthorized to perform an action on the resource + """ + def __init__(self, user, resource, action, details): + self.user = user + self.resource = resource + self.action = action + self.details = details + + def __str__(self): + return repr(f"{self.user} is not authorized to perform {self.action} on {self.resource}: {self.details}") diff --git a/lemur/tests/test_certificates.py b/lemur/tests/test_certificates.py index 6d32cc3570..5d182e24c4 100644 --- a/lemur/tests/test_certificates.py +++ b/lemur/tests/test_certificates.py @@ -825,8 +825,8 @@ def test_create_basic_csr(client): owner="joe@example.com", key_type="RSA2048", extensions=dict( - names=dict( - sub_alt_names=x509.SubjectAlternativeName( + sub_alt_names=dict( + names=x509.SubjectAlternativeName( [ x509.DNSName("test.example.com"), x509.DNSName("test2.example.com"), @@ -1578,3 +1578,86 @@ def start_server(): daemon.setDaemon(True) # Set as a daemon so it will be killed once the main thread is dead. daemon.start() return daemon + + +def mocked_is_authorized_for_domain(name): + domain_in_error = "fail.lemur.com" + if name == domain_in_error: + raise UnauthorizedError(user="dummy_user", resource=domain_in_error, action="issue_certificate", + details="unit test, mocked failure") + + +@pytest.mark.parametrize( + "common_name, extensions, expected_error, authz_check_count", + [ + ("fail.lemur.com", None, True, 1), + ("fail.lemur.com", dict( + sub_alt_names=dict( + names=x509.SubjectAlternativeName( + [ + x509.DNSName("test.example.com"), + x509.DNSName("test2.example.com"), + ] + ) + ) + ), True, 3), # CN is checked after SAN + ("test.example.com", dict( + sub_alt_names=dict( + names=x509.SubjectAlternativeName( + [ + x509.DNSName("fail.lemur.com"), + x509.DNSName("test2.example.com"), + ] + ) + ) + ), True, 1), + (None, dict( + sub_alt_names=dict( + names=x509.SubjectAlternativeName( + [ + x509.DNSName("fail.lemur.com"), + x509.DNSName("test2.example.com"), + ] + ) + ) + ), True, 1), + ("pass.lemur.com", None, False, 1), + ("pass.lemur.com", dict( + sub_alt_names=dict( + names=x509.SubjectAlternativeName( + [ + x509.DNSName("test.example.com"), + x509.DNSName("test2.example.com"), + ] + ) + ) + ), False, 3), + ("pass.lemur.com", dict( + sub_alt_names=dict( + names=x509.SubjectAlternativeName( + [ + x509.DNSName("test.example.com"), + x509.DNSName("pass.lemur.com"), + ] + ) + ) + ), False, 2), # CN repeated in SAN + ], +) +def test_allowed_issuance_for_domain(common_name, extensions, expected_error, authz_check_count): + from lemur.certificates.service import allowed_issuance_for_domain + + with patch( + 'lemur.certificates.service.is_authorized_for_domain', side_effect=mocked_is_authorized_for_domain + ) as wrapper: + try: + allowed_issuance_for_domain(common_name, extensions) + if expected_error: + assert False, f"UnauthorizedError did not occur, input: CN({common_name}), SAN({extensions})" + except UnauthorizedError as e: + if expected_error: + pass + else: + assert False, f"UnauthorizedError occured, input: CN({common_name}), SAN({extensions})" + + assert wrapper.call_count == authz_check_count From 419249e8246ad8a9d41d186bd4f487de50dcec1c Mon Sep 17 00:00:00 2001 From: sayali Date: Fri, 3 Dec 2021 15:45:43 -0800 Subject: [PATCH 2/8] doc update --- docs/administration.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/administration.rst b/docs/administration.rst index 467f6e50fd..bac60fc9be 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -1025,6 +1025,22 @@ For more information about how to use social logins, see: `Satellizer Date: Fri, 3 Dec 2021 16:33:04 -0800 Subject: [PATCH 3/8] doc update --- docs/administration.rst | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/administration.rst b/docs/administration.rst index bac60fc9be..f4954311f9 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -1025,8 +1025,9 @@ For more information about how to use social logins, see: `Satellizer `_ + +To allow integration with external access/membership management tools that may exist in your organization, lemur offers +below plugins in addition to it's own RBAC implementation. + +Membership Plugin +----------------- + +:Authors: + Sayali Charhate +:Type: + User Membership +:Description: + Adds support to learn and validate user membership details from an external service. User memberships are used to + create user roled dynamically as described in :ref:`iam target`. Configure this plugin slug as `USER_MEMBERSHIP_PROVIDER` + +Authorization Plugins +--------------------- + +:Authors: + Sayali Charhate +:Type: + External Authorization +:Description: + Adds support to implement custom authorization logic that is best suited for your enterprise. Lemur offers `AuthorizationPlugin` + and its extended version `DomainAuthorizationPlugin`. One can implement a `DomainAuthorizationPlugin` and configure its + slug as `USER_DOMAIN_AUTHORIZATION_PROVIDER` to check if caller is authorized to issue a certificate for a given Common + Name and Subject Alternative Name (SAN) of type DNSName From db0c5bfcfc193ac80026e67a5d98ff8c0923d874 Mon Sep 17 00:00:00 2001 From: sayali Date: Fri, 3 Dec 2021 16:34:37 -0800 Subject: [PATCH 4/8] typo --- docs/administration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/administration.rst b/docs/administration.rst index f4954311f9..1f1185e450 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -2084,6 +2084,6 @@ Authorization Plugins External Authorization :Description: Adds support to implement custom authorization logic that is best suited for your enterprise. Lemur offers `AuthorizationPlugin` - and its extended version `DomainAuthorizationPlugin`. One can implement a `DomainAuthorizationPlugin` and configure its + and its extended version `DomainAuthorizationPlugin`. One can implement `DomainAuthorizationPlugin` and configure its slug as `USER_DOMAIN_AUTHORIZATION_PROVIDER` to check if caller is authorized to issue a certificate for a given Common Name and Subject Alternative Name (SAN) of type DNSName From d69d7207003d47598d7747e1e525c67ca3425851 Mon Sep 17 00:00:00 2001 From: sayali Date: Fri, 3 Dec 2021 17:27:53 -0800 Subject: [PATCH 5/8] docs syntax --- docs/administration.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/administration.rst b/docs/administration.rst index 1f1185e450..e84b70fd6a 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -2032,7 +2032,7 @@ get it added. Want to create your own extension? See :doc:`../developer/plugins/index` to get started. -.. _iam target +.. _iam_target: Identity and Access Management ============================== @@ -2073,7 +2073,7 @@ Membership Plugin User Membership :Description: Adds support to learn and validate user membership details from an external service. User memberships are used to - create user roled dynamically as described in :ref:`iam target`. Configure this plugin slug as `USER_MEMBERSHIP_PROVIDER` + create user roled dynamically as described in :ref:`iam_target`. Configure this plugin slug as `USER_MEMBERSHIP_PROVIDER` Authorization Plugins --------------------- From ee9a66d7837a4cc9d5d85682bedf77858e818380 Mon Sep 17 00:00:00 2001 From: sayali Date: Thu, 9 Dec 2021 12:32:34 -0800 Subject: [PATCH 6/8] typo --- docs/administration.rst | 4 ++-- lemur/domains/service.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/administration.rst b/docs/administration.rst index e84b70fd6a..2b647bba55 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -1034,7 +1034,7 @@ If you are not using a custom authorization provider you do not need to configur .. data:: USER_DOMAIN_AUTHORIZATION_PROVIDER :noindex: - An optional plugin to perform authorization of domains for which certificate is being issued. Provide plugin slug here. + An optional plugin to perform domain level authorization during certificate issuance. Provide plugin slug here. Plugin is used to check if caller is authorized to issue a certificate for a given Common Name and Subject Alternative Name (SAN) of type DNSName. Plugin shall be an implementation of DomainAuthorizationPlugin. @@ -2073,7 +2073,7 @@ Membership Plugin User Membership :Description: Adds support to learn and validate user membership details from an external service. User memberships are used to - create user roled dynamically as described in :ref:`iam_target`. Configure this plugin slug as `USER_MEMBERSHIP_PROVIDER` + create user roles dynamically as described in :ref:`iam_target`. Configure this plugin slug as `USER_MEMBERSHIP_PROVIDER` Authorization Plugins --------------------- diff --git a/lemur/domains/service.py b/lemur/domains/service.py index cf0e386f24..3d83b056be 100644 --- a/lemur/domains/service.py +++ b/lemur/domains/service.py @@ -62,7 +62,7 @@ def is_domain_sensitive(name): def is_authorized_for_domain(name): """ - If authorization plugin is available, perform the check to see if current user can issue certificate for a give + If authorization plugin is available, perform the check to see if current user can issue certificate for a given domain. Return True if authorized, False otherwise. If authorization plugin is not available, return true by default From f34d16e924b9cc66330cf6518c6fcb885c85dfe8 Mon Sep 17 00:00:00 2001 From: sayali Date: Thu, 9 Dec 2021 16:08:07 -0800 Subject: [PATCH 7/8] update return type --- lemur/domains/service.py | 10 ++++++---- lemur/plugins/bases/authorization.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lemur/domains/service.py b/lemur/domains/service.py index 3d83b056be..8a0be6c725 100644 --- a/lemur/domains/service.py +++ b/lemur/domains/service.py @@ -14,6 +14,7 @@ from lemur.certificates.models import Certificate from lemur.domains.models import Domain from lemur.plugins.base import plugins +from lemur.plugins.bases.authorization import UnauthorizedError def get(domain_id): @@ -64,11 +65,10 @@ def is_authorized_for_domain(name): """ If authorization plugin is available, perform the check to see if current user can issue certificate for a given domain. - Return True if authorized, False otherwise. - If authorization plugin is not available, return true by default + Raises UnauthorizedError if unauthorized. + If authorization plugin is not available, it returns without performing any check :param name: domain (string) for which authorization check is being done - :return: True/False """ if current_app.config.get("USER_DOMAIN_AUTHORIZATION_PROVIDER") is None: # nothing to check since USER_DOMAIN_AUTHORIZATION_PROVIDER is not configured @@ -78,10 +78,12 @@ def is_authorized_for_domain(name): caller = g.caller_application if hasattr(g, 'caller_application') else g.user.email # if the caller can be mapped to an application name, use that to perform authorization # this could be true when using API key to call lemur (migration script e2d406ada25c_.py) - _, error = user_domain_authorization_provider.is_authorized(domain=name, caller=caller) + authorized, error = user_domain_authorization_provider.is_authorized(domain=name, caller=caller) if error: raise error + if not authorized: + raise UnauthorizedError(user=caller, resource=name, action="issue_certificate") def create(name, sensitive): diff --git a/lemur/plugins/bases/authorization.py b/lemur/plugins/bases/authorization.py index b82f7976e8..bf886f8bde 100644 --- a/lemur/plugins/bases/authorization.py +++ b/lemur/plugins/bases/authorization.py @@ -35,7 +35,7 @@ class UnauthorizedError(LemurException): """ Raised when user is unauthorized to perform an action on the resource """ - def __init__(self, user, resource, action, details): + def __init__(self, user, resource, action, details="no additional details"): self.user = user self.resource = resource self.action = action From 64efa7379e29ef38edd23c94ed040b413efb682b Mon Sep 17 00:00:00 2001 From: sayali Date: Mon, 10 Jan 2022 13:41:00 -0800 Subject: [PATCH 8/8] comments --- lemur/certificates/views.py | 1 + lemur/domains/service.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lemur/certificates/views.py b/lemur/certificates/views.py index 07dc4b4374..641a72389d 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -516,6 +516,7 @@ def post(self, data=None): return dict(message=f"You are not authorized to use the authority: {data['authority'].name}"), 403 data["creator"] = g.user + # allowed_issuance_for_domain throws UnauthorizedError if caller is not authorized try: service.allowed_issuance_for_domain(data["common_name"], data["extensions"]) except UnauthorizedError as e: diff --git a/lemur/domains/service.py b/lemur/domains/service.py index 8a0be6c725..c15bb11814 100644 --- a/lemur/domains/service.py +++ b/lemur/domains/service.py @@ -75,9 +75,9 @@ def is_authorized_for_domain(name): return user_domain_authorization_provider = plugins.get(current_app.config.get("USER_DOMAIN_AUTHORIZATION_PROVIDER")) - caller = g.caller_application if hasattr(g, 'caller_application') else g.user.email # if the caller can be mapped to an application name, use that to perform authorization # this could be true when using API key to call lemur (migration script e2d406ada25c_.py) + caller = g.caller_application if hasattr(g, 'caller_application') else g.user.email authorized, error = user_domain_authorization_provider.is_authorized(domain=name, caller=caller) if error: