refactor approval service key to not need approver
This commit is contained in:
parent
7edf679670
commit
cc9bedbeb9
11 changed files with 331 additions and 13 deletions
|
@ -155,7 +155,7 @@ def test_mixing_keys_e2e(initialized_db):
|
|||
|
||||
# Approve the key and try again.
|
||||
admin_user = model.user.get_user('devtable')
|
||||
model.service_keys.approve_service_key(key.kid, admin_user, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER, approver=admin_user)
|
||||
|
||||
valid_token = _token(token_data, key_id='newkey', private_key=private_key)
|
||||
|
||||
|
|
|
@ -3,15 +3,17 @@ import pathvalidate
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
from flask import request, jsonify
|
||||
from flask import request, jsonify, make_response
|
||||
|
||||
from data.database import ServiceKeyApprovalType
|
||||
from data.model import ServiceKeyDoesNotExist
|
||||
from util.config.validator import EXTRA_CA_DIRECTORY
|
||||
|
||||
from config_app.config_endpoints.exception import InvalidRequest
|
||||
from config_app.config_endpoints.api import resource, ApiResource, nickname
|
||||
from config_app.config_endpoints.api.superuser_models_pre_oci import pre_oci_model
|
||||
from config_app.config_util.ssl import load_certificate, CertInvalidException
|
||||
from config_app.c_app import config_provider, INIT_SCRIPTS_LOCATION
|
||||
from config_app.c_app import app, config_provider, INIT_SCRIPTS_LOCATION
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -146,4 +148,44 @@ class SuperUserServiceKeyManagement(ApiResource):
|
|||
'keys': [key.to_dict() for key in keys],
|
||||
})
|
||||
|
||||
@resource('/v1/superuser/approvedkeys/<kid>')
|
||||
class SuperUserServiceKeyApproval(ApiResource):
|
||||
""" Resource for approving service keys. """
|
||||
|
||||
schemas = {
|
||||
'ApproveServiceKey': {
|
||||
'id': 'ApproveServiceKey',
|
||||
'type': 'object',
|
||||
'description': 'Information for approving service keys',
|
||||
'properties': {
|
||||
'notes': {
|
||||
'type': 'string',
|
||||
'description': 'Optional approval notes',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@nickname('approveServiceKey')
|
||||
@validate_json_request('ApproveServiceKey')
|
||||
def post(self, kid):
|
||||
notes = request.get_json().get('notes', '')
|
||||
approver = app.config.get('SUPER_USERS', [])[0] # get the first superuser created in the config tool
|
||||
try:
|
||||
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
|
||||
|
||||
# Log the approval of the service key.
|
||||
key_log_metadata = {
|
||||
'kid': kid,
|
||||
'service': key.service,
|
||||
'name': key.name,
|
||||
'expiration_date': key.expiration_date,
|
||||
}
|
||||
|
||||
log_action('service_key_approve', None, key_log_metadata)
|
||||
except ServiceKeyDoesNotExist:
|
||||
raise NotFound()
|
||||
except ServiceKeyAlreadyApproved:
|
||||
pass
|
||||
|
||||
return make_response('', 201)
|
||||
|
|
|
@ -17,6 +17,13 @@ def _create_key(key):
|
|||
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
|
||||
key.rotation_duration, approval)
|
||||
|
||||
class ServiceKeyDoesNotExist(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceKeyAlreadyApproved(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PreOCIModel(SuperuserDataInterface):
|
||||
"""
|
||||
|
@ -27,5 +34,14 @@ class PreOCIModel(SuperuserDataInterface):
|
|||
keys = model.service_keys.list_all_keys()
|
||||
return [_create_key(key) for key in keys]
|
||||
|
||||
def approve_service_key(self, kid, approval_type, notes=''):
|
||||
try:
|
||||
key = model.service_keys.approve_service_key(kid, approval_type, notes=notes)
|
||||
return _create_key(key)
|
||||
except model.ServiceKeyDoesNotExist:
|
||||
raise ServiceKeyDoesNotExist
|
||||
except model.ServiceKeyAlreadyApproved:
|
||||
raise ServiceKeyAlreadyApproved
|
||||
|
||||
|
||||
pre_oci_model = PreOCIModel()
|
||||
|
|
|
@ -46,6 +46,7 @@ export class KubeDeployModalComponent {
|
|||
this.errorMessage = `Could cycle the deployments with the new configuration. Error: ${err.toString()}`;
|
||||
})
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
this.state = 'error';
|
||||
this.errorMessage = `Could not deploy the configuration. Error: ${err.toString()}`;
|
||||
})
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
<div class="request-service-key-dialog-element">
|
||||
<!-- Modal message dialog -->
|
||||
<div class="co-dialog modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" ng-show="!working">×</button>
|
||||
<h4 class="modal-title">Create key for service {{ requestKeyInfo.service }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show="working">
|
||||
<div class="cor-loader"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-show="!working">
|
||||
<!-- Step 0 -->
|
||||
<div ng-show="step == 0">
|
||||
<table class="co-option-table">
|
||||
<tr>
|
||||
<td><input type="radio" id="automaticKey" ng-model="requestKind" value="automatic"></td>
|
||||
<td>
|
||||
<label for="automaticKey">Have the service provide a key</label>
|
||||
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the single instance is setup now.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="radio" id="presharedKey" ng-model="requestKind" value="preshared"></td>
|
||||
<td>
|
||||
<label for="presharedKey">Generate shared key</label>
|
||||
<div class="help-text">Recommended for <code>{{ requestKeyInfo.service }}</code> installations where the instances are dynamically started.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 (automatic) -->
|
||||
<div ng-show="step == 1 && requestKind == 'automatic'" style="text-align: center">
|
||||
<div style="margin-top: 20px;">
|
||||
Please start the <code>{{ requestKeyInfo.service }}</code> service now, configured for <a href="https://github.com/coreos/jwtproxy#autogenerated-private-key" ng-safenewtab>autogenerated private key</a>. The key approval process will continue automatically once the service connects to Quay.
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
Waiting for service to connect
|
||||
</div>
|
||||
<div style="margin-top: 10px; margin-bottom: 20px;">
|
||||
<div class="cor-loader-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 (automatic) -->
|
||||
<div ng-show="step == 2 && requestKind == 'automatic'" style="text-align: center">
|
||||
A key for service <code>{{ requestKeyInfo.service }}</code> has been automatically generated, approved and saved in the service's keystore.
|
||||
</div>
|
||||
|
||||
<!-- Step 1 (generate) -->
|
||||
<div ng-show="step == 1 && requestKind == 'preshared'">
|
||||
<form name="createForm" ng-submit="createPresharedKey()">
|
||||
<table class="co-form-table">
|
||||
<tr>
|
||||
<td><label for="create-key-name">Key Name:</label></td>
|
||||
<td>
|
||||
<input class="form-control" name="create-key-name" type="text" ng-model="preshared.name" placeholder="Friendly Key Name">
|
||||
<span class="co-help-text">
|
||||
A friendly name for the key for later reference.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="create-key-expiration">Expiration date (optional):</label></td>
|
||||
<td>
|
||||
<span class="datetime-picker" datetime="preshared.expiration"></span>
|
||||
<span class="co-help-text">
|
||||
The date and time that the key expires. If left blank, the key will never expire.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="create-key-notes">Approval Notes (optional):</label></td>
|
||||
<td>
|
||||
<markdown-input content="preshared.notes"
|
||||
can-write="true"
|
||||
(content-changed)="updateNotes($event.content)"
|
||||
field-title="notes"></markdown-input>
|
||||
<span class="co-help-text">
|
||||
Optional notes for additional human-readable information about why the key was created.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 (generate) -->
|
||||
<div ng-show="step == 2 && requestKind == 'preshared'">
|
||||
<div class="co-alert co-alert-warning">
|
||||
The following key has been generated for service <code>{{ requestKeyInfo.service }}</code>.
|
||||
<br><br>
|
||||
Please copy the key's ID and copy/download the key's private contents and place it in the directory with the service's configuration.
|
||||
<br><br>
|
||||
<strong>Once this dialog is closed this private key will not be accessible anywhere else!</strong>
|
||||
</div>
|
||||
|
||||
<label>Key ID:</label>
|
||||
<div class="copy-box" value="createdKey.kid"></div>
|
||||
|
||||
<label>Private Key (PEM):</label>
|
||||
<textarea class="key-display form-control" onclick="this.focus();this.select()" readonly>{{ createdKey.private_key }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" ng-show="!working">
|
||||
<button type="button" class="btn btn-primary" ng-show="step == 1 && requestKind == 'preshared'"
|
||||
ng-disabled="createForm.$invalid"
|
||||
ng-click="createPresharedKey()">
|
||||
Generate Key
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'preshared'"
|
||||
ng-click="showGenerate()">
|
||||
Continue
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-show="step == 0 && requestKind == 'automatic'"
|
||||
ng-click="startApproval()">
|
||||
Start Approval
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" ng-click="downloadPrivateKey(createdKey)" ng-if="createdKey && isDownloadSupported()">
|
||||
<i class="fa fa-download"></i> Download Private Key
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step == 2">Close</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" ng-show="step != 2">Cancel</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* An element which displays a dialog for requesting or creating a service key.
|
||||
*/
|
||||
angular.module('quay').directive('requestServiceKeyDialog', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/request-service-key-dialog.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'requestKeyInfo': '=requestKeyInfo',
|
||||
'keyCreated': '&keyCreated'
|
||||
},
|
||||
controller: function($scope, $element, $timeout, ApiService) {
|
||||
var handleNewKey = function(key) {
|
||||
var data = {
|
||||
'notes': 'Approved during setup of service ' + key.service
|
||||
};
|
||||
|
||||
var params = {
|
||||
'kid': key.kid
|
||||
};
|
||||
|
||||
ApiService.approveServiceKey(data, params).then(function(resp) {
|
||||
$scope.keyCreated({'key': key});
|
||||
$scope.step = 2;
|
||||
}, ApiService.errorDisplay('Could not approve service key'));
|
||||
};
|
||||
|
||||
var checkKeys = function() {
|
||||
var isShown = ($element.find('.modal').data('bs.modal') || {}).isShown;
|
||||
if (!isShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: filter by service.
|
||||
ApiService.listServiceKeys().then(function(resp) {
|
||||
var keys = resp['keys'];
|
||||
for (var i = 0; i < keys.length; ++i) {
|
||||
var key = keys[i];
|
||||
if (key.service == $scope.requestKeyInfo.service && !key.approval && key.rotation_duration) {
|
||||
handleNewKey(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$timeout(checkKeys, 1000);
|
||||
}, ApiService.errorDisplay('Could not list service keys'));
|
||||
};
|
||||
|
||||
$scope.show = function() {
|
||||
$scope.working = false;
|
||||
$scope.step = 0;
|
||||
$scope.requestKind = null;
|
||||
$scope.preshared = {
|
||||
'name': $scope.requestKeyInfo.service + ' Service Key',
|
||||
'notes': 'Created during setup for service `' + $scope.requestKeyInfo.service + '`'
|
||||
};
|
||||
|
||||
$element.find('.modal').modal({});
|
||||
};
|
||||
|
||||
$scope.hide = function() {
|
||||
$scope.loading = false;
|
||||
$element.find('.modal').modal('hide');
|
||||
};
|
||||
|
||||
$scope.showGenerate = function() {
|
||||
$scope.step = 1;
|
||||
};
|
||||
|
||||
$scope.startApproval = function() {
|
||||
$scope.step = 1;
|
||||
checkKeys();
|
||||
};
|
||||
|
||||
$scope.isDownloadSupported = function() {
|
||||
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
|
||||
if (isSafari) {
|
||||
// Doesn't work properly in Safari, sadly.
|
||||
return false;
|
||||
}
|
||||
|
||||
try { return !!new Blob(); } catch(e) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
$scope.downloadPrivateKey = function(key) {
|
||||
var blob = new Blob([key.private_key]);
|
||||
FileSaver.saveAs(blob, key.service + '.pem');
|
||||
};
|
||||
|
||||
$scope.createPresharedKey = function() {
|
||||
$scope.working = true;
|
||||
|
||||
var data = {
|
||||
'name': $scope.preshared.name,
|
||||
'service': $scope.requestKeyInfo.service,
|
||||
'expiration': $scope.preshared.expiration || null,
|
||||
'notes': $scope.preshared.notes
|
||||
};
|
||||
|
||||
ApiService.createServiceKey(data).then(function(resp) {
|
||||
$scope.working = false;
|
||||
$scope.step = 2;
|
||||
$scope.createdKey = resp;
|
||||
$scope.keyCreated({'key': resp});
|
||||
}, ApiService.errorDisplay('Could not create service key'));
|
||||
};
|
||||
|
||||
$scope.updateNotes = function(content) {
|
||||
$scope.preshared.notes = content;
|
||||
};
|
||||
|
||||
$scope.$watch('requestKeyInfo', function(info) {
|
||||
if (info && info.service) {
|
||||
$scope.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -145,7 +145,7 @@ def set_key_expiration(kid, expiration_date):
|
|||
service_key.save()
|
||||
|
||||
|
||||
def approve_service_key(kid, approver, approval_type, notes=''):
|
||||
def approve_service_key(kid, approval_type, approver=None, notes=''):
|
||||
try:
|
||||
with db_transaction():
|
||||
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get()
|
||||
|
|
|
@ -118,7 +118,7 @@ class PreOCIModel(SuperuserDataInterface):
|
|||
|
||||
def approve_service_key(self, kid, approver, approval_type, notes=''):
|
||||
try:
|
||||
key = model.service_keys.approve_service_key(kid, approver, approval_type, notes=notes)
|
||||
key = model.service_keys.approve_service_key(kid, approval_type, approver=approver, notes=notes)
|
||||
return _create_key(key)
|
||||
except model.ServiceKeyDoesNotExist:
|
||||
raise ServiceKeyDoesNotExist
|
||||
|
|
|
@ -161,8 +161,7 @@ def __generate_service_key(kid, name, user, timestamp, approval_type, expiration
|
|||
rotation_duration=rotation_duration)
|
||||
|
||||
if approval_type is not None:
|
||||
model.service_keys.approve_service_key(key.kid, user, approval_type,
|
||||
notes='The **test** approval')
|
||||
model.service_keys.approve_service_key(key.kid, approval_type, notes='The **test** approval')
|
||||
|
||||
key_metadata = {
|
||||
'kid': kid,
|
||||
|
@ -820,7 +819,7 @@ def populate_database(minimal=False, with_storage=False):
|
|||
key = model.service_keys.create_service_key('test_service_key', 'test_service_key', 'quay',
|
||||
_TEST_JWK, {}, None)
|
||||
|
||||
model.service_keys.approve_service_key(key.kid, new_user_1, ServiceKeyApprovalType.SUPERUSER,
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.SUPERUSER,
|
||||
notes='Test service key for local/test registry testing')
|
||||
|
||||
# Add an app specific token.
|
||||
|
|
|
@ -559,7 +559,7 @@ class KeyServerTestCase(EndpointTestCase):
|
|||
}, data=jwk, expected_code=403)
|
||||
|
||||
# Approve the key.
|
||||
model.service_keys.approve_service_key('kid420', 1, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key('kid420', ServiceKeyApprovalType.SUPERUSER, approver=1)
|
||||
|
||||
# Rotate that new key
|
||||
with assert_action_logged('service_key_rotate'):
|
||||
|
@ -598,7 +598,7 @@ class KeyServerTestCase(EndpointTestCase):
|
|||
def test_attempt_delete_service_key_with_expired_key(self):
|
||||
# Generate two keys, approving the first.
|
||||
private_key, _ = model.service_keys.generate_service_key('sample_service', None, kid='first')
|
||||
model.service_keys.approve_service_key('first', 1, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key('first', ServiceKeyApprovalType.SUPERUSER, approver=1)
|
||||
model.service_keys.generate_service_key('sample_service', None, kid='second')
|
||||
|
||||
# Mint a JWT with our test payload
|
||||
|
@ -661,7 +661,7 @@ class KeyServerTestCase(EndpointTestCase):
|
|||
expected_code=403, service='sample_service', kid='kid321')
|
||||
|
||||
# Approve the second key.
|
||||
model.service_keys.approve_service_key('kid123', 1, ServiceKeyApprovalType.SUPERUSER)
|
||||
model.service_keys.approve_service_key('kid123', ServiceKeyApprovalType.SUPERUSER, approver=1)
|
||||
|
||||
# Using the credentials of our approved key, delete our unapproved key
|
||||
with assert_action_logged('service_key_delete'):
|
||||
|
|
|
@ -18,8 +18,7 @@ def generate_key(service, name, expiration_date=None, notes=None):
|
|||
metadata=metadata,
|
||||
name=name)
|
||||
# Auto-approve the service key.
|
||||
model.service_keys.approve_service_key(key.kid, None, ServiceKeyApprovalType.AUTOMATIC,
|
||||
notes=notes or '')
|
||||
model.service_keys.approve_service_key(key.kid, ServiceKeyApprovalType.AUTOMATIC, notes=notes or '')
|
||||
|
||||
# Log the creation and auto-approval of the service key.
|
||||
key_log_metadata = {
|
||||
|
|
Reference in a new issue