From 927b055e65cafc791ace55f8e719cc935efcf4fa Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 21 Oct 2020 17:23:24 -0400 Subject: [PATCH] change license uploading to parse RHSM manifests Co-authored-by: Christian Adams --- awx/api/parsers.py | 25 ------ awx/api/views/root.py | 37 ++++----- awx/conf/license.py | 2 +- awx/main/access.py | 4 +- awx/main/conf.py | 16 ---- awx/main/management/commands/check_license.py | 2 +- .../management/commands/inventory_import.py | 4 +- awx/main/utils/licensing.py | 79 ++++++++++--------- awx/main/validators.py | 6 -- awx/settings/defaults.py | 4 - .../client/src/license/license.controller.js | 12 +-- .../client/src/license/license.partial.html | 9 +-- requirements/requirements.txt | 1 - requirements/requirements_git.txt | 1 - 14 files changed, 77 insertions(+), 125 deletions(-) diff --git a/awx/api/parsers.py b/awx/api/parsers.py index 8c9cfede94..ce18bce0af 100644 --- a/awx/api/parsers.py +++ b/awx/api/parsers.py @@ -34,28 +34,3 @@ class JSONParser(parsers.JSONParser): return obj except ValueError as exc: raise ParseError(_('JSON parse error - %s\nPossible cause: trailing comma.' % str(exc))) - - -class ConfigJSONParser(parsers.JSONParser): - """ - Entitlement Certificates have newlines in them which require json.loads to - not use strict parsing. - """ - - def parse(self, stream, media_type=None, parser_context=None): - """ - Parses the incoming bytestream as JSON and returns the resulting data. - """ - parser_context = parser_context or {} - encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET) - - try: - data = smart_str(stream.read(), encoding=encoding) - if not data: - return {} - obj = json.loads(data, object_pairs_hook=OrderedDict, strict=False) - if not isinstance(obj, dict) and obj is not None: - raise ParseError(_('JSON parse error - not a JSON object')) - return obj - except ValueError as exc: - raise ParseError(_('JSON parse error - %s\nPossible cause: trailing comma.' % str(exc))) diff --git a/awx/api/views/root.py b/awx/api/views/root.py index abcd0eed8a..96d86f9966 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -20,7 +20,6 @@ from rest_framework import status import requests from awx.api.generics import APIView -from awx.api.parsers import ConfigJSONParser from awx.conf.registry import settings_registry from awx.main.analytics import all_collectors from awx.main.ha import is_ha_environment @@ -30,6 +29,7 @@ from awx.main.utils import ( get_custom_venv_choices, to_python_boolean, ) +from awx.main.utils.licensing import validate_entitlement_manifest from awx.api.versioning import reverse, drf_reverse from awx.main.constants import PRIVILEGE_ESCALATION_METHODS from awx.main.models import ( @@ -276,7 +276,6 @@ class ApiV2ConfigView(APIView): permission_classes = (IsAuthenticated,) name = _('Configuration') swagger_topic = 'System Configuration' - parser_classes = (ConfigJSONParser,) def check_permissions(self, request): super(ApiV2ConfigView, self).check_permissions(request) @@ -287,7 +286,7 @@ class ApiV2ConfigView(APIView): '''Return various sitewide configuration settings''' from awx.main.utils.common import get_licenser - license_data = get_licenser().validate(new_cert=False) + license_data = get_licenser().validate() if not license_data.get('valid_key', False): license_data = {} @@ -350,22 +349,25 @@ class ApiV2ConfigView(APIView): extra=dict(actor=request.user.username)) return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST) - # Save Entitlement Cert/Key + from awx.main.utils.common import get_licenser license_data = json.loads(data_actual) - if 'entitlement_cert' in license_data: - settings.ENTITLEMENT_CERT = license_data['entitlement_cert'] + if 'manifest' in license_data: + try: + license_data = validate_entitlement_manifest(license_data['manifest']) + except Exception as e: + logger.exception('Invalid license submitted.') + return Response({"error": 'Invalid license submitted.'}, status=status.HTTP_400_BAD_REQUEST) - try: - # Validate entitlement cert and get subscription metadata - # validate() will clear the entitlement cert if not valid - from awx.main.utils.common import get_licenser - license_data_validated = get_licenser().validate(new_cert=True) - except Exception: - logger.warning(smart_text(u"Invalid license submitted."), - extra=dict(actor=request.user.username)) - # If License invalid, clear entitlment cert value - settings.ENTITLEMENT_CERT = '' - return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) + try: + # Validate entitlement cert and get subscription metadata + # validate() will clear the entitlement cert if not valid + license_data_validated = get_licenser().license_from_manifest(license_data) + except Exception: + logger.warning(smart_text(u"Invalid license submitted."), + extra=dict(actor=request.user.username)) + return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST) + else: + license_data_validated = get_licenser().validate() # If the license is valid, write it to the database. if license_data_validated['valid_key']: @@ -381,7 +383,6 @@ class ApiV2ConfigView(APIView): # Clear license and entitlement certificate try: settings.LICENSE = {} - settings.ENTITLEMENT_CERT = '' return Response(status=status.HTTP_204_NO_CONTENT) except Exception: # FIX: Log diff --git a/awx/conf/license.py b/awx/conf/license.py index b9d142a9f1..d92830cd9d 100644 --- a/awx/conf/license.py +++ b/awx/conf/license.py @@ -6,7 +6,7 @@ __all__ = ['get_license'] def _get_validated_license_data(): from awx.main.utils.licensing import Licenser - return Licenser().validate(new_cert=False) + return Licenser().validate() def get_license(): diff --git a/awx/main/access.py b/awx/main/access.py index 536c8e4699..0ecb025d92 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -307,7 +307,7 @@ class BaseAccess(object): return True # User has access to both, permission check passed def check_license(self, add_host_name=None, feature=None, check_expiration=True, quiet=False): - validation_info = get_licenser().validate(new_cert=False) + validation_info = get_licenser().validate() if validation_info.get('license_type', 'UNLICENSED') == 'open': return @@ -345,7 +345,7 @@ class BaseAccess(object): report_violation(_("Host count exceeds available instances.")) def check_org_host_limit(self, data, add_host_name=None): - validation_info = get_licenser().validate(new_cert=False) + validation_info = get_licenser().validate() if validation_info.get('license_type', 'UNLICENSED') == 'open': return diff --git a/awx/main/conf.py b/awx/main/conf.py index 95e15e9def..7c61a39f84 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -12,7 +12,6 @@ from rest_framework.fields import FloatField # Tower from awx.conf import fields, register, register_validate -from awx.main.validators import validate_entitlement_cert logger = logging.getLogger('awx.main.conf') @@ -356,21 +355,6 @@ register( category_slug='jobs', ) -register( - 'ENTITLEMENT_CERT', - field_class=fields.CharField, - allow_blank=True, - default='', - required=False, - validators=[validate_entitlement_cert], # TODO: may need to use/modify `validate_certificate` validator - label=_('RHSM Entitlement Public Certificate and Private Key'), - help_text=_('Obtain a key pair via subscription-manager, or https://access.redhat.com. Refer to Ansible Tower docs for formatting key pair.'), - category=_('SYSTEM'), - category_slug='system', - encrypted=True, -) - - register( 'AWX_RESOURCE_PROFILING_ENABLED', field_class=fields.BooleanField, diff --git a/awx/main/management/commands/check_license.py b/awx/main/management/commands/check_license.py index 23d51c7e17..356ab42249 100644 --- a/awx/main/management/commands/check_license.py +++ b/awx/main/management/commands/check_license.py @@ -16,7 +16,7 @@ class Command(BaseCommand): def handle(self, *args, **options): super(Command, self).__init__() - license = get_licenser().validate(new_cert=False) + license = get_licenser().validate() if options.get('data'): return json.dumps(license) return license.get('license_type', 'none') diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 9f97faf45e..f4431b2705 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -901,7 +901,7 @@ class Command(BaseCommand): )) def check_license(self): - license_info = get_licenser().validate(new_cert=False) + license_info = get_licenser().validate() local_license_type = license_info.get('license_type', 'UNLICENSED') if license_info.get('license_key', 'UNLICENSED') == 'UNLICENSED': logger.error(LICENSE_NON_EXISTANT_MESSAGE) @@ -938,7 +938,7 @@ class Command(BaseCommand): logger.warning(LICENSE_MESSAGE % d) def check_org_host_limit(self): - license_info = get_licenser().validate(new_cert=False) + license_info = get_licenser().validate() if license_info.get('license_type', 'UNLICENSED') == 'open': return diff --git a/awx/main/utils/licensing.py b/awx/main/utils/licensing.py index 1021dcbb8e..87641d7bbb 100644 --- a/awx/main/utils/licensing.py +++ b/awx/main/utils/licensing.py @@ -9,16 +9,26 @@ The Licenser class can do the following: - Parse an Entitlement cert to generate license ''' -import os +import base64 import configparser from datetime import datetime import collections import copy -import tempfile +import io +import json import logging +import os import re import requests import time +import zipfile + +from dateutil.parser import parse as parse_date + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography import x509 # Django from django.conf import settings @@ -26,9 +36,6 @@ from django.conf import settings # AWX from awx.main.models import Host -# RHSM -from rhsm import certificate - MAX_INSTANCES = 9999999 logger = logging.getLogger(__name__) @@ -40,6 +47,26 @@ def rhsm_config(): return config +def validate_entitlement_manifest(data): + buff = io.BytesIO() + buff.write(base64.b64decode(data)) + z = zipfile.ZipFile(buff) + buff = io.BytesIO() + + export = z.open('consumer_export.zip').read() + sig = z.open('signature').read() + with open('/etc/tower/certs/candlepin-redhat-ca.crt', 'rb') as f: + cert = x509.load_pem_x509_certificate(f.read(), backend=default_backend()) + key = cert.public_key() + key.verify(sig, export, padding=padding.PKCS1v15(), algorithm=hashes.SHA256()) + + buff.write(export) + z = zipfile.ZipFile(buff) + for f in z.filelist: + if f.filename.startswith('export/entitlements') and f.filename.endswith('.json'): + return json.loads(z.open(f).read()) + + class Licenser(object): # warn when there is a month (30 days) left on the license LICENSE_TIMEOUT = 60 * 60 * 24 * 30 @@ -108,32 +135,15 @@ class Licenser(object): settings.LICENSE = {} - def _generate_product_config(self): - raw_cert = getattr(settings, 'ENTITLEMENT_CERT', None) - # Fail early if no entitlement cert is available - if not raw_cert or raw_cert == '': - self._clear_license_setting() - return - - # Read certificate - certinfo = certificate.create_from_pem(raw_cert) - if not certinfo.is_valid(): - raise ValueError("Could not parse entitlement certificate") - if certinfo.is_expired(): - raise ValueError("Certificate is expired") - if not any(map(lambda x: x.id == '480', certinfo.products)): - self._clear_license_setting() - raise ValueError("Certificate is for another product") - + def license_from_manifest(self, manifest): # Parse output for subscription metadata to build config license = dict() - license['sku'] = certinfo.order.sku - license['instance_count'] = int(certinfo.order.quantity_used) - license['support_level'] = certinfo.order.service_level - license['subscription_name'] = certinfo.order.name - license['pool_id'] = certinfo.pool.id - license['license_date'] = certinfo.end.strftime('%s') - license['product_name'] = certinfo.order.name + license['sku'] = manifest['pool']['productId'] + license['instance_count'] = manifest['pool']['quantity'] + license['subscription_name'] = manifest['pool']['productName'] + license['pool_id'] = manifest['pool']['id'] + license['license_date'] = parse_date(manifest['endDate']).strftime('%s') + license['product_name'] = manifest['pool']['productName'] license['valid_key'] = True license['license_type'] = 'enterprise' license['satellite'] = False @@ -269,7 +279,7 @@ class Licenser(object): ValidSub = collections.namedtuple('ValidSub', 'sku name support_level end_date trial quantity pool_id satellite') valid_subs = [] for sub in json: - satellite = sub['satellite'] + satellite = sub.get('satellite') if satellite: is_valid = self.is_appropriate_sat_sub(sub) else: @@ -344,12 +354,7 @@ class Licenser(object): ) - def validate(self, new_cert=False): - - # Generate Config from Entitlement cert if it exists - if new_cert: - self._generate_product_config() - + def validate(self): # Return license attributes with additional validation info. attrs = copy.deepcopy(self._attrs) type = attrs.get('license_type', 'none') @@ -368,7 +373,7 @@ class Licenser(object): attrs['available_instances'] = available_instances attrs['free_instances'] = max(0, available_instances - current_instances) - license_date = int(attrs.get('license_date', None) or 0) + license_date = int(attrs.get('license_date', 0) or 0) current_date = int(time.time()) time_remaining = license_date - current_date attrs['time_remaining'] = time_remaining diff --git a/awx/main/validators.py b/awx/main/validators.py index d751b51cbe..879be056e5 100644 --- a/awx/main/validators.py +++ b/awx/main/validators.py @@ -17,12 +17,6 @@ from rest_framework.exceptions import ParseError from awx.main.utils import parse_yaml_or_json -def validate_entitlement_cert(data, min_keys=0, max_keys=None, min_certs=0, max_certs=None): - # TODO: replace with more actual robust logic here to allow for multiple certificates in one cert file (because this is how entitlements do) - # This is a temporary hack that should not be merged. Look at Ln:92 - pass - - def validate_pem(data, min_keys=0, max_keys=None, min_certs=0, max_certs=None): """ Validate the given PEM data is valid and contains the required numbers of diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 707f40aa00..618f5282a4 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -667,10 +667,6 @@ EC2_ENABLED_VALUE = 'running' EC2_INSTANCE_ID_VAR = 'ec2_id' EC2_EXCLUDE_EMPTY_GROUPS = True -# Entitlements -ENTITLEMENT_CERT = '' - - # ------------ # -- VMware -- # ------------ diff --git a/awx/ui/client/src/license/license.controller.js b/awx/ui/client/src/license/license.controller.js index 00d00565ab..c6c130114d 100644 --- a/awx/ui/client/src/license/license.controller.js +++ b/awx/ui/client/src/license/license.controller.js @@ -111,14 +111,14 @@ export default const raw = new FileReader(); raw.onload = function() { - $scope.newLicense.entitlement_cert = raw.result; + $scope.newLicense.manifest = btoa(raw.result); }; try { - raw.readAsText(event.target.files[0]); + raw.readAsBinaryString(event.target.files[0]); } catch(err) { ProcessErrors($rootScope, null, null, null, - {msg: i18n._('Invalid file format. Please upload a certificate/key pair')}); + {msg: i18n._('Invalid file format. Please upload a valid Red Hat Subscription Manifest.')}); } }; @@ -135,7 +135,7 @@ export default }; $scope.replacePassword = () => { - if ($scope.user_is_superuser && !$scope.newLicense.entitlement_cert) { + if ($scope.user_is_superuser && !$scope.newLicense.manifest) { $scope.showPlaceholderPassword = false; $scope.subscriptionCreds.password = ""; $timeout(() => { @@ -190,8 +190,8 @@ export default Wait('start'); let payload = {}; let attach = false; - if ($scope.newLicense.entitlement_cert) { - payload.entitlement_cert = $scope.newLicense.entitlement_cert; + if ($scope.newLicense.manifest) { + payload.manifest = $scope.newLicense.manifest; } else if ($scope.selectedLicense.fullLicense) { payload.pool_id = $scope.selectedLicense.fullLicense.pool_id; payload.org = $scope.subscriptionCreds.organization_id; diff --git a/awx/ui/client/src/license/license.partial.html b/awx/ui/client/src/license/license.partial.html index 6958a536e0..b7f9f41942 100644 --- a/awx/ui/client/src/license/license.partial.html +++ b/awx/ui/client/src/license/license.partial.html @@ -95,14 +95,14 @@ 2 - Choose your entitlement certificate and key pair, agree to the End User License Agreement, and click submit. + Choose your Red Hat Subscription Manifest, agree to the End User License Agreement, and click submit.
* - Entitlement certificate and key + Red Hat Subscription Manifest
-
Upload an entitlement certificate and key pair
+
Upload a Red Hat Subscription Manifest
Browse {{fileName|translate}} @@ -119,7 +119,6 @@
-<<<<<<< HEAD Provide your Red Hat or Satellite credentials below and you can choose from a list of your available subscriptions. If you are a Satellite user please specify the organization ID you wish to associate this Tower Installation to. The credentials you use will be stored for future use in retrieving renewal or expanded subscriptions.
@@ -199,7 +198,7 @@ Save successful!
- +
diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 8bbc446236..8449a19233 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -52,7 +52,6 @@ idna==2.9 # via hyperlink, idna-ssl, requests, twisted, yarl importlib-metadata==1.5.0 # via importlib-resources, irc, jsonschema importlib-resources==1.4.0 # via jaraco.text incremental==17.5.0 # via twisted -iniparse==0.5 # via rhsm irc==18.0.0 # via -r /awx_devel/requirements/requirements.in isodate==0.6.0 # via msrest, python3-saml jaraco.classes==3.1.0 # via jaraco.collections diff --git a/requirements/requirements_git.txt b/requirements/requirements_git.txt index ff7a407ae5..340cbfdcc7 100644 --- a/requirements/requirements_git.txt +++ b/requirements/requirements_git.txt @@ -1,2 +1 @@ git+https://github.com/ansible/system-certifi.git@devel#egg=certifi -git+https://github.com/ansible/python-rhsm.git@python3-backport#egg=rhsm