Add superuser config section for updating license

This commit is contained in:
Joseph Schorr 2016-10-11 15:16:28 -04:00
parent 5fee4d6d19
commit ee96693252
11 changed files with 370 additions and 34 deletions

View file

@ -18,11 +18,12 @@ from auth.permissions import SuperUserPermission
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, from endpoints.api import (ApiResource, nickname, resource, validate_json_request,
internal_only, require_scope, show_if, parse_args, internal_only, require_scope, show_if, parse_args,
query_param, abort, require_fresh_login, path_param, verify_not_prod, 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 endpoints.api.logs import get_logs, get_aggregate_logs
from data import model from data import model
from data.database import ServiceKeyApprovalType from data.database import ServiceKeyApprovalType
from util.useremails import send_confirmation_email, send_recovery_email from util.useremails import send_confirmation_email, send_recovery_email
from util.license import decode_license, LicenseError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -819,3 +820,143 @@ class SuperUserServiceKeyApproval(ApiResource):
return make_response('', 201) return make_response('', 201)
abort(403) 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}

View file

@ -567,6 +567,33 @@ a:focus {
margin-right: 4px; 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 { .co-checkbox {
position: relative; position: relative;
} }

View file

@ -0,0 +1,40 @@
<div class="config-license-field-element">
<!-- Note: This hidden input will only have a value if there is a valid license, ensuring that the user cannot save
config if the license is invalid (since this box will be empty and therefore "required") -->
<input type="text" name="licenseRequiredBox" ng-model="requiredBox" style="visibility: hidden; height: 1px; position: absolute;" required>
<div class="cor-loader-inline" ng-show="state == 'loading-license'"></div>
<div class="license-valid license-status" ng-show="state == 'license-valid'">
<h4><i class="fa fa-check-circle"></i>License Valid</h4>
<table class="co-table">
<tr><td>Product:</td><td>{{ licenseDecoded.publicProductName || licenseDecoded.productName }}</td></tr>
<tr><td>Plan:</td><td>{{ licenseDecoded.publicPlanName || licenseDecoded.planName }}</td></tr>
</table>
</div>
<div class="license-invalid license-status" ng-show="state == 'license-error'">
<h4><i class="fa fa-times-circle"></i> Validation Failed</h4>
<h5>{{ licenseError }}</h5>
</div>
<button class="btn btn-default" ng-show="!showingEditor" ng-click="showEditor($event)"><i class="fa fa-pencil"></i> Update License</button>
<div class="license-editor" ng-show="showingEditor">
<p>
Your license can be found under the "Raw Format" tab of your Quay Enterprise
subscription in the <a href="https://account.tectonic.com" target="_blank">Tectonic Account</a>.
</p>
<textarea id="enterLicenseBox" ng-model="licenseContents" class="form-control"
placeholder="Paste your raw license here, which should already be in base64 format: GtqMjMwNDgyM3Vq..."
ng-readonly="state == 'validating-license'"></textarea>
<button class="btn btn-primary" ng-show="state != 'validating-license'"
ng-click="validateAndUpdate($event)" ng-disabled="!licenseContents">Update License</button>
<div class="license-validating" ng-show="state == 'validating-license'">
<span class="cor-loader-inline"></span> Validating License
</div>
</div>
</div>

View file

@ -3,6 +3,16 @@
<div ng-show="config && config['SUPER_USERS']"> <div ng-show="config && config['SUPER_USERS']">
<form id="configform" name="configform"> <form id="configform" name="configform">
<!-- License -->
<div class="co-panel">
<div class="co-panel-heading">
<i class="fa fa-credit-card-alt"></i> License
</div>
<div class="co-panel-body">
<div class="config-license-field"></div>
</div>
</div>
<!-- Basic Configuration --> <!-- Basic Configuration -->
<div class="co-panel"> <div class="co-panel">
<div class="co-panel-heading"> <div class="co-panel-heading">

View file

@ -1246,5 +1246,69 @@ angular.module("core-config-setup", ['angularFileUpload'])
} }
}; };
return directiveDefinitionObject; 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;
}); });

View file

@ -107,7 +107,7 @@
'hasDatabaseSSLCert': false, 'hasDatabaseSSLCert': false,
'licenseContents': '', 'licenseContents': '',
'licenseError': null, 'licenseError': null,
'licenseDecoded': null, 'licenseDecoded': null
}; };
$scope.$watch('currentStep', function(currentStep) { $scope.$watch('currentStep', function(currentStep) {

View file

@ -51,8 +51,7 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
SuperUserOrganizationManagement, SuperUserOrganizationList, SuperUserOrganizationManagement, SuperUserOrganizationList,
SuperUserAggregateLogs, SuperUserServiceKeyManagement, SuperUserAggregateLogs, SuperUserServiceKeyManagement,
SuperUserServiceKey, SuperUserServiceKeyApproval, SuperUserServiceKey, SuperUserServiceKeyApproval,
SuperUserTakeOwnership,) SuperUserTakeOwnership, SuperUserMessages, SuperUserLicense)
from endpoints.api.globalmessages import (GlobalUserMessage, GlobalUserMessages,)
from endpoints.api.secscan import RepositoryImageSecurity from endpoints.api.secscan import RepositoryImageSecurity
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
@ -4158,6 +4157,37 @@ class TestSuperUserList(ApiTestCase):
self._run_test('GET', 200, 'devtable', None) 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): class TestSuperUserManagement(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)

View file

@ -111,18 +111,19 @@ class TestLicense(unittest.TestCase):
self.assertFalse(license.is_expired) self.assertFalse(license.is_expired)
def test_validate_basic_license(self): 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': [{}]}) decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
def test_validate_storage_entitlement_valid(self): 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, 'software.quay.regions': 2,
}) })
decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]}) decoded.validate({'DISTRIBUTED_STORAGE_CONFIG': [{}]})
def test_validate_storage_entitlement_invalid(self): 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, 'software.quay.regions': 1,
}) })

