change license uploading to parse RHSM manifests

Co-authored-by: Christian Adams <chadams@redhat.com>
This commit is contained in:
Ryan Petrello 2020-10-21 17:23:24 -04:00
parent 4445d096f5
commit 927b055e65
No known key found for this signature in database
GPG Key ID: F2AA5F2122351777
14 changed files with 77 additions and 125 deletions

View File

@ -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)))

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -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,

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -667,10 +667,6 @@ EC2_ENABLED_VALUE = 'running'
EC2_INSTANCE_ID_VAR = 'ec2_id'
EC2_EXCLUDE_EMPTY_GROUPS = True
# Entitlements
ENTITLEMENT_CERT = ''
# ------------
# -- VMware --
# ------------

View File

@ -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;

View File

@ -95,14 +95,14 @@
2
</span>
<span class="License-helperText">
<translate>Choose your entitlement certificate and key pair, agree to the End User License Agreement, and click submit.</translate>
<translate>Choose your Red Hat Subscription Manifest, agree to the End User License Agreement, and click submit.</translate>
</span>
</div>
<div class="License-subTitleText">
<span class="Form-requiredAsterisk">*</span>
<translate>Entitlement certificate and key</translate>
<translate>Red Hat Subscription Manifest</translate>
</div>
<div class="License-helperText License-licenseStepHelp" translate>Upload an entitlement certificate and key pair</div>
<div class="License-helperText License-licenseStepHelp" translate>Upload a Red Hat Subscription Manifest</div>
<div class="License-filePicker">
<span class="btn btn-primary" ng-click="fakeClick()" ng-disabled="!user_is_superuser || (subscriptionCreds.username && subscriptionCreds.username.length > 0) || (subscriptionCreds.password && subscriptionCreds.password.length > 0)" translate>Browse</span>
<span class="License-fileName" ng-class="{'License-helperText' : fileName == 'No file selected.'}">{{fileName|translate}}</span>
@ -119,7 +119,6 @@
<div class="d-block w-100">
<div class="AddPermissions-directions">
<span class="License-helperText">
<<<<<<< HEAD
<translate>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.</translate>
</span>
</div>
@ -199,7 +198,7 @@
<span ng-show="success == true" class="License-greenText License-submit--success pull-right" translate>Save successful!</span>
</div>
<div>
<button ng-click="submit()" class="btn btn-success pull-right" ng-disabled="(!newLicense.entitlement_cert && !selectedLicense.fullLicense) || newLicense.eula == null || !user_is_superuser" translate>Submit</button>
<button ng-click="submit()" class="btn btn-success pull-right" ng-disabled="(!newLicense.manifest && !selectedLicense.fullLicense) || newLicense.eula == null || !user_is_superuser" translate>Submit</button>
</div>
</div>
</div>

View File

@ -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

View File

@ -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