diff --git a/config.py b/config.py index 9958f6c12..c4d59bba0 100644 --- a/config.py +++ b/config.py @@ -304,3 +304,7 @@ class DefaultConfig(object): # The timeout for service key approval. UNAPPROVED_SERVICE_KEY_TTL_SEC = 60 * 60 * 24 # One day + + # How long to wait before GCing an expired service key. + EXPIRED_SERVICE_KEY_TTL_SEC = 60 * 60 * 24 * 7 # One week + diff --git a/data/model/notification.py b/data/model/notification.py index 53e2eed78..194e2975b 100644 --- a/data/model/notification.py +++ b/data/model/notification.py @@ -79,7 +79,7 @@ def list_notifications(user, kind_name=None, id_filter=None, include_dismissed=F def delete_all_notifications_by_path_prefix(prefix): (Notification .delete() - .where(Notification.lookup_path % prefix + '%') + .where(Notification.lookup_path ** (prefix + '%')) .execute()) diff --git a/data/model/service_keys.py b/data/model/service_keys.py index 2b6b879e6..8face6aa6 100644 --- a/data/model/service_keys.py +++ b/data/model/service_keys.py @@ -15,6 +15,11 @@ def _expired_keys_clause(service): return ((ServiceKey.service == service) & (ServiceKey.expiration_date <= datetime.utcnow())) +def _stale_expired_keys_clause(service): + expired_ttl = timedelta(seconds=config.app_config['EXPIRED_SERVICE_KEY_TTL_SEC']) + return ((ServiceKey.service == service) & + (ServiceKey.expiration_date <= (datetime.utcnow() - expired_ttl))) + def _stale_unapproved_keys_clause(service): unapproved_ttl = timedelta(seconds=config.app_config['UNAPPROVED_SERVICE_KEY_TTL_SEC']) @@ -24,7 +29,7 @@ def _stale_unapproved_keys_clause(service): def _gc_expired(service): - ServiceKey.delete().where(_expired_keys_clause(service) | + ServiceKey.delete().where(_stale_expired_keys_clause(service) | _stale_unapproved_keys_clause(service)).execute() @@ -53,17 +58,18 @@ def create_service_key(name, kid, service, jwk, metadata, expiration_date, rotat _notify_superusers(key) _gc_expired(service) - return key -def generate_service_key(service, expiration_date, kid=None, name='', metadata=None): +def generate_service_key(service, expiration_date, kid=None, name='', metadata=None, + rotation_duration=None): private_key = RSA.generate(2048) jwk = RSAKey(key=private_key.publickey()).serialize() if kid is None: kid = canonical_kid(jwk) - key = create_service_key(name, kid, service, jwk, metadata or {}, expiration_date) + key = create_service_key(name, kid, service, jwk, metadata or {}, expiration_date, + rotation_duration=rotation_duration) return (private_key, key) diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index cf37fc88a..4eb614eeb 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -484,6 +484,7 @@ def key_view(key): 'metadata': key.metadata, 'created_date': key.created_date, 'expiration_date': key.expiration_date, + 'rotation_duration': key.rotation_duration, 'approval': approval_view(key.approval) if key.approval is not None else None, } @@ -562,6 +563,9 @@ class SuperUserServiceKeyManagement(ApiResource): except ValueError: abort(400) + if expiration_date <= datetime.now(): + abort(400) + # Create the metadata for the key. user = get_authenticated_user() metadata = body.get('metadata', {}) @@ -572,8 +576,8 @@ class SuperUserServiceKeyManagement(ApiResource): }) # Generate a key with a private key that we *never save*. - (private_key, key) = model.service_keys.generate_service_key(body['service'], metadata, - expiration_date, + (private_key, key) = model.service_keys.generate_service_key(body['service'], expiration_date, + metadata=metadata, name=body.get('name', '')) # Auto-approve the service key. model.service_keys.approve_service_key(key.kid, user, ServiceKeyApprovalType.SUPERUSER, @@ -670,6 +674,9 @@ class SuperUserServiceKey(ApiResource): except ValueError: abort(400) + if expiration_date <= datetime.now(): + abort(400) + key_log_metadata.update({ 'old_expiration_date': key.expiration_date, 'expiration_date': expiration_date, diff --git a/initdb.py b/initdb.py index c629b2d30..984cf972a 100644 --- a/initdb.py +++ b/initdb.py @@ -156,9 +156,10 @@ def __create_subtree(with_storage, repo, structure, creator_username, parent, ta def __generate_service_key(kid, name, user, timestamp, approval_type, expiration=None, - metadata=None, service='sample_service'): + metadata=None, service='sample_service', rotation_duration=None): _, key = model.service_keys.generate_service_key(service, expiration, kid=kid, - name=name, metadata=metadata) + name=name, metadata=metadata, + rotation_duration=rotation_duration) if approval_type is not None: model.service_keys.approve_service_key(key.kid, user, approval_type, @@ -660,7 +661,8 @@ def populate_database(minimal=False, with_storage=False): __generate_service_key('kid4', 'autorotatingkey', new_user_1, six_ago, ServiceKeyApprovalType.KEY_ROTATION, today + timedelta(1), - dict(rotation_ttl=timedelta(hours=12).total_seconds())) + rotation_duration=timedelta(hours=12).total_seconds()) + __generate_service_key('kid5', 'key for another service', new_user_1, today, ServiceKeyApprovalType.SUPERUSER, today + timedelta(14), service='different_sample_service') diff --git a/static/directives/service-keys-manager.html b/static/directives/service-keys-manager.html index 4815725ff..1b7b1d5ba 100644 --- a/static/directives/service-keys-manager.html +++ b/static/directives/service-keys-manager.html @@ -64,11 +64,11 @@ - + - Automatically rotated + Automatically rotated - + diff --git a/static/js/directives/ui/datetime-picker.js b/static/js/directives/ui/datetime-picker.js index 5c3822f49..7f5e2c8d9 100644 --- a/static/js/directives/ui/datetime-picker.js +++ b/static/js/directives/ui/datetime-picker.js @@ -18,7 +18,8 @@ angular.module('quay').directive('datetimePicker', function () { $element.find('input').datetimepicker({ 'format': 'LLL', 'sideBySide': true, - 'showClear': true + 'showClear': true, + 'minDate': new Date() }); $element.find('input').on("dp.change", function (e) { diff --git a/static/js/directives/ui/service-keys-manager.js b/static/js/directives/ui/service-keys-manager.js index d71a862ed..da5190f17 100644 --- a/static/js/directives/ui/service-keys-manager.js +++ b/static/js/directives/ui/service-keys-manager.js @@ -32,7 +32,9 @@ angular.module('quay').directive('serviceKeysManager', function () { var keys = $scope.keys.map(function(key) { var expiration_datetime = -Number.MAX_VALUE; - if (key.expiration_date) { + if (key.rotation_duration) { + expiration_datetime = -(Number.MAX_VALUE/2); + } else if (key.expiration_date) { expiration_datetime = new Date(key.expiration_date).valueOf() * (-1); } @@ -66,15 +68,19 @@ angular.module('quay').directive('serviceKeysManager', function () { key.expanded = !key.expanded; }; + $scope.getRotationDate = function(key) { + return moment(key.created_date).add(key.rotation_duration, 's').format('LLL'); + }; + $scope.getExpirationInfo = function(key) { if (!key.expiration_date) { return ''; } - if (key.metadata.rotation_ttl) { - var rotate_date = moment(key.created_date).add(key.metadata.rotation_ttl, 's') + if (key.rotation_duration) { + var rotate_date = moment(key.created_date).add(key.rotation_duration, 's') if (moment().isBefore(rotate_date)) { - return {'className': 'rotation', 'icon': 'fa-refresh', 'rotateDate': rotate_date}; + return {'className': 'rotation', 'icon': 'fa-refresh', 'willRotate': true}; } } diff --git a/static/js/services/notification-service.js b/static/js/services/notification-service.js index 17f0262d5..875fed7dd 100644 --- a/static/js/services/notification-service.js +++ b/static/js/services/notification-service.js @@ -131,6 +131,42 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P return '/repository/' + metadata.repository + '?tab=tags'; }, 'dismissable': true + }, + 'service_key_submitted': { + 'level': 'primary', + 'message': 'Service key {kid} for service {service} requests approval

