diff --git a/data/database.py b/data/database.py index ee3edf900..3f546f3e9 100644 --- a/data/database.py +++ b/data/database.py @@ -12,6 +12,7 @@ from random import SystemRandom import resumablehashlib import toposort +from enum import Enum from peewee import * from sqlalchemy.engine.url import make_url @@ -869,6 +870,9 @@ class TorrentInfo(BaseModel): ) +class ServiceKeyApprovalType(Enum): + SUPERUSER = 'Super User API' + _ServiceKeyApproverProxy = Proxy() class ServiceKeyApproval(BaseModel): approver = ForeignKeyField(_ServiceKeyApproverProxy, null=True) diff --git a/data/model/service_keys.py b/data/model/service_keys.py index 75c727b16..138925c2a 100644 --- a/data/model/service_keys.py +++ b/data/model/service_keys.py @@ -22,7 +22,29 @@ def _gc_expired(service): # TODO ACTION_LOGS for keys -def upsert_service_key(name, kid, service, jwk, metadata, expiration_date): + +def create_service_key(name, kid, service, jwk, metadata, expiration_date): + _gc_expired(service) + + sk = ServiceKey.create(name=name, kid=kid, service=service, jwk=jwk, metadata=metadata, + expiration_date=expiration_date) + + superusers = User.select().where(User.username << app.config['SUPER_USERS']) + for superuser in superusers: + # TODO(jzelinskie): create notification type in the database migration + # I already put it in initdb + create_notification('service_key_submitted', superuser, { + 'name': name, + 'kid': kid, + 'service': service, + 'jwk': jwk, + 'metadata': metadata, + 'created_date': sk.created_date, + 'expiration_date': expiration_date, + }) + + +def update_service_key(name, kid, metadata, expiration_date): _gc_expired(service) try: @@ -33,29 +55,23 @@ def upsert_service_key(name, kid, service, jwk, metadata, expiration_date): key.expiration_date = expiration_date key.save() except ServiceKey.DoesNotExist: - sk = ServiceKey.create(name=name, kid=kid, service=service, jwk=jwk, metadata=metadata, - expiration_date=expiration_date) - superusers = User.select().where(User.username << app.config['SUPER_USERS']) - for superuser in superusers: - # TODO(jzelinskie): create notification type in the database migration - create_notification('service_key_submitted', superuser, { - 'name': name, - 'kid': kid, - 'service': service, - 'jwk': jwk, - 'metadata': metadata, - 'created_date': sk.created_date, - 'expiration_date': expiration_date, - }) + raise ServiceKeyDoesNotExist -def get_service_keys(service, kid=None): +def get_service_keys(approved_only, kid=None, service=None): _gc_expired(service) - query = ServiceKey.select().where(ServiceKey.service == service, - ~(ServiceKey.approval >> None)) - if kid: + query = ServiceKey.select() + + if service is not None: + query = query.where(ServiceKey.service == service) + + if kid is not None: query.where(ServiceKey.kid == kid) + + if approved_only: + query = query.where(~(ServiceKey.approval >> None)) + return query @@ -69,11 +85,10 @@ def delete_service_key(service, kid): raise ServiceKeyDoesNotExist() -def approve_service_key(service, kid, approver, approval_type): +def approve_service_key(kid, approver, approval_type): try: with db_transaction(): - key = db_for_update(ServiceKey.select().where(ServiceKey.service == service, - ServiceKey.kid == kid)).get() + key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get() if key.approval is not None: raise ServiceKeyAlreadyApproved diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index f5fdbf8f7..79c362611 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -4,8 +4,10 @@ import string import logging import os +from datetime import datetime from random import SystemRandom -from flask import request, make_response + +from flask import request, make_response, jsonify import features @@ -469,38 +471,154 @@ class SuperUserOrganizationManagement(ApiResource): abort(403) -@resource('/v1/superuser/services//keys/') -@path_param('service', 'The service using the key') -@path_param('kid', 'The unique identifier for a service key') + +@resource('/v1/superuser/keys') @show_if(features.SUPER_USERS) class SuperUserServiceKeyManagement(ApiResource): - """ Resource for managing service keys. """ + """ Resource for managing service keys.""" schemas = { - 'ApproveServiceKey': { - 'id': 'ApproveServiceKey', + 'CreateServiceKey': { + 'id': 'PutServiceKey', 'type': 'object', - 'description': 'Description of approved keys for a service', + 'description': 'Description of creation of a service key', + 'required': ['service', 'expiration'], 'properties': { - 'kid': { + 'service': { 'type': 'string', - 'description': 'The key being approved for service authentication usage.', + 'description': 'The service authenticating with this key', + }, + 'name': { + 'type': 'string', + 'description': 'The friendly name of a service key', + }, + 'metadata': { + 'type': 'object', + 'description': 'The key/value pairs of this key\'s metadata', + }, + 'expiration': { + 'description': 'The expiration date as a unix timestamp', + 'anyOf': [{'type': 'string'}, {'type': 'null'}], }, }, }, } @verify_not_prod - @nickname('approveServiceKey') - @validate_json_request('ApproveServiceKey') + @nickname('getServiceKeys') @require_scope(scopes.SUPERUSER) - def put(self, service, kid): + def get(): + if SuperUserPermission().can(): + return jsonify(list(model.service_keys.get_service_keys(False))) + abort(403) + + @verify_not_prod + @nickname('createServiceKey') + @require_scope(scopes.SUPERUSER) + @validate_json_request('PutServiceKey') + def post(self, service): + if SuperUserPermission().can(): + body = request.get_json() + + expiration_date = body['expiration'] + if expiration_date is not None and expiration_date != '': + try: + expiration_date = datetime.utcfromtimestamp(float(expiration_date)) + except ValueError: + abort(400) + + + user = get_authenticate_user() + metadata = body.get('metadata', {}) + metadata.update({ + 'created_by': 'Quay SuperUser Panel', + 'creator': user.username, + 'superuser ip': request.remote_addr, + }) + + key = RSAKey(key=RSA.generate(2048)) + key.serialize() + jwk = json.dumps(key.to_dict(), "enc") + + public_key, private_key = generate_key_pair() + jwk = jwk(private_key) + kid = kid(private_key) + + + model.service_keys.create_service_key(body['name'] or '', kid, jwk, metadata, expiration_date) + model.service_keys.approve_service_key(kid, user, + return jsonify({'public_key': public_key, 'private_key': private_key}) + + abort(403) + + +@resource('/v1/superuser/keys/') +@path_param('kid', 'The unique identifier for a service key') +@show_if(features.SUPER_USERS) +class SuperUserServiceKeyManagement(ApiResource): + """ Resource for managing service keys. """ + schemas = { + 'PutServiceKey': { + 'id': 'PutServiceKey', + 'type': 'object', + 'description': 'Description of updates for a service key', + 'required': ['name', 'metadata', 'expiration'], + 'properties': { + 'name': { + 'type': 'string', + 'description': 'The friendly name of a service key', + }, + 'metadata': { + 'type': 'object', + 'description': 'The key/value pairs of this key\'s metadata', + }, + 'expiration': { + 'description': 'The expiration date as a unix timestamp', + 'anyOf': [{'type': 'string'}, {'type': 'null'}], + }, + }, + }, + } + + @verify_not_prod + @nickname('putServiceKey') + @require_scope(scopes.SUPERUSER) + @validate_json_request('PutServiceKey') + def put(self, kid): + if SuperUserPermission().can(): + body = request.get_json() + + expiration_date = body['expiration'] + if expiration_date is not None and expiration_date != '': + try: + expiration_date = datetime.utcfromtimestamp(float(expiration_date)) + except ValueError: + abort(400) + + model.service_keys.update_service_key(body['name'], kid, body['metadata'], expiration_date) + + abort(403) + + +@resource('/v1/superuser/approvedkeys/') +@path_param('kid', 'The unique identifier for a service key') +@show_if(features.SUPER_USERS) +class SuperUserServiceKeyApproval(ApiResource): + """ Resource for approving service keys. """ + + @verify_not_prod + @nickname('approveServiceKey') + @require_scope(scopes.SUPERUSER) + @validate_json_request('ApproveServiceKey') + def put(self, kid): if SuperUserPermission().can(): approver = get_authenticated_user() try: - model.service_keys.approve_service_key(service, kid, approver, 'Quay SuperUser API') + model.service_keys.approve_service_key(kid, approver, ServiceKeyApprovalType.SUPERUSER) except model.ServiceKeyDoesNotExist: abort(404) except model.ServiceKeyAlreadyApproved: pass make_response('', 200) + + abort(403) diff --git a/endpoints/key_server.py b/endpoints/key_server.py index 919da667e..b80ea488e 100644 --- a/endpoints/key_server.py +++ b/endpoints/key_server.py @@ -18,7 +18,24 @@ JWT_HEADER_NAME = 'Authorization' JWT_AUDIENCE = 'quay' -def _validate_JWT(encoded_jwt, jwk, service): +def _validate_jwk(jwk, kid): + if 'kty' not in jwk: + abort(400) + + if 'kid' not in jwk or jwk['kid'] != kid: + abort(400) + + if jwk['kty'] == 'EC': + if 'x' not in jwk or 'y' not in jwk: + abort(400) + elif jwk['kty'] == 'RSA': + if 'e' not in jwk or 'n' not in jwk: + abort(400) + else: + abort(400) + + +def _validate_jwt(encoded_jwt, jwk, service): public_key = RSAPublicNumbers(e=jwk.e, n=jwk.n).public_key(default_backend()) @@ -47,15 +64,16 @@ def _signer_jwk(encoded_jwt, jwk, service, kid): @key_server.route('/services//keys', methods=['GET']) def get_service_keys(service): - kid = request.args.get('kid', None) - if kid is not None: - keys = data.model.service_keys.get_service_keys(service, kid=kid) - else: - keys = data.model.service_keys.get_service_keys(service) - + keys = data.model.service_keys.get_service_keys(service) return jsonify({'keys': [key.jwk for key in keys]}) +@key_server.route('/services//keys/', methods=['GET']) +def get_service_key(service, kid): + key = data.model.service_keys.get_service_keys(service, kid=kid) + return jsonify(key.jwk) + + @key_server.route('/services//keys/', methods=['PUT']) def put_service_keys(service, kid): expiration_date = request.args.get('expiration', None) @@ -70,27 +88,14 @@ def put_service_keys(service, kid): except ValueError: abort(400) - if 'kty' not in jwk: - abort(400) - - if 'kid' not in jwk or jwk['kid'] != kid: - abort(400) - - if jwk['kty'] == 'EC': - if 'x' not in jwk or 'y' not in jwk: - abort(400) - elif jwk['kty'] == 'RSA': - if 'e' not in jwk or 'n' not in jwk: - abort(400) - else: - abort(400) + _validate_jwk(jwk, kid) encoded_jwt = request.headers.get(JWT_HEADER_NAME, None) if not encoded_jwt: abort(400) signer_jwk = _signer_jwk(encoded_jwt, jwk, service, kid) - _validate_JWT(encoded_jwt, signer_jwk, service) + _validate_jwt(encoded_jwt, signer_jwk, service) metadata = { 'ip': request.remote_addr, @@ -98,7 +103,10 @@ def put_service_keys(service, kid): } - data.model.service_keys.upsert_service_key('', kid, service, jwk, metadata, expiration_date) + try: + data.model.service_keys.update_service_key('', kid, service, metadata, expiration_date) + except data.model.ServiceKeyDoesNotExist: + data.model.service_keys.create_service_key('', kid, service, jwk, metadata, expiration_date) @key_server.route('/services//keys/', methods=['DELETE']) @@ -108,7 +116,7 @@ def delete_service_key(service, kid): abort(400) signer_jwk = _signer_jwk(encoded_jwt, None, service, kid) - _validate_JWT(encoded_jwt, signer_jwk, service) + _validate_jwt(encoded_jwt, signer_jwk, service) try: data.model.service_keys.delete_service_key(service, kid)