diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py index 3da059290..9235e8ef3 100644 --- a/endpoints/api/suconfig.py +++ b/endpoints/api/suconfig.py @@ -19,7 +19,7 @@ from data.database import User from util.config.configutil import add_enterprise_config_defaults from util.config.database import sync_database_with_config from util.config.validator import validate_service_for_config, CONFIG_FILENAMES -from util.license import decode_license, LicenseError +from util.license import decode_license, LicenseDecodeError from data.runmigration import run_alembic_migration from data.users import get_federated_service_name, get_users_handler @@ -283,16 +283,17 @@ class SuperUserSetAndValidateLicense(ApiResource): license_contents = request.get_json()['license'] try: decoded_license = decode_license(license_contents) - except LicenseError as le: + except LicenseDecodeError as le: raise InvalidRequest(le.message) - if decoded_license.is_expired: - raise InvalidRequest('License has expired') + statuses = decoded_license.validate({}) + all_met = all(status.is_met() for status in statuses) + if all_met: + config_provider.save_license(license_contents) - config_provider.save_license(license_contents) return { - 'decoded': decoded_license.subscription, - 'success': True + 'status': [status.as_dict(for_private=True) for status in statuses], + 'success': all_met, } diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index d1fe52518..7344da266 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -11,7 +11,7 @@ from flask import request, make_response, jsonify import features -from app import app, avatar, superusers, authentication, config_provider +from app import app, avatar, superusers, authentication, config_provider, license_validator from auth import scopes from auth.auth_context import get_authenticated_user from auth.permissions import SuperUserPermission @@ -23,7 +23,7 @@ from endpoints.api.logs import get_logs, get_aggregate_logs from data import model from data.database import ServiceKeyApprovalType from util.useremails import send_confirmation_email, send_recovery_email -from util.license import decode_license, LicenseError +from util.license import decode_license, LicenseDecodeError logger = logging.getLogger(__name__) @@ -851,15 +851,15 @@ class SuperUserLicense(ApiResource): if SuperUserPermission().can(): try: decoded_license = config_provider.get_license() - except LicenseError as le: + except LicenseDecodeError as le: raise InvalidRequest(le.message) - if decoded_license.is_expired: - raise InvalidRequest('License has expired') + statuses = decoded_license.validate(app.config) + all_met = all(status.is_met() for status in statuses) return { - 'decoded': decoded_license.subscription, - 'success': True + 'status': [status.as_dict(for_private=True) for status in statuses], + 'success': all_met, } abort(403) @@ -875,16 +875,19 @@ class SuperUserLicense(ApiResource): license_contents = request.get_json()['license'] try: decoded_license = decode_license(license_contents) - except LicenseError as le: + except LicenseDecodeError as le: raise InvalidRequest(le.message) - if decoded_license.is_expired: - raise InvalidRequest('License has expired') + statuses = decoded_license.validate(app.config) + all_met = all(status.is_met() for status in statuses) + if all_met: + # Save the license and update the license check thread. + config_provider.save_license(license_contents) + license_validator.compute_license_sufficiency() - config_provider.save_license(license_contents) return { - 'decoded': decoded_license.subscription, - 'success': True + 'status': [status.as_dict(for_private=True) for status in statuses], + 'success': all_met, } abort(403) diff --git a/static/css/core-ui.css b/static/css/core-ui.css index bd482b37e..5ce33c73b 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -567,6 +567,11 @@ a:focus { margin-right: 4px; } +.config-license-field-element .required { + background-color: #f5f5f5; + color: #333; +} + .config-license-field-element textarea { padding: 10px; margin-bottom: 10px; @@ -577,9 +582,8 @@ a:focus { margin-bottom: 26px; } -.config-license-field-element table td:first-child { - width: 150px; - font-weight: bold; +.config-license-field-element table { + margin-top: 20px; } .config-license-field-element .fa { @@ -594,6 +598,10 @@ a:focus { color: #D64456; } +.config-license-field-element li { + padding: 4px; +} + .co-checkbox { position: relative; } diff --git a/static/css/pages/setup.css b/static/css/pages/setup.css index 2ca87dbdc..2cdeb027b 100644 --- a/static/css/pages/setup.css +++ b/static/css/pages/setup.css @@ -36,6 +36,10 @@ margin-bottom: 16px; } +.initial-setup-modal .config-license-field { + margin-top: 30px; +} + .initial-setup-modal .license-valid .fa { margin-right: 6px; } @@ -43,14 +47,3 @@ .initial-setup-modal .license-valid table { margin-top: 40px; } - -.initial-setup-modal .license-valid table td { - border: 0px; - padding: 4px; -} - -.initial-setup-modal .license-valid table td:first-child { - font-weight: bold; - max-width: 100px; - padding-right: 20px; -} diff --git a/static/directives/config/config-license-field.html b/static/directives/config/config-license-field.html index 9eb688156..f414f2aa6 100644 --- a/static/directives/config/config-license-field.html +++ b/static/directives/config/config-license-field.html @@ -3,19 +3,54 @@ config if the license is invalid (since this box will be empty and therefore "required") --> -
+
-
+