Key was created on {created_date}', + 'actions': [ + { + 'title': 'Approve Key', + 'kind': 'primary', + 'handler': function(notification) { + var params = { + 'kid': notification.metadata.kid + }; + + ApiService.approveServiceKey({}, params).then(function(resp) { + notificationService.update(); + window.location = '/superuser/?tab=servicekeys'; + }, ApiService.errorDisplay('Could not approve service key')); + } + }, + { + 'title': 'Delete Key', + 'kind': 'default', + 'handler': function(notification) { + var params = { + 'kid': notification.metadata.kid + }; + + ApiService.deleteServiceKey(null, params).then(function(resp) { + notificationService.update(); + }, ApiService.errorDisplay('Could not delete service key')); + } + } + ], + 'page': function(metadata) { + return '/superuser/?tab=servicekeys'; + }, } }; diff --git a/static/js/services/string-builder-service.js b/static/js/services/string-builder-service.js index 7cec9f10e..87fe2cd66 100644 --- a/static/js/services/string-builder-service.js +++ b/static/js/services/string-builder-service.js @@ -44,6 +44,10 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f return metadata.kid.substr(0, 12); }, + 'created_date': function(value) { + return moment.unix(value).format('LLL'); + }, + 'expiration_date': function(value) { return moment.unix(value).format('LLL'); }, diff --git a/test/data/test.db b/test/data/test.db index 17eeccc8b..064c75d43 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 90650dc46..0579cbec0 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -3567,7 +3567,7 @@ class TestSuperUserKeyManagement(ApiTestCase): existing_modify = model.log.LogEntry.select().where(LogEntry.kind == kind).count() json = self.getJsonResponse(SuperUserServiceKeyManagement) - self.assertEquals(4, len(json['keys'])) + key_count = len(json['keys']) key = json['keys'][0] self.assertTrue('name' in key) @@ -3622,7 +3622,7 @@ class TestSuperUserKeyManagement(ApiTestCase): self.getResponse(SuperUserServiceKey, params=dict(kid=key['kid']), expected_code=404) json = self.getJsonResponse(SuperUserServiceKeyManagement) - self.assertEquals(3, len(json['keys'])) + self.assertEquals(key_count - 1, len(json['keys'])) # Ensure a log was added for the deletion. kind = LogEntryKind.get(LogEntryKind.name == 'service_key_delete')