diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index baa5b1a86..d1fe52518 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -18,11 +18,12 @@ from auth.permissions import SuperUserPermission from endpoints.api import (ApiResource, nickname, resource, validate_json_request, internal_only, require_scope, show_if, parse_args, query_param, abort, require_fresh_login, path_param, verify_not_prod, - page_support, log_action) + page_support, log_action, InvalidRequest) 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 logger = logging.getLogger(__name__) @@ -819,3 +820,143 @@ class SuperUserServiceKeyApproval(ApiResource): return make_response('', 201) abort(403) + + +@resource('/v1/superuser/license') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserLicense(ApiResource): + """ Resource for getting and setting a license. """ + schemas = { + 'UpdateLicense': { + 'type': 'object', + 'description': 'Updates a license', + 'required': [ + 'license', + ], + 'properties': { + 'license': { + 'type': 'string' + }, + }, + }, + } + + @nickname('getLicense') + @require_fresh_login + @require_scope(scopes.SUPERUSER) + @verify_not_prod + def get(self): + """ Returns the current decoded license. """ + if SuperUserPermission().can(): + try: + decoded_license = config_provider.get_license() + except LicenseError as le: + raise InvalidRequest(le.message) + + if decoded_license.is_expired: + raise InvalidRequest('License has expired') + + return { + 'decoded': decoded_license.subscription, + 'success': True + } + + abort(403) + + @nickname('updateLicense') + @require_fresh_login + @require_scope(scopes.SUPERUSER) + @verify_not_prod + @validate_json_request('UpdateLicense') + def put(self): + """ Validates the given license contents and then saves it to the config volume. """ + if SuperUserPermission().can(): + license_contents = request.get_json()['license'] + try: + decoded_license = decode_license(license_contents) + except LicenseError as le: + raise InvalidRequest(le.message) + + if decoded_license.is_expired: + raise InvalidRequest('License has expired') + + config_provider.save_license(license_contents) + return { + 'decoded': decoded_license.subscription, + 'success': True + } + + abort(403) + + +@resource('/v1/messages') +@show_if(features.SUPER_USERS) +class SuperUserMessages(ApiResource): + """ Resource for getting a list of super user messages """ + + schemas = { + 'GetMessage': { + 'id': 'GetMessage', + 'type': 'object', + 'description': 'Messages that a super user has saved in the past', + 'properties': { + 'message': { + 'type': 'array', + 'description': 'A list of messages', + 'itemType': { + 'type': 'object', + 'properties': { + 'id': { + 'type': 'integer', + 'description': 'The message id', + }, + 'content': { + 'type': 'string', + 'description': 'The actual message', + }, + }, + }, + }, + }, + }, + 'CreateMessage': { + 'id': 'CreateMessage', + 'type': 'object', + 'description': 'Create a new message', + 'properties': { + 'message': { + 'type': 'object', + 'description': 'A single message', + 'properties': { + 'content': { + 'type': 'string', + 'description': 'The actual message', + }, + }, + }, + }, + } + } + + @nickname('getMessages') + def get(self): + """ Return a super users messages """ + return { + 'messages': [message_view(m) for m in model.message.get_messages()], + } + + @verify_not_prod + @nickname('createMessages') + @validate_json_request('CreateMessage') + @require_scope(scopes.SUPERUSER) + def post(self): + """ Create a message """ + if SuperUserPermission().can(): + model.message.create([request.get_json()['message']]) + return make_response('', 201) + abort(403) + + +def message_view(message): + return {'id': message.id, 'content': message.content} diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 26fb1b5cf..bd482b37e 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -567,6 +567,33 @@ a:focus { margin-right: 4px; } +.config-license-field-element textarea { + padding: 10px; + margin-bottom: 10px; + height: 250px; +} + +.config-license-field-element .license-status { + margin-bottom: 26px; +} + +.config-license-field-element table td:first-child { + width: 150px; + font-weight: bold; +} + +.config-license-field-element .fa { + margin-right: 6px; +} + +.config-license-field-element .license-valid h4 { + color: #2FC98E; +} + +.config-license-field-element .license-invalid h4 { + color: #D64456; +} + .co-checkbox { position: relative; } diff --git a/static/directives/config/config-license-field.html b/static/directives/config/config-license-field.html new file mode 100644 index 000000000..9eb688156 --- /dev/null +++ b/static/directives/config/config-license-field.html @@ -0,0 +1,40 @@ +
+ + + +
+ +
+

License Valid

+ + + +
Product:{{ licenseDecoded.publicProductName || licenseDecoded.productName }}
Plan:{{ licenseDecoded.publicPlanName || licenseDecoded.planName }}
+
+ +
+

Validation Failed

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

+ Your license can be found under the "Raw Format" tab of your Quay Enterprise + subscription in the Tectonic Account. +

+ + + + + +
+ Validating License +
+
+
\ No newline at end of file diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index c86187488..d810ad1a7 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -3,6 +3,16 @@
+ +
+
+ License +
+
+
+
+
+
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index c47ba2f77..ccc7317af 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -1246,5 +1246,69 @@ angular.module("core-config-setup", ['angularFileUpload']) } }; return directiveDefinitionObject; + }) + + .directive('configLicenseField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/config/config-license-field.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, $element, ApiService) { + $scope.state = 'loading-license'; + $scope.showingEditor = false; + $scope.requiredBox = ''; + + 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 = ''; + }); + }; + + loadLicense(); + + $scope.showEditor = function($event) { + $event.preventDefault(); + $event.stopPropagation(); + + $scope.showingEditor = true; + }; + + $scope.validateAndUpdate = function($event) { + $event.preventDefault(); + $event.stopPropagation(); + + $scope.state = 'validating-license'; + + 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 = ''; + }); + }; + } + }; + return directiveDefinitionObject; }); diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js index 0b5eecad7..3759a9bf2 100644 --- a/static/js/pages/setup.js +++ b/static/js/pages/setup.js @@ -107,7 +107,7 @@ 'hasDatabaseSSLCert': false, 'licenseContents': '', 'licenseError': null, - 'licenseDecoded': null, + 'licenseDecoded': null }; $scope.$watch('currentStep', function(currentStep) { diff --git a/test/test_api_security.py b/test/test_api_security.py index 426e7ea20..c31e771af 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -51,8 +51,7 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana SuperUserOrganizationManagement, SuperUserOrganizationList, SuperUserAggregateLogs, SuperUserServiceKeyManagement, SuperUserServiceKey, SuperUserServiceKeyApproval, - SuperUserTakeOwnership,) -from endpoints.api.globalmessages import (GlobalUserMessage, GlobalUserMessages,) + SuperUserTakeOwnership, SuperUserMessages, SuperUserLicense) from endpoints.api.secscan import RepositoryImageSecurity from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel @@ -4158,6 +4157,37 @@ class TestSuperUserList(ApiTestCase): self._run_test('GET', 200, 'devtable', None) +class TestSuperUserLicense(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(SuperUserLicense) + + def test_get_anonymous(self): + self._run_test('GET', 401, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 403, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 403, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 400, 'devtable', None) + + + def test_put_anonymous(self): + self._run_test('PUT', 401, None, {}) + + def test_put_freshuser(self): + self._run_test('PUT', 403, 'freshuser', {'license': ''}) + + def test_put_reader(self): + self._run_test('PUT', 403, 'reader', {'license': ''}) + + def test_put_devtable(self): + self._run_test('PUT', 400, 'devtable', {'license': ''}) + + class TestSuperUserManagement(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) diff --git a/test/test_license.py b/test/test_license.py index 802b036b7..9dc920d61 100644 --- a/test/test_license.py +++ b/test/test_license.py @@ -111,18 +111,19 @@ class TestLicense(unittest.TestCase): self.assertFalse(license.is_expired) def test_validate_basic_license(self): - decoded = self.get_license(timedelta(days=30), entitlements={}) + 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), entitlements={ + decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={ 'software.quay.regions': 2, }) decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]}) def test_validate_storage_entitlement_invalid(self): - decoded = self.get_license(timedelta(days=30), entitlements={ + decoded = self.get_license(timedelta(days=30), service_end=timedelta(days=40), entitlements={ 'software.quay.regions': 1, }) diff --git a/util/config/provider/baseprovider.py b/util/config/provider/baseprovider.py index ad7768de9..f9a48c67a 100644 --- a/util/config/provider/baseprovider.py +++ b/util/config/provider/baseprovider.py @@ -102,22 +102,22 @@ class BaseProvider(object): def _get_license_file(self): """ 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) + 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) - def validate_license(self, config): - """ Validates that the configuration matches the license file (if any). """ - if not config.get('SETUP_COMPLETE', False): - raise SetupIncompleteException() - + def get_license(self): + """ Returns the decoded license, if any. """ with self._get_license_file() as f: license_file_contents = f.read() - self.license = decode_license(license_file_contents) - self.license.validate(config) + return decode_license(license_file_contents) def save_license(self, license_file_contents): """ Saves the given contents as the license file. """ diff --git a/util/config/provider/testprovider.py b/util/config/provider/testprovider.py index a1bce6d30..d08b13049 100644 --- a/util/config/provider/testprovider.py +++ b/util/config/provider/testprovider.py @@ -1,5 +1,5 @@ import json -from StringIO import StringIO +import io from util.config.provider.baseprovider import BaseProvider @@ -53,7 +53,7 @@ class TestConfigProvider(BaseProvider): if filename in REAL_FILES: return open(filename, mode=mode) - return StringIO(self.files[filename]) + return io.BytesIO(self.files[filename]) def requires_restart(self, app_config): return False diff --git a/util/license.py b/util/license.py index 85a77ff3c..1fd9f9fb5 100644 --- a/util/license.py +++ b/util/license.py @@ -43,11 +43,26 @@ class LicenseValidationError(LicenseError): def _get_date(decoded, field): """ Retrieves the encoded date found at the given field under the decoded license block. """ date_str = decoded.get(field) - if date_str: - return parser.parse(date_str).replace(tzinfo=None) + return parser.parse(date_str).replace(tzinfo=None) if date_str else None - return datetime.now() - timedelta(days=2) +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) + + def check_expired(self, cutoff_date=None): + return self.expiration_and_grace <= (cutoff_date or datetime.now()) + + @property + def expiration_and_grace(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()) class License(object): """ License represents a fully decoded and validated (but potentially expired) license. """ @@ -65,7 +80,8 @@ class License(object): @property def is_expired(self): - return self._get_expired(datetime.now()) + cutoff_date = datetime.now() + return bool([dt for dt in self._get_expiration_dates() if dt.check_expired(cutoff_date)]) def validate(self, config): """ Validates the license and all its entitlements against the given config. """ @@ -81,47 +97,54 @@ class License(object): max_regions) raise LicenseValidationError(msg) - def _get_expired(self, compare_date): + def _get_expiration_dates(self): # Check if the license overall has expired. expiration_date = _get_date(self.decoded, 'expirationDate') - if expiration_date <= compare_date: - logger.debug('License expired on %s', expiration_date) - return True + 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: - return True + 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') - logger.debug('Trial-only license expires on %s', trial_end_date) - return trial_end_date <= (compare_date - TRIAL_GRACE_PERIOD) + 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) - logger.debug('In-trial license expires on %s', service_end_date) - return service_end_date <= (compare_date - TRIAL_GRACE_PERIOD) + 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": - logger.debug('Monthly license expires on %s', service_end_date) - return service_end_date <= (compare_date - MONTHLY_GRACE_PERIOD) + yield LicenseExpirationDate('Monthly subscription', service_end_date, MONTHLY_GRACE_PERIOD) if duration_period == "years": - logger.debug('Yearly license expires on %s', service_end_date) - return service_end_date <= (compare_date - YEARLY_GRACE_PERIOD) + yield LicenseExpirationDate('Yearly subscription', service_end_date, YEARLY_GRACE_PERIOD) - return True _PROD_LICENSE_PUBLIC_KEY_DATA = """