License Valid

- - + + + + + + + + + + + + + +
Product:{{ licenseDecoded.publicProductName || licenseDecoded.productName }}
Plan:{{ licenseDecoded.publicPlanName || licenseDecoded.planName }}
RequirementRequired CountSubscriptionSubscription CountExpiration Date
{{ requirementTitles[status.requirement.name] }}{{ status.requirement.count }}{{ status.entitlement.product_name }}{{ status.entitlement.count }}
-
+

Validation Failed

-
{{ licenseError }}
+
{{ licenseError }}
+
+

The following errors were found:

+
    +
  • +
    + +
    + {{ requirementTitles[status.requirement.name] }}: {{ status.requirement.count }} areis required: License provides {{ status.entitlement.count }} +
    + + +
    + {{ requirementTitles[status.requirement.name] }}: License is missing requirement +
    + + +
    + {{ requirementTitles[status.requirement.name] }}: Requirement expired on {{ status.entitlement.expiration.expiration_date }} +
    +
    +
  • +
+
@@ -28,12 +63,12 @@ + ng-readonly="state == LicenseStates.validating"> - -
+
Validating License
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index 19977d359..3cbafb175 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -1256,24 +1256,52 @@ angular.module("core-config-setup", ['angularFileUpload']) transclude: false, restrict: 'C', scope: { + 'isValid': '=?isValid', + 'forSetup': '@forSetup' }, controller: function($scope, $element, ApiService, UserService) { - $scope.state = 'loading-license'; - $scope.showingEditor = false; + $scope.LicenseStates = { + none: 'no-license', + loading: 'license-loading', + valid: 'license-valid', + invalid: 'license-error', + validating: 'validating-license' + }; + + $scope.state = $scope.forSetup == 'true' ? $scope.LicenseStates.none : $scope.LicenseStates.loading; + $scope.showingEditor = $scope.forSetup == 'true'; $scope.requiredBox = ''; + $scope.requirementTitles = { + 'software.quay': 'Quay Enterprise', + 'software.quay.regions': 'Distributed Storage Regions' + }; + + var handleLicenseSuccess = function(resp) { + $scope.state = resp['success'] ? $scope.LicenseStates.valid : $scope.LicenseStates.invalid; + $scope.requiredBox = resp['success'] ? 'filled' : ''; + $scope.showingEditor = !resp['success']; + $scope.licenseStatus = resp['status']; + $scope.licenseError = null; + $scope.isValid = resp['success']; + }; + + var handleLicenseError = function(resp) { + $scope.licenseError = ApiService.getErrorMessage(resp); + $scope.licenseStatus = null; + $scope.state = 'license-error'; + $scope.showingEditor = true; + $scope.requiredBox = ''; + $scope.isValid = false; + }; + var loadLicense = function() { - ApiService.getLicense().then(function(resp) { - $scope.state = 'license-valid'; - $scope.showingEditor = false; - $scope.licenseDecoded = resp['decoded']; - $scope.requiredBox = 'filled'; - }, function(resp) { - $scope.licenseError = ApiService.getErrorMessage(resp); - $scope.state = 'license-error'; - $scope.showingEditor = true; - $scope.requiredBox = ''; - }); + if ($scope.forSetup == 'true') { + $scope.state = $scope.LicenseStates.none; + return; + } + + ApiService.getLicense().then(handleLicenseSuccess, handleLicenseError); }; UserService.updateUserIn($scope, function(user) { @@ -1293,23 +1321,17 @@ angular.module("core-config-setup", ['angularFileUpload']) $event.preventDefault(); $event.stopPropagation(); - $scope.state = 'validating-license'; + $scope.state = $scope.LicenseStates.validating; var data = { 'license': $scope.licenseContents }; - ApiService.updateLicense(data).then(function(resp) { - $scope.state = 'license-valid'; - $scope.showingEditor = false; - $scope.licenseDecoded = resp['decoded']; - $scope.requiredBox = 'filled'; - }, function(resp) { - $scope.licenseError = ApiService.getErrorMessage(resp); - $scope.state = 'license-error'; - $scope.showingEditor = true; - $scope.requiredBox = ''; - }); + if ($scope.forSetup == 'true') { + ApiService.suSetAndValidateLicense(data).then(handleLicenseSuccess, handleLicenseError); + } else { + ApiService.updateLicense(data).then(handleLicenseSuccess, handleLicenseError); + } }; } }; diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js index 3759a9bf2..ab1938fe1 100644 --- a/static/js/pages/setup.js +++ b/static/js/pages/setup.js @@ -40,12 +40,6 @@ // License is being uploaded. 'UPLOAD_LICENSE': 'upload-license', - // License is being validated. - 'VALIDATING_LICENSE': 'upload-license-validating', - - // License is validated. - 'VALIDATED_LICENSE': 'upload-license-validated', - // DB is being configured. 'CONFIG_DB': 'config-db', @@ -105,9 +99,7 @@ $scope.currentState = { 'hasDatabaseSSLCert': false, - 'licenseContents': '', - 'licenseError': null, - 'licenseDecoded': null + 'licenseValid': false }; $scope.$watch('currentStep', function(currentStep) { @@ -144,27 +136,6 @@ } }); - $scope.validateLicense = function() { - $scope.currentStep = $scope.States.VALIDATING_LICENSE; - - var data = { - 'license': $scope.currentState.licenseContents - }; - - ApiService.suSetAndValidateLicense(data).then(function(resp) { - $scope.currentStep = $scope.States.VALIDATED_LICENSE; - - $scope.currentState.licenseError = null; - $scope.currentState.licenseDecoded = resp['decoded']; - }, function(resp) { - $scope.currentStep = $scope.States.UPLOAD_LICENSE; - - $scope.currentState.licenseError = ApiService.getErrorMessage(resp); - $scope.currentState.licenseContents = ''; - $scope.currentState.licenseDecoded = null; - }); - }; - $scope.restartContainer = function(state) { $scope.currentStep = state; ContainerService.restartContainer(function() { diff --git a/static/partials/setup.html b/static/partials/setup.html index e238ab846..a59c506e6 100644 --- a/static/partials/setup.html +++ b/static/partials/setup.html @@ -130,21 +130,9 @@ The container must be restarted to apply the configuration changes.
- - - - - @@ -259,26 +241,12 @@ Database Validation Issue: {{ errors.DatabaseValidationError }}
- + - - - diff --git a/test/test_license.py b/test/test_license.py index 9dc920d61..c10f6a23e 100644 --- a/test/test_license.py +++ b/test/test_license.py @@ -9,9 +9,13 @@ from Crypto.PublicKey import RSA from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_der_public_key -from util.license import decode_license, LICENSE_PRODUCT_NAME, LicenseValidationError +from util.license import (decode_license, LicenseDecodeError, ExpirationType, + MONTHLY_GRACE_PERIOD, YEARLY_GRACE_PERIOD, TRIAL_GRACE_PERIOD) +def get_date(delta): + return str(datetime.now() + delta) + class TestLicense(unittest.TestCase): def keys(self): with open('test/data/test.pem') as f: @@ -21,12 +25,12 @@ class TestLicense(unittest.TestCase): backend=default_backend()) return (public_key, private_key) - def create_license(self, license_data): + def create_license(self, license_data, keys=None): jwt_data = { 'license': json.dumps(license_data), } - (public_key, private_key) = self.keys() + (public_key, private_key) = keys or self.keys() # Encode the license with the JWT key. encoded = jwt.encode(jwt_data, private_key, algorithm='RS256') @@ -34,102 +38,435 @@ class TestLicense(unittest.TestCase): # Decode it into a license object. return decode_license(encoded, public_key_instance=public_key) - def get_license(self, expiration_delta=None, **kwargs): - license_data = { - 'expirationDate': str(datetime.now() + expiration_delta), + def test_license_decodeerror_invalid(self): + with self.assertRaises(LicenseDecodeError): + decode_license('some random stuff') + + def test_license_decodeerror_badkey(self): + (_, private_key) = self.keys() + jwt_data = { + 'license': json.dumps({}), } - if kwargs: - sub = { - 'productName': LICENSE_PRODUCT_NAME, - } + encoded_stuff = jwt.encode(jwt_data, private_key, algorithm='RS256') + with self.assertRaises(LicenseDecodeError): + # Note that since we don't give a key here, the prod one will be used, and it should fail. + decode_license(encoded_stuff) - sub['trialOnly'] = kwargs.get('trial_only', False) - sub['inTrial'] = kwargs.get('in_trial', False) - sub['entitlements'] = kwargs.get('entitlements', []) + def assertValid(self, license, config=None): + results = license.validate(config or {}) + is_met = all([r.is_met() for r in results]) + self.assertTrue(is_met, [r for r in results if not r.is_met()]) - if 'trial_end' in kwargs: - sub['trialEnd'] = str(datetime.now() + kwargs['trial_end']) + def assertNotValid(self, license, config=None, requirement=None, expired=None): + results = license.validate(config or {}) + is_met = all([r.is_met() for r in results]) + self.assertFalse(is_met) - if 'service_end' in kwargs: - sub['serviceEnd'] = str(datetime.now() + kwargs['service_end']) + invalid_results = [r for r in results if not r.is_met()] + if requirement is not None: + self.assertEquals(invalid_results[0].requirement.name, requirement) - if 'duration' in kwargs: - sub['durationPeriod'] = kwargs['duration'] + if expired is not None: + self.assertEquals(invalid_results[0].entitlement.expiration.expiration_type, expired) - license_data['subscriptions'] = {'somesub': sub} - - decoded_license = self.create_license(license_data) - return decoded_license - - def test_license_itself_expired(self): - # License is expired. - license = self.get_license(timedelta(days=-30)) - - def test_no_qe_subscription(self): - # License is not expired, but there is no QE sub, so not valid. - license = self.get_license(timedelta(days=30)) - - def test_trial_withingrace(self): - license = self.get_license(timedelta(days=30), trial_only=True, trial_end=timedelta(days=-1)) - self.assertFalse(license.is_expired) - - def test_trial_outsidegrace(self): - license = self.get_license(timedelta(days=30), trial_only=True, trial_end=timedelta(days=-10)) - self.assertTrue(license.is_expired) - - def test_trial_intrial_withingrace(self): - license = self.get_license(timedelta(days=30), in_trial=True, service_end=timedelta(days=-1)) - self.assertFalse(license.is_expired) - - def test_trial_intrial_outsidegrace(self): - license = self.get_license(timedelta(days=30), in_trial=True, service_end=timedelta(days=-10)) - self.assertTrue(license.is_expired) - - def test_monthly_license_valid(self): - license = self.get_license(timedelta(days=30), service_end=timedelta(days=10), duration='months') - self.assertFalse(license.is_expired) - - def test_monthly_license_withingrace(self): - license = self.get_license(timedelta(days=30), service_end=timedelta(days=-10), duration='months') - self.assertFalse(license.is_expired) - - def test_monthly_license_outsidegrace(self): - license = self.get_license(timedelta(days=30), service_end=timedelta(days=-40), duration='months') - self.assertTrue(license.is_expired) - - def test_yearly_license_withingrace(self): - license = self.get_license(timedelta(days=30), service_end=timedelta(days=-40), duration='years') - self.assertFalse(license.is_expired) - - def test_yearly_license_outsidegrace(self): - license = self.get_license(timedelta(days=30), service_end=timedelta(days=-100), duration='years') - self.assertTrue(license.is_expired) - - def test_valid_license(self): - license = self.get_license(timedelta(days=300), service_end=timedelta(days=40), duration='years') - self.assertFalse(license.is_expired) - - def test_validate_basic_license(self): - decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), - duration='months', entitlements={}) - decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]}) - - def test_validate_storage_entitlement_valid(self): - decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={ - 'software.quay.regions': 2, + def test_missing_subscriptions(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), }) - decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]}) + self.assertNotValid(license, requirement='software.quay') - def test_validate_storage_entitlement_invalid(self): - decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={ - 'software.quay.regions': 1, + def test_empty_subscriptions(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": {}, }) - with self.assertRaises(LicenseValidationError): - decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}, {}]}) + self.assertNotValid(license, requirement='software.quay') + def test_missing_quay_entitlement(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay.regions": 0, + }, + }, + }, + }) + + self.assertNotValid(license, requirement='software.quay') + + def test_valid_quay_entitlement(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertValid(license) + + def test_missing_expiration(self): + license = self.create_license({ + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertNotValid(license, expired=ExpirationType.license_wide) + + def test_expired_license(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=-10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertNotValid(license, expired=ExpirationType.license_wide) + + def test_expired_sub_implicit_monthly_withingrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(MONTHLY_GRACE_PERIOD * -1 + timedelta(days=1)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertValid(license) + + def test_expired_sub_monthly_withingrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(MONTHLY_GRACE_PERIOD * -1 + timedelta(days=1)), + "durationPeriod": "monthly", + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertValid(license) + + def test_expired_sub_monthly_outsidegrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(MONTHLY_GRACE_PERIOD * -1 + timedelta(days=-1)), + "durationPeriod": "monthly", + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertNotValid(license, expired=ExpirationType.monthly) + + def test_expired_sub_yearly_withingrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(YEARLY_GRACE_PERIOD * -1 + timedelta(days=1)), + "durationPeriod": "yearly", + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertValid(license) + + def test_expired_sub_yearly_outsidegrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(YEARLY_GRACE_PERIOD * -1 + timedelta(days=-1)), + "durationPeriod": "yearly", + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertNotValid(license, expired=ExpirationType.yearly) + + def test_expired_sub_intrial_withingrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=1)), + "inTrial": True, + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertValid(license) + + def test_expired_sub_intrial_outsidegrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)), + "inTrial": True, + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertNotValid(license, expired=ExpirationType.in_trial) + + def test_expired_sub_trialonly_withingrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=1)), + "trialOnly": True, + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertValid(license) + + def test_expired_sub_trialonly_outsidegrace(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)), + "trialOnly": True, + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + self.assertNotValid(license, expired=ExpirationType.trial_only) + + def test_valid_quay_entitlement_regions(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + config = { + 'DISTRIBUTED_STORAGE_CONFIG': [ + {'name': 'first'}, + ], + } + + self.assertValid(license, config=config) + + def test_invalid_quay_entitlement_regions(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + }, + }) + + config = { + 'DISTRIBUTED_STORAGE_CONFIG': [ + {'name': 'first'}, + {'name': 'second'}, + ], + } + + self.assertNotValid(license, config=config, requirement='software.quay.regions') + + def test_valid_regions_across_multiple_sub(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + "anothersub": { + "serviceEnd": get_date(timedelta(days=20)), + "entitlements": { + "software.quay.regions": 5, + }, + }, + }, + }) + + config = { + 'DISTRIBUTED_STORAGE_CONFIG': [ + {'name': 'first'}, + {'name': 'second'}, + ], + } + + self.assertValid(license, config=config) + + def test_valid_regions_across_multiple_sub_one_expired(self): + # Setup a license with one sub having too few regions, and another having enough, but it is + # expired. + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "serviceEnd": get_date(timedelta(days=10)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 1, + }, + }, + "anothersub": { + "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)), + "trialOnly": True, + "entitlements": { + "software.quay.regions": 5, + }, + }, + }, + }) + + config = { + 'DISTRIBUTED_STORAGE_CONFIG': [ + {'name': 'first'}, + {'name': 'second'}, + ], + } + + self.assertNotValid(license, config=config, requirement='software.quay.regions', + expired=ExpirationType.trial_only) + + def test_valid_regions_across_multiple_sub_one_expired(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)), + "trialOnly": True, + "entitlements": { + "software.quay": 1, + "software.quay.regions": 3, + }, + }, + "anothersub": { + "serviceEnd": get_date(timedelta(days=20)), + "entitlements": { + "software.quay": 1, + "software.quay.regions": 5, + }, + }, + }, + }) + + config = { + 'DISTRIBUTED_STORAGE_CONFIG': [ + {'name': 'first'}, + {'name': 'second'}, + ], + } + + self.assertValid(license, config=config) + + def test_quay_is_under_expired_sub(self): + license = self.create_license({ + "expirationDate": get_date(timedelta(days=10)), + "subscriptions": { + "somesub": { + "trialEnd": get_date(TRIAL_GRACE_PERIOD * -1 + timedelta(days=-1)), + "trialOnly": True, + "entitlements": { + "software.quay": 1, + "software.quay.regions": 3, + }, + }, + "anothersub": { + "serviceEnd": get_date(timedelta(days=20)), + "entitlements": { + "software.quay.regions": 5, + }, + }, + }, + }) + + config = { + 'DISTRIBUTED_STORAGE_CONFIG': [ + {'name': 'first'}, + {'name': 'second'}, + ], + } + + self.assertNotValid(license, config=config, expired=ExpirationType.trial_only, + requirement='software.quay') if __name__ == '__main__': unittest.main() diff --git a/util/config/provider/baseprovider.py b/util/config/provider/baseprovider.py index f9a48c67a..c833c2323 100644 --- a/util/config/provider/baseprovider.py +++ b/util/config/provider/baseprovider.py @@ -1,7 +1,7 @@ import logging import yaml -from util.license import LICENSE_FILENAME, LicenseError, decode_license +from util.license import LICENSE_FILENAME, LicenseDecodeError, decode_license logger = logging.getLogger(__name__) @@ -104,13 +104,13 @@ class BaseProvider(object): """ Returns the contents of the license file. """ if not self.has_license_file(): msg = 'Could not find license file. Please make sure it is in your config volume.' - raise LicenseError(msg) + raise LicenseDecodeError(msg) try: return self.get_volume_file(LICENSE_FILENAME) except IOError: msg = 'Could not open license file. Please make sure it is in your config volume.' - raise LicenseError(msg) + raise LicenseDecodeError(msg) def get_license(self): """ Returns the decoded license, if any. """ diff --git a/util/config/provider/testprovider.py b/util/config/provider/testprovider.py index 6f9400b50..ecf2376e9 100644 --- a/util/config/provider/testprovider.py +++ b/util/config/provider/testprovider.py @@ -1,21 +1,22 @@ import json import io +from datetime import datetime, timedelta from util.config.provider.baseprovider import BaseProvider +from util.license import (EntitlementValidationResult, Entitlement, Expiration, ExpirationType, + EntitlementRequirement) REAL_FILES = ['test/data/signing-private.gpg', 'test/data/signing-public.gpg'] class TestLicense(object): - @property - def subscription(self): - return {} - - @property - def is_expired(self): - return False + def validate_entitlement_requirement(self, entitlement_req, check_time): + expiration = Expiration(ExpirationType.license_wide, datetime.now() + timedelta(days=31)) + entitlement = Entitlement('fake', 0, 'someprod', expiration) + fakereq = EntitlementRequirement('fake', 0) + return EntitlementValidationResult(fakereq, datetime.now(), entitlement) def validate(self, config): - pass + return [self.validate_entitlement_requirement(None, None)] class TestConfigProvider(BaseProvider): """ Implementation of the config provider for testing. Everything is kept in-memory instead on diff --git a/util/license.py b/util/license.py index 6ffa7d40f..663349a31 100644 --- a/util/license.py +++ b/util/license.py @@ -6,6 +6,9 @@ import time from ctypes import c_bool from datetime import datetime, timedelta from threading import Thread +from functools import total_ordering +from enum import Enum, IntEnum +from collections import namedtuple from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_public_key @@ -19,132 +22,254 @@ logger = logging.getLogger(__name__) TRIAL_GRACE_PERIOD = timedelta(7, 0) # 1 week -MONTHLY_GRACE_PERIOD = timedelta(30, 0) # 1 month +MONTHLY_GRACE_PERIOD = timedelta(335, 0) # 11 months YEARLY_GRACE_PERIOD = timedelta(90, 0) # 3 months -LICENSE_PRODUCT_NAME = "quay-enterprise" LICENSE_FILENAME = 'license' -class LicenseError(Exception): +class LicenseDecodeError(Exception): """ Exception raised if the license could not be read, decoded or has expired. """ pass -class LicenseDecodeError(LicenseError): - """ Exception raised if the license could not be decoded. """ - pass - - -class LicenseValidationError(LicenseError): - """ Exception raised if the license could not be validated. """ - pass - - -def _get_date(decoded, field): +def _get_date(decoded, field, default_date=datetime.min): """ Retrieves the encoded date found at the given field under the decoded license block. """ date_str = decoded.get(field) - return parser.parse(date_str).replace(tzinfo=None) if date_str else None + return parser.parse(date_str).replace(tzinfo=None) if date_str else default_date -class LicenseExpirationDate(object): - def __init__(self, title, expiration_date, grace_period=None): - self.title = title - self.expiration_date = expiration_date - self.grace_period = grace_period or timedelta(seconds=0) +@total_ordering +class Entitlement(object): + """ An entitlement is a specific piece of software or functionality granted + by a license. It has an expiration date, as well as the count of the + things being granted. Entitlements are orderable by their counts. + """ + def __init__(self, entitlement_name, count, product_name, expiration): + self.name = entitlement_name + self.count = count + self.product_name = product_name + self.expiration = expiration - def check_expired(self, cutoff_date=None): - return self.expiration_and_grace <= (cutoff_date or datetime.now()) + def __lt__(self, rhs): + return self.count < rhs.count + + def __repr__(self): + return str(dict( + name=self.name, + count=self.count, + product_name=self.product_name, + expiration=repr(self.expiration), + )) + + def as_dict(self, for_private=False): + data = { + 'name': self.name, + } + + if for_private: + data.update({ + 'count': self.count, + 'product_name': self.product_name, + 'expiration': self.expiration.as_dict(for_private=True), + }) + + return data + +class ExpirationType(Enum): + """ An enum which represents the different possible types of expirations. If + you posess an expired enum, you can use this to figure out at what level + the expiration was most restrictive. + """ + license_wide = 'License Wide Expiration' + trial_only = 'Trial Only Expiration' + in_trial = 'In-Trial Expiration' + monthly = 'Monthly Subscription Expiration' + yearly = 'Yearly Subscription Expiration' + + +@total_ordering +class Expiration(object): + """ An Expiration is an orderable representation of an expiration date and a + grace period. If you sort Expiration objects, they will be sorted by the + actual cutoff date, which is the combination of the expiration date and + the grace period. + """ + def __init__(self, expiration_type, exp_date, grace_period=timedelta(seconds=0)): + self.expiration_type = expiration_type + self.expiration_date = exp_date + self.grace_period = grace_period @property - def expiration_and_grace(self): + def expires_at(self): return self.expiration_date + self.grace_period - def __str__(self): - return 'License expiration "%s" date %s with grace %s: %s' % (self.title, self.expiration_date, - self.grace_period, - self.check_expired()) + def is_expired(self, now): + """ Check if the current object should already be considered expired when + compared with the passed in datetime object. + """ + return self.expires_at < now + + def __lt__(self, rhs): + return self.expires_at < rhs.expires_at + + def __repr__(self): + return str(dict( + expiration_type=repr(self.expiration_type), + expiration_date=repr(self.expiration_date), + grace_period=repr(self.grace_period), + )) + + def as_dict(self, for_private=False): + data = { + 'expiration_type': str(self.expiration_type), + } + + if for_private: + data.update({ + 'expiration_date': str(self.expiration_date), + 'grace_period': str(self.grace_period), + }) + + return data + + +class EntitlementStatus(IntEnum): + """ An EntitlementStatus represent the current effectiveness of an + Entitlement when compared with its corresponding requirement. As an + example, if the software requires 9 items, and the Entitlement only + provides for 7, you would use an insufficient_count status. + """ + met = 0 + expired = 1 + insufficient_count = 2 + no_matching = 3 + + +@total_ordering +class EntitlementValidationResult(object): + """ An EntitlementValidationResult encodes the combination of a specific + entitlement and the software requirement which caused it to be examined. + They are orderable by the value of the EntitlementStatus enum, and will + in general be sorted by most to least satisfiable status type. + """ + def __init__(self, requirement, created_at, entitlement=None): + self.requirement = requirement + self._created_at = created_at + self.entitlement = entitlement + + def get_status(self): + """ Returns the EntitlementStatus when comparing the specified Entitlement + with the corresponding requirement. + """ + if self.entitlement is not None: + if self.entitlement.expiration.is_expired(self._created_at): + return EntitlementStatus.expired + + if self.entitlement.count < self.requirement.count: + return EntitlementStatus.insufficient_count + + return EntitlementStatus.met + + return EntitlementStatus.no_matching + + def is_met(self): + """ Returns whether this specific EntitlementValidationResult meets all + of the criteria for being sufficient, including unexpired (or in the + grace period), and with a sufficient count. + """ + return self.get_status() == EntitlementStatus.met + + def __lt__(self, rhs): + return self.get_status() < rhs.get_status() + + def __repr__(self): + return str(dict( + requirement=repr(self.requirement), + created_at=repr(self._created_at), + entitlement=repr(self.entitlement), + )) + + def as_dict(self, for_private=False): + def req_view(): + return { + 'name': self.requirement.name, + 'count': self.requirement.count, + } + + data = { + 'requirement': req_view(), + 'status': str(self.get_status()), + } + + if self.entitlement is not None: + data['entitlement'] = self.entitlement.as_dict(for_private=for_private) + + return data + class License(object): """ License represents a fully decoded and validated (but potentially expired) license. """ def __init__(self, decoded): self.decoded = decoded - @property - def subscription(self): - """ Returns the Quay Enterprise subscription, if any. """ + def validate_entitlement_requirement(self, entitlement_req, check_time): + all_active_entitlements = list(self._find_entitlements(entitlement_req.name)) + + if len(all_active_entitlements) == 0: + return EntitlementValidationResult(entitlement_req, check_time) + + entitlement_results = [EntitlementValidationResult(entitlement_req, check_time, ent) + for ent in all_active_entitlements] + entitlement_results.sort() + return entitlement_results[0] + + def _find_entitlements(self, entitlement_name): + license_expiration = Expiration( + ExpirationType.license_wide, + _get_date(self.decoded, 'expirationDate'), + ) + for sub in self.decoded.get('subscriptions', {}).values(): - if sub.get('productName') == LICENSE_PRODUCT_NAME: - return sub + entitlement_count = sub.get('entitlements', {}).get(entitlement_name) - return None + if entitlement_count is not None: + entitlement_expiration = min(self._sub_expiration(sub), license_expiration) + yield Entitlement( + entitlement_name, + entitlement_count, + sub.get('productName', 'unknown'), + entitlement_expiration, + ) - @property - def is_expired(self): - cutoff_date = datetime.now() - return bool([dt for dt in self._get_expiration_dates() if dt.check_expired(cutoff_date)]) + @staticmethod + def _sub_expiration(subscription): + # A trial license has its own end logic, and uses the trialEnd property + if subscription.get('trialOnly', False): + trial_expiration = Expiration( + ExpirationType.trial_only, + _get_date(subscription, 'trialEnd'), + TRIAL_GRACE_PERIOD, + ) + return trial_expiration + + # From here we always use the serviceEnd + service_end = _get_date(subscription, 'serviceEnd') + + if subscription.get('inTrial', False): + return Expiration(ExpirationType.in_trial, service_end, TRIAL_GRACE_PERIOD) + + if subscription.get('durationPeriod') == 'yearly': + return Expiration(ExpirationType.yearly, service_end, YEARLY_GRACE_PERIOD) + + # We assume monthly license unless specified otherwise + return Expiration(ExpirationType.monthly, service_end, MONTHLY_GRACE_PERIOD) def validate(self, config): - """ Validates the license and all its entitlements against the given config. """ - # Check that the license has not expired. - if self.is_expired: - raise LicenseValidationError('License has expired') - - # Check the maximum number of replication regions. - max_regions = min(self.decoded.get('entitlements', {}).get('software.quay.regions', 1), 1) - config_regions = len(config.get('DISTRIBUTED_STORAGE_CONFIG', [])) - if max_regions != -1 and config_regions > max_regions: - msg = '{} regions configured, but license file allows up to {}'.format(config_regions, - max_regions) - raise LicenseValidationError(msg) - - def _get_expiration_dates(self): - # Check if the license overall has expired. - expiration_date = _get_date(self.decoded, 'expirationDate') - if expiration_date is None: - yield LicenseExpirationDate('No valid Tectonic Account License', datetime.min) - return - - yield LicenseExpirationDate('Tectonic Account License', expiration_date) - - # Check for any QE subscriptions. - sub = self.subscription - if sub is None: - yield LicenseExpirationDate('No Quay Enterprise Subscription', datetime.min) - return - - # Check for a trial-only license. - if sub.get('trialOnly', False): - trial_end_date = _get_date(sub, 'trialEnd') - if trial_end_date is None: - yield LicenseExpirationDate('Invalid trial subscription', datetime.min) - else: - yield LicenseExpirationDate('Trial subscription', trial_end_date, TRIAL_GRACE_PERIOD) - - return - - # Check for a normal license that is in trial. - service_end_date = _get_date(sub, 'serviceEnd') - if service_end_date is None: - yield LicenseExpirationDate('No valid Quay Enterprise Subscription', datetime.min) - return - - if sub.get('inTrial', False): - # If the subscription is in a trial, but not a trial only - # subscription, give 7 days after trial end to update license - # to one which has been paid (they've put in a credit card and it - # might auto convert, so we could assume it will auto-renew) - yield LicenseExpirationDate('In-trial subscription', service_end_date, TRIAL_GRACE_PERIOD) - - # Otherwise, check the service expiration. - duration_period = sub.get('durationPeriod', 'months') - - # If the subscription is monthly, give 3 months grace period - if duration_period == "months": - yield LicenseExpirationDate('Monthly subscription', service_end_date, MONTHLY_GRACE_PERIOD) - - if duration_period == "years": - yield LicenseExpirationDate('Yearly subscription', service_end_date, YEARLY_GRACE_PERIOD) - + """ Returns a list of EntitlementValidationResult objects, one per requirement. + """ + requirements = _gen_entitlement_requirements(config) + now = datetime.now() + return [self.validate_entitlement_requirement(req, now) for req in requirements] _PROD_LICENSE_PUBLIC_KEY_DATA = """ @@ -183,6 +308,17 @@ LICENSE_VALIDATION_INTERVAL = 3600 # seconds LICENSE_VALIDATION_EXPIRED_INTERVAL = 60 # seconds +EntitlementRequirement = namedtuple('EntitlementRequirements', ['name', 'count']) + + +def _gen_entitlement_requirements(config_obj): + config_regions = len(config_obj.get('DISTRIBUTED_STORAGE_CONFIG', [])) + return [ + EntitlementRequirement('software.quay', 1), + EntitlementRequirement('software.quay.regions', config_regions), + ] + + class LicenseValidator(Thread): """ LicenseValidator is a thread that asynchronously reloads and validates license files. @@ -191,36 +327,44 @@ class LicenseValidator(Thread): synchronization primitive. """ def __init__(self, config_provider, *args, **kwargs): + config = config_provider.get_config() or {} + self._config_provider = config_provider + self._entitlement_requirements = _gen_entitlement_requirements(config) # multiprocessing.Value does not ensure consistent write-after-reads, but we don't need that. - self._license_is_expired = multiprocessing.Value(c_bool, True) + self._license_is_insufficient = multiprocessing.Value(c_bool, True) super(LicenseValidator, self).__init__(*args, **kwargs) self.daemon = True @property - def expired(self): - return self._license_is_expired.value + def insufficient(self): + return self._license_is_insufficient.value - def _check_expiration(self): + def compute_license_sufficiency(self): + """ Check whether all of our requirements are met, and set the status of + the result of the check, which will be used to disable the software. + Returns True if any requirements are not met, and False if all are met. + """ try: current_license = self._config_provider.get_license() - is_expired = current_license.is_expired - logger.debug('updating license expiration to %s', is_expired) - self._license_is_expired.value = is_expired - except (IOError, LicenseError): + now = datetime.now() + any_invalid = not all(current_license.validate_entitlement_requirement(req, now).is_met() + for req in self._entitlement_requirements) + logger.debug('updating license license_is_insufficient to %s', any_invalid) + except (IOError, LicenseDecodeError): logger.exception('failed to validate license') - is_expired = True - self._license_is_expired.value = is_expired + any_invalid = True - return is_expired + self._license_is_insufficient.value = any_invalid + return any_invalid def run(self): logger.debug('Starting license validation thread') while True: - expired = self._check_expiration() - sleep_time = LICENSE_VALIDATION_EXPIRED_INTERVAL if expired else LICENSE_VALIDATION_INTERVAL + invalid = self.compute_license_sufficiency() + sleep_time = LICENSE_VALIDATION_EXPIRED_INTERVAL if invalid else LICENSE_VALIDATION_INTERVAL logger.debug('waiting %d seconds before retrying to validate license', sleep_time) time.sleep(sleep_time) @@ -231,11 +375,11 @@ class LicenseValidator(Thread): """ if response_func is None: def _response_func(): - return make_response('License has expired.', 402) + return make_response('License is insufficient.', 402) response_func = _response_func def _enforce_license(): - if self.expired: - logger.debug('blocked interaction due to expired license') + if self.insufficient: + logger.debug('blocked interaction due to insufficient license') return response_func() blueprint.before_request(_enforce_license)