View file

@ -102,22 +102,22 @@ class BaseProvider(object):
def _get_license_file(self): def _get_license_file(self):
""" Returns the contents of the license file. """ """ 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: try:
return self.get_volume_file(LICENSE_FILENAME) return self.get_volume_file(LICENSE_FILENAME)
except IOError: except IOError:
msg = 'Could not open license file. Please make sure it is in your config volume.' msg = 'Could not open license file. Please make sure it is in your config volume.'
raise LicenseError(msg) raise LicenseError(msg)
def validate_license(self, config): def get_license(self):
""" Validates that the configuration matches the license file (if any). """ """ Returns the decoded license, if any. """
if not config.get('SETUP_COMPLETE', False):
raise SetupIncompleteException()
with self._get_license_file() as f: with self._get_license_file() as f:
license_file_contents = f.read() license_file_contents = f.read()
self.license = decode_license(license_file_contents) return decode_license(license_file_contents)
self.license.validate(config)
def save_license(self, license_file_contents): def save_license(self, license_file_contents):
""" Saves the given contents as the license file. """ """ Saves the given contents as the license file. """

View file

@ -1,5 +1,5 @@
import json import json
from StringIO import StringIO import io
from util.config.provider.baseprovider import BaseProvider from util.config.provider.baseprovider import BaseProvider
@ -53,7 +53,7 @@ class TestConfigProvider(BaseProvider):
if filename in REAL_FILES: if filename in REAL_FILES:
return open(filename, mode=mode) return open(filename, mode=mode)
return StringIO(self.files[filename]) return io.BytesIO(self.files[filename])
def requires_restart(self, app_config): def requires_restart(self, app_config):
return False return False

View file

@ -43,11 +43,26 @@ class LicenseValidationError(LicenseError):
def _get_date(decoded, field): def _get_date(decoded, field):
""" Retrieves the encoded date found at the given field under the decoded license block. """ """ Retrieves the encoded date found at the given field under the decoded license block. """
date_str = decoded.get(field) date_str = decoded.get(field)
if date_str: return parser.parse(date_str).replace(tzinfo=None) if date_str else None
return parser.parse(date_str).replace(tzinfo=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): class License(object):
""" License represents a fully decoded and validated (but potentially expired) license. """ """ License represents a fully decoded and validated (but potentially expired) license. """
@ -65,7 +80,8 @@ class License(object):
@property @property
def is_expired(self): 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): def validate(self, config):
""" Validates the license and all its entitlements against the given config. """ """ Validates the license and all its entitlements against the given config. """
@ -81,47 +97,54 @@ class License(object):
max_regions) max_regions)
raise LicenseValidationError(msg) raise LicenseValidationError(msg)
def _get_expired(self, compare_date): def _get_expiration_dates(self):
# Check if the license overall has expired. # Check if the license overall has expired.
expiration_date = _get_date(self.decoded, 'expirationDate') expiration_date = _get_date(self.decoded, 'expirationDate')
if expiration_date <= compare_date: if expiration_date is None:
logger.debug('License expired on %s', expiration_date) yield LicenseExpirationDate('No valid Tectonic Account License', datetime.min)
return True return
yield LicenseExpirationDate('Tectonic Account License', expiration_date)
# Check for any QE subscriptions. # Check for any QE subscriptions.
sub = self.subscription sub = self.subscription
if sub is None: if sub is None:
return True yield LicenseExpirationDate('No Quay Enterprise Subscription', datetime.min)
return
# Check for a trial-only license. # Check for a trial-only license.
if sub.get('trialOnly', False): if sub.get('trialOnly', False):
trial_end_date = _get_date(sub, 'trialEnd') trial_end_date = _get_date(sub, 'trialEnd')
logger.debug('Trial-only license expires on %s', trial_end_date) if trial_end_date is None:
return trial_end_date <= (compare_date - TRIAL_GRACE_PERIOD) 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. # Check for a normal license that is in trial.
service_end_date = _get_date(sub, 'serviceEnd') 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 sub.get('inTrial', False):
# If the subscription is in a trial, but not a trial only # If the subscription is in a trial, but not a trial only
# subscription, give 7 days after trial end to update license # 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 # 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) # might auto convert, so we could assume it will auto-renew)
logger.debug('In-trial license expires on %s', service_end_date) yield LicenseExpirationDate('In-trial subscription', service_end_date, TRIAL_GRACE_PERIOD)
return service_end_date <= (compare_date - TRIAL_GRACE_PERIOD)
# Otherwise, check the service expiration. # Otherwise, check the service expiration.
duration_period = sub.get('durationPeriod', 'months') duration_period = sub.get('durationPeriod', 'months')
# If the subscription is monthly, give 3 months grace period # If the subscription is monthly, give 3 months grace period
if duration_period == "months": if duration_period == "months":
logger.debug('Monthly license expires on %s', service_end_date) yield LicenseExpirationDate('Monthly subscription', service_end_date, MONTHLY_GRACE_PERIOD)
return service_end_date <= (compare_date - MONTHLY_GRACE_PERIOD)
if duration_period == "years": if duration_period == "years":
logger.debug('Yearly license expires on %s', service_end_date) yield LicenseExpirationDate('Yearly subscription', service_end_date, YEARLY_GRACE_PERIOD)
return service_end_date <= (compare_date - YEARLY_GRACE_PERIOD)
return True
_PROD_LICENSE_PUBLIC_KEY_DATA = """ _PROD_LICENSE_PUBLIC_KEY_DATA = """