service keys: do all the right stuff
This commit is contained in:
parent
6ecff950ab
commit
4079dba167
7 changed files with 149 additions and 41 deletions
|
@ -1,8 +1,6 @@
|
||||||
import requests
|
|
||||||
import os.path
|
import os.path
|
||||||
|
|
||||||
from data.buildlogs import BuildLogs
|
import requests
|
||||||
from data.userevent import UserEventBuilder
|
|
||||||
|
|
||||||
|
|
||||||
def build_requests_session():
|
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*
|
# hide the ID range for production (in which this value is overridden). Should *not*
|
||||||
# be relied upon for secure encryption otherwise.
|
# be relied upon for secure encryption otherwise.
|
||||||
PAGE_TOKEN_KEY = 'um=/?Kqgp)2yQaS/A6C{NL=dXE&>C:}('
|
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
|
||||||
|
|
|
@ -871,18 +871,22 @@ class TorrentInfo(BaseModel):
|
||||||
|
|
||||||
_ServiceKeyApproverProxy = Proxy()
|
_ServiceKeyApproverProxy = Proxy()
|
||||||
class ServiceKeyApproval(BaseModel):
|
class ServiceKeyApproval(BaseModel):
|
||||||
approver = ForeignKeyField(_ServiceKeyApproverProxy)
|
approver = ForeignKeyField(_ServiceKeyApproverProxy, null=True)
|
||||||
approval_type = CharField(index=True)
|
approval_type = CharField(index=True)
|
||||||
approved_date = DateTimeField(default=datetime.now)
|
approved_date = DateTimeField(default=datetime.utcnow)
|
||||||
|
notes = TextField()
|
||||||
_ServiceKeyApproverProxy.initialize(User)
|
_ServiceKeyApproverProxy.initialize(User)
|
||||||
|
|
||||||
|
|
||||||
class ServiceKey(BaseModel):
|
class ServiceKey(BaseModel):
|
||||||
|
name = CharField()
|
||||||
kid = CharField(unique=True, index=True)
|
kid = CharField(unique=True, index=True)
|
||||||
service = CharField(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)
|
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
|
is_model = lambda x: inspect.isclass(x) and issubclass(x, BaseModel) and x is not BaseModel
|
||||||
|
|
|
@ -80,6 +80,10 @@ class ServiceKeyDoesNotExist(DataModelException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceKeyAlreadyApproved(DataModelException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TooManyLoginAttemptsException(Exception):
|
class TooManyLoginAttemptsException(Exception):
|
||||||
def __init__(self, message, retry_after):
|
def __init__(self, message, retry_after):
|
||||||
super(TooManyLoginAttemptsException, self).__init__(message)
|
super(TooManyLoginAttemptsException, self).__init__(message)
|
||||||
|
|
|
@ -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):
|
def _gc_expired(service):
|
||||||
ServiceKey.delete().where(ServiceKey.service == service,
|
expired_keys = ((ServiceKey.service == service) &
|
||||||
ServiceKey.expiration_date <= datetime.now).execute()
|
(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)
|
_gc_expired(service)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with db_transaction():
|
with db_transaction():
|
||||||
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get()
|
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get()
|
||||||
key.service = service
|
key.name = name
|
||||||
key.jwk = jwk
|
key.metadata.update(metadata)
|
||||||
key.expiration_date = expiration_date
|
key.expiration_date = expiration_date
|
||||||
key.save()
|
key.save()
|
||||||
except ServiceKey.DoesNotExist:
|
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):
|
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):
|
def approve_service_key(service, kid, approver, approval_type):
|
||||||
try:
|
try:
|
||||||
with db_transaction():
|
with db_transaction():
|
||||||
approval = ServiceKeyApproval.create(approver=approver, approval_type=approval_type)
|
|
||||||
key = db_for_update(ServiceKey.select().where(ServiceKey.service == service,
|
key = db_for_update(ServiceKey.select().where(ServiceKey.service == service,
|
||||||
ServiceKey.kid == kid)).get()
|
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.approval = approval
|
||||||
key.save()
|
key.save()
|
||||||
except ServiceKey.DoesNotExist:
|
except ServiceKey.DoesNotExist:
|
||||||
|
|
|
@ -5,19 +5,20 @@ import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from random import SystemRandom
|
from random import SystemRandom
|
||||||
from flask import request
|
from flask import request, make_response
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, avatar, superusers, authentication, config_provider
|
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,
|
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)
|
page_support)
|
||||||
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 auth.permissions import SuperUserPermission
|
|
||||||
from auth import scopes
|
|
||||||
from util.useremails import send_confirmation_email, send_recovery_email
|
from util.useremails import send_confirmation_email, send_recovery_email
|
||||||
|
|
||||||
|
|
||||||
|
@ -467,3 +468,39 @@ class SuperUserOrganizationManagement(ApiResource):
|
||||||
return org_view(org)
|
return org_view(org)
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
@resource('/v1/superuser/services/<service>/keys/<kid>')
|
||||||
|
@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)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, abort, request, make_response
|
from flask import Blueprint, jsonify, abort, request, make_response
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
@ -16,26 +18,42 @@ JWT_HEADER_NAME = 'Authorization'
|
||||||
JWT_AUDIENCE = 'quay'
|
JWT_AUDIENCE = 'quay'
|
||||||
|
|
||||||
|
|
||||||
def _validate_JWT(jwt, service, kid):
|
def _validate_JWT(encoded_jwt, jwk, service):
|
||||||
try:
|
public_key = RSAPublicNumbers(e=jwk.e,
|
||||||
service_key = data.model.service_keys.get_service_keys(service, kid=kid)
|
n=jwk.n).public_key(default_backend())
|
||||||
except data.model.ServiceKeyDoesNotExist:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
public_key = RSAPublicNumbers(e=service_key.jwk.e,
|
|
||||||
n=service_key.jwk.n).public_key(default_backend())
|
|
||||||
|
|
||||||
try:
|
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:
|
except strictjwt.InvalidTokenError:
|
||||||
abort(400)
|
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/<service>/keys', methods=['GET'])
|
@key_server.route('/services/<service>/keys', methods=['GET'])
|
||||||
def get_service_keys(service):
|
def get_service_keys(service):
|
||||||
keys = data.model.service_keys.get_service_keys(service)
|
kid = request.args.get('kid', None)
|
||||||
jwks = [key.jwk for key in keys]
|
if kid is not None:
|
||||||
return jsonify({'keys': jwks})
|
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/<service>/keys/<kid>', methods=['PUT'])
|
@key_server.route('/services/<service>/keys/<kid>', methods=['PUT'])
|
||||||
|
@ -47,11 +65,6 @@ def put_service_keys(service, kid):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
jwt = request.headers.get(JWT_HEADER_NAME, None)
|
|
||||||
if not jwt:
|
|
||||||
abort(400)
|
|
||||||
_validate_JWT(jwt, service, kid)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jwk = request.json()
|
jwk = request.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -60,6 +73,9 @@ def put_service_keys(service, kid):
|
||||||
if 'kty' not in jwk:
|
if 'kty' not in jwk:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
|
if 'kid' not in jwk or jwk['kid'] != kid:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
if jwk['kty'] == 'EC':
|
if jwk['kty'] == 'EC':
|
||||||
if 'x' not in jwk or 'y' not in jwk:
|
if 'x' not in jwk or 'y' not in jwk:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
@ -69,15 +85,30 @@ def put_service_keys(service, kid):
|
||||||
else:
|
else:
|
||||||
abort(400)
|
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/<service>/keys/<kid>', methods=['DELETE'])
|
@key_server.route('/services/<service>/keys/<kid>', methods=['DELETE'])
|
||||||
def delete_service_key(service, kid):
|
def delete_service_key(service, kid):
|
||||||
jwt = request.headers.get(JWT_HEADER_NAME, None)
|
encoded_jwt = request.headers.get(JWT_HEADER_NAME, None)
|
||||||
if not jwt:
|
if not encoded_jwt:
|
||||||
abort(400)
|
abort(400)
|
||||||
_validate_JWT(jwt, service, kid)
|
|
||||||
|
signer_jwk = _signer_jwk(encoded_jwt, None, service, kid)
|
||||||
|
_validate_JWT(encoded_jwt, signer_jwk, service)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data.model.service_keys.delete_service_key(service, kid)
|
data.model.service_keys.delete_service_key(service, kid)
|
||||||
|
|
|
@ -336,6 +336,7 @@ def initialize_database():
|
||||||
NotificationKind.create(name='build_success')
|
NotificationKind.create(name='build_success')
|
||||||
NotificationKind.create(name='build_failure')
|
NotificationKind.create(name='build_failure')
|
||||||
NotificationKind.create(name='vulnerability_found')
|
NotificationKind.create(name='vulnerability_found')
|
||||||
|
NotificationKind.create(name='service_key_submitted')
|
||||||
|
|
||||||
NotificationKind.create(name='password_required')
|
NotificationKind.create(name='password_required')
|
||||||
NotificationKind.create(name='over_private_usage')
|
NotificationKind.create(name='over_private_usage')
|
||||||
|
|
Reference in a new issue