From 4079dba16702c1b1b67b6826e7b70dee8026b6b0 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 23 Mar 2016 18:16:03 -0400 Subject: [PATCH] service keys: do all the right stuff --- config.py | 7 ++-- data/database.py | 12 ++++--- data/model/__init__.py | 4 +++ data/model/service_keys.py | 50 ++++++++++++++++++++------ endpoints/api/superuser.py | 43 ++++++++++++++++++++-- endpoints/key_server.py | 73 +++++++++++++++++++++++++++----------- initdb.py | 1 + 7 files changed, 149 insertions(+), 41 deletions(-) diff --git a/config.py b/config.py index 06407c7b1..9958f6c12 100644 --- a/config.py +++ b/config.py @@ -1,8 +1,6 @@ -import requests import os.path -from data.buildlogs import BuildLogs -from data.userevent import UserEventBuilder +import requests def build_requests_session(): @@ -303,3 +301,6 @@ class DefaultConfig(object): # hide the ID range for production (in which this value is overridden). Should *not* # be relied upon for secure encryption otherwise. PAGE_TOKEN_KEY = 'um=/?Kqgp)2yQaS/A6C{NL=dXE&>C:}(' + + # The timeout for service key approval. + UNAPPROVED_SERVICE_KEY_TTL_SEC = 60 * 60 * 24 # One day diff --git a/data/database.py b/data/database.py index 9eb4da218..ee3edf900 100644 --- a/data/database.py +++ b/data/database.py @@ -871,18 +871,22 @@ class TorrentInfo(BaseModel): _ServiceKeyApproverProxy = Proxy() class ServiceKeyApproval(BaseModel): - approver = ForeignKeyField(_ServiceKeyApproverProxy) + approver = ForeignKeyField(_ServiceKeyApproverProxy, null=True) approval_type = CharField(index=True) - approved_date = DateTimeField(default=datetime.now) + approved_date = DateTimeField(default=datetime.utcnow) + notes = TextField() _ServiceKeyApproverProxy.initialize(User) class ServiceKey(BaseModel): + name = CharField() kid = CharField(unique=True, index=True) service = CharField(index=True) - jwk = CharField(unique=True) + jwk = JSONField(unique=True) + metadata = JSONField(unique=True) + created_date = DateTimeField(default=datetime.utcnow) expiration_date = DateTimeField(null=True) - approval = ForeignKeyField(ServiceKeyApproval, index=True) + approval = ForeignKeyField(ServiceKeyApproval, index=True, null=True) is_model = lambda x: inspect.isclass(x) and issubclass(x, BaseModel) and x is not BaseModel diff --git a/data/model/__init__.py b/data/model/__init__.py index fbe615883..36a33d0c7 100644 --- a/data/model/__init__.py +++ b/data/model/__init__.py @@ -80,6 +80,10 @@ class ServiceKeyDoesNotExist(DataModelException): pass +class ServiceKeyAlreadyApproved(DataModelException): + pass + + class TooManyLoginAttemptsException(Exception): def __init__(self, message, retry_after): super(TooManyLoginAttemptsException, self).__init__(message) diff --git a/data/model/service_keys.py b/data/model/service_keys.py index 0d27fec4f..75c727b16 100644 --- a/data/model/service_keys.py +++ b/data/model/service_keys.py @@ -1,25 +1,52 @@ -from datetime import datetime +from datetime import datetime, timedelta + +from app import app +from data.database import db_for_update, User, ServiceKey, ServiceKeyApproval +from data.model import ServiceKeyDoesNotExist, ServiceKeyAlreadyApproved, db_transaction +from data.model.notification import create_notification + + +UNAPPROVED_TTL = timedelta(seconds=app.config['UNAPPROVED_SERVICE_KEY_TTL_SEC']) -from data.model import ServiceKeyDoesNotExist, db_transaction -from data.database import db_for_update, ServiceKey, ServiceKeyApproval def _gc_expired(service): - ServiceKey.delete().where(ServiceKey.service == service, - ServiceKey.expiration_date <= datetime.now).execute() + expired_keys = ((ServiceKey.service == service) & + (ServiceKey.expiration_date <= datetime.utcnow())) + + stale_unapproved_keys = ((ServiceKey.service >> service) & + (ServiceKey.approval >> None) & + (ServiceKey.created_date + UNAPPROVED_TTL) >= datetime.utcnow()) + + ServiceKey.delete().where(expired_keys | stale_unapproved_keys).execute() -def upsert_service_key(kid, service, jwk, expiration_date): +# TODO ACTION_LOGS for keys + +def upsert_service_key(name, kid, service, jwk, metadata, expiration_date): _gc_expired(service) try: with db_transaction(): key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get() - key.service = service - key.jwk = jwk + key.name = name + key.metadata.update(metadata) key.expiration_date = expiration_date key.save() except ServiceKey.DoesNotExist: - ServiceKey.create(kid=kid, service=service, jwk=jwk, expiration_date=expiration_date) + 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, + }) def get_service_keys(service, kid=None): @@ -45,9 +72,12 @@ def delete_service_key(service, kid): def approve_service_key(service, kid, approver, approval_type): try: with db_transaction(): - approval = ServiceKeyApproval.create(approver=approver, approval_type=approval_type) key = db_for_update(ServiceKey.select().where(ServiceKey.service == service, ServiceKey.kid == kid)).get() + if key.approval is not None: + raise ServiceKeyAlreadyApproved + + approval = ServiceKeyApproval.create(approver=approver, approval_type=approval_type) key.approval = approval key.save() except ServiceKey.DoesNotExist: diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index 3279c9e9f..f5fdbf8f7 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -5,19 +5,20 @@ import logging import os from random import SystemRandom -from flask import request +from flask import request, make_response import features from app import app, avatar, superusers, authentication, config_provider +from auth import scopes +from auth.auth_context import get_authenticated_user +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) from endpoints.api.logs import get_logs, get_aggregate_logs from data import model -from auth.permissions import SuperUserPermission -from auth import scopes from util.useremails import send_confirmation_email, send_recovery_email @@ -467,3 +468,39 @@ class SuperUserOrganizationManagement(ApiResource): return org_view(org) 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') +@show_if(features.SUPER_USERS) +class SuperUserServiceKeyManagement(ApiResource): + """ Resource for managing service keys. """ + schemas = { + 'ApproveServiceKey': { + 'id': 'ApproveServiceKey', + 'type': 'object', + 'description': 'Description of approved keys for a service', + 'properties': { + 'kid': { + 'type': 'string', + 'description': 'The key being approved for service authentication usage.', + }, + }, + }, + } + + @verify_not_prod + @nickname('approveServiceKey') + @validate_json_request('ApproveServiceKey') + @require_scope(scopes.SUPERUSER) + def put(self, service, kid): + if SuperUserPermission().can(): + approver = get_authenticated_user() + try: + model.service_keys.approve_service_key(service, kid, approver, 'Quay SuperUser API') + except model.ServiceKeyDoesNotExist: + abort(404) + except model.ServiceKeyAlreadyApproved: + pass + + make_response('', 200) diff --git a/endpoints/key_server.py b/endpoints/key_server.py index 096f431c3..919da667e 100644 --- a/endpoints/key_server.py +++ b/endpoints/key_server.py @@ -1,5 +1,7 @@ from datetime import datetime +import jwt + from flask import Blueprint, jsonify, abort, request, make_response from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers from cryptography.hazmat.backends import default_backend @@ -16,26 +18,42 @@ JWT_HEADER_NAME = 'Authorization' JWT_AUDIENCE = 'quay' -def _validate_JWT(jwt, service, kid): - try: - service_key = data.model.service_keys.get_service_keys(service, kid=kid) - except data.model.ServiceKeyDoesNotExist: - abort(404) - - public_key = RSAPublicNumbers(e=service_key.jwk.e, - n=service_key.jwk.n).public_key(default_backend()) +def _validate_JWT(encoded_jwt, jwk, service): + public_key = RSAPublicNumbers(e=jwk.e, + n=jwk.n).public_key(default_backend()) try: - strictjwt.decode(jwt, public_key, algorithms=['RS256'], audience=JWT_AUDIENCE, issuer=service) + strictjwt.decode(encoded_jwt, public_key, algorithms=['RS256'], + audience=JWT_AUDIENCE, issuer=service) except strictjwt.InvalidTokenError: abort(400) +def _signer_jwk(encoded_jwt, jwk, service, kid): + decoded_jwt = jwt.decode(encoded_jwt, verify=False) + + signer_kid = decoded_jwt.get('signer_kid', '') + # If we already have our own JWK and it's the signer, short-circuit. + if (signer_kid == kid or signer_kid == '') and jwk is not None: + return jwk + else: + try: + service_key = data.model.service_keys.get_service_keys(service, kid=signer_kid) + except data.model.ServiceKeyDoesNotExist: + abort(404) + + return service_key.jwk + + @key_server.route('/services//keys', methods=['GET']) def get_service_keys(service): - keys = data.model.service_keys.get_service_keys(service) - jwks = [key.jwk for key in keys] - return jsonify({'keys': jwks}) + 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) + + return jsonify({'keys': [key.jwk for key in keys]}) @key_server.route('/services//keys/', methods=['PUT']) @@ -47,11 +65,6 @@ def put_service_keys(service, kid): except ValueError: abort(400) - jwt = request.headers.get(JWT_HEADER_NAME, None) - if not jwt: - abort(400) - _validate_JWT(jwt, service, kid) - try: jwk = request.json() except ValueError: @@ -60,6 +73,9 @@ def put_service_keys(service, 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) @@ -69,15 +85,30 @@ def put_service_keys(service, kid): else: abort(400) - data.model.service_keys.upsert_service_key(kid, service, jwk, expiration_date) + 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) + + metadata = { + 'ip': request.remote_addr, + 'signer_jwk': signer_jwk, + } + + + data.model.service_keys.upsert_service_key('', kid, service, jwk, metadata, expiration_date) @key_server.route('/services//keys/', methods=['DELETE']) def delete_service_key(service, kid): - jwt = request.headers.get(JWT_HEADER_NAME, None) - if not jwt: + encoded_jwt = request.headers.get(JWT_HEADER_NAME, None) + if not encoded_jwt: abort(400) - _validate_JWT(jwt, service, kid) + + signer_jwk = _signer_jwk(encoded_jwt, None, service, kid) + _validate_JWT(encoded_jwt, signer_jwk, service) try: data.model.service_keys.delete_service_key(service, kid) diff --git a/initdb.py b/initdb.py index 63cebbeb4..abc06d866 100644 --- a/initdb.py +++ b/initdb.py @@ -336,6 +336,7 @@ def initialize_database(): NotificationKind.create(name='build_success') NotificationKind.create(name='build_failure') NotificationKind.create(name='vulnerability_found') + NotificationKind.create(name='service_key_submitted') NotificationKind.create(name='password_required') NotificationKind.create(name='over_private_usage')