Start of a v2 API.
This commit is contained in:
parent
3bfa2a6509
commit
acbcc2e206
16 changed files with 508 additions and 55 deletions
13
app.py
13
app.py
|
@ -6,14 +6,12 @@ from flask import Flask, Config, request, Request, _request_ctx_stack
|
||||||
from flask.ext.principal import Principal
|
from flask.ext.principal import Principal
|
||||||
from flask.ext.login import LoginManager, UserMixin
|
from flask.ext.login import LoginManager, UserMixin
|
||||||
from flask.ext.mail import Mail
|
from flask.ext.mail import Mail
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from avatars.avatars import Avatar
|
from avatars.avatars import Avatar
|
||||||
from storage import Storage
|
from storage import Storage
|
||||||
|
|
||||||
from avatars.avatars import Avatar
|
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data import database
|
from data import database
|
||||||
from data.userfiles import Userfiles
|
from data.userfiles import Userfiles
|
||||||
|
@ -45,6 +43,15 @@ CONFIG_PROVIDER = FileConfigProvider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', '
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegexConverter(BaseConverter):
|
||||||
|
def __init__(self, url_map, *items):
|
||||||
|
super(RegexConverter, self).__init__(url_map)
|
||||||
|
logger.debug('Installing regex converter with regex: %s', items[0])
|
||||||
|
self.regex = items[0]
|
||||||
|
|
||||||
|
|
||||||
|
app.url_map.converters['regex'] = RegexConverter
|
||||||
|
|
||||||
# Instantiate the default configuration (for test or for normal operation).
|
# Instantiate the default configuration (for test or for normal operation).
|
||||||
if 'TEST' in os.environ:
|
if 'TEST' in os.environ:
|
||||||
CONFIG_PROVIDER = TestConfigProvider()
|
CONFIG_PROVIDER = TestConfigProvider()
|
||||||
|
|
87
auth/jwt_auth.py
Normal file
87
auth/jwt_auth.py
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import logging
|
||||||
|
import jwt
|
||||||
|
import re
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
|
from flask import request
|
||||||
|
from flask.ext.principal import identity_changed, Identity
|
||||||
|
from cryptography.x509 import load_pem_x509_certificate
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
from auth_context import set_grant_user_context
|
||||||
|
from permissions import repository_read_grant, repository_write_grant
|
||||||
|
from util.names import parse_namespace_repository
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_REGEX = re.compile(r'Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)')
|
||||||
|
|
||||||
|
|
||||||
|
def process_jwt_auth(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
logger.debug('Called with params: %s, %s', args, kwargs)
|
||||||
|
auth = request.headers.get('authorization', '').strip()
|
||||||
|
if auth:
|
||||||
|
logger.debug('Validating auth header: %s', auth)
|
||||||
|
|
||||||
|
# Extract the jwt token from the header
|
||||||
|
match = TOKEN_REGEX.match(auth)
|
||||||
|
if match is None or match.end() != len(auth):
|
||||||
|
logger.debug('Not a valid bearer token: %s', auth)
|
||||||
|
return
|
||||||
|
|
||||||
|
encoded = match.group(1)
|
||||||
|
logger.debug('encoded JWT: %s', encoded)
|
||||||
|
|
||||||
|
# Load the JWT returned.
|
||||||
|
try:
|
||||||
|
with open('/Users/jake/Projects/registry-v2/ca/quay.host.crt') as cert_file:
|
||||||
|
cert_obj = load_pem_x509_certificate(cert_file.read(), default_backend())
|
||||||
|
public_key = cert_obj.public_key()
|
||||||
|
payload = jwt.decode(encoded, public_key, algorithms=['RS256'], audience='quay',
|
||||||
|
issuer='token-issuer')
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
logger.exception('Exception when decoding returned JWT')
|
||||||
|
return (None, 'Invalid username or password')
|
||||||
|
|
||||||
|
if not 'sub' in payload:
|
||||||
|
raise Exception('Missing subject field in JWT')
|
||||||
|
|
||||||
|
if not 'exp' in payload:
|
||||||
|
raise Exception('Missing exp field in JWT')
|
||||||
|
|
||||||
|
# Verify that the expiration is no more than 300 seconds in the future.
|
||||||
|
if datetime.fromtimestamp(payload['exp']) > datetime.utcnow() + timedelta(seconds=300):
|
||||||
|
logger.debug('Payload expiration is outside of the 300 second window: %s', payload['exp'])
|
||||||
|
return (None, 'Invalid username or password')
|
||||||
|
|
||||||
|
username = payload['sub']
|
||||||
|
loaded_identity = Identity(username, 'signed_grant')
|
||||||
|
|
||||||
|
# Process the grants from the payload
|
||||||
|
if 'access' in payload:
|
||||||
|
for grant in payload['access']:
|
||||||
|
if grant['type'] != 'repository':
|
||||||
|
continue
|
||||||
|
|
||||||
|
namespace, repo_name = parse_namespace_repository(grant['name'])
|
||||||
|
|
||||||
|
if 'push' in grant['actions']:
|
||||||
|
loaded_identity.provides.add(repository_write_grant(namespace, repo_name))
|
||||||
|
elif 'pull' in grant['actions']:
|
||||||
|
loaded_identity.provides.add(repository_read_grant(namespace, repo_name))
|
||||||
|
|
||||||
|
identity_changed.send(app, identity=loaded_identity)
|
||||||
|
set_grant_user_context(username)
|
||||||
|
|
||||||
|
logger.debug('Identity changed to %s', username)
|
||||||
|
else:
|
||||||
|
logger.debug('No auth header.')
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
|
@ -514,6 +514,11 @@ class RepositoryTag(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TagManifest(BaseModel):
|
||||||
|
tag = ForeignKeyField(RepositoryTag)
|
||||||
|
digest = CharField(index=True)
|
||||||
|
|
||||||
|
|
||||||
class BUILD_PHASE(object):
|
class BUILD_PHASE(object):
|
||||||
""" Build phases enum """
|
""" Build phases enum """
|
||||||
ERROR = 'error'
|
ERROR = 'error'
|
||||||
|
|
29
endpoints/v1/__init__.py
Normal file
29
endpoints/v1/__init__.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
from flask import Blueprint, make_response
|
||||||
|
|
||||||
|
from endpoints.decorators import anon_protect, anon_allowed
|
||||||
|
|
||||||
|
|
||||||
|
v1_bp = Blueprint('v1', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Note: This is *not* part of the Docker index spec. This is here for our own health check,
|
||||||
|
# since we have nginx handle the _ping below.
|
||||||
|
@v1_bp.route('/_internal_ping')
|
||||||
|
@anon_allowed
|
||||||
|
def internal_ping():
|
||||||
|
return make_response('true', 200)
|
||||||
|
|
||||||
|
|
||||||
|
@v1_bp.route('/_ping')
|
||||||
|
@anon_allowed
|
||||||
|
def ping():
|
||||||
|
# NOTE: any changes made here must also be reflected in the nginx config
|
||||||
|
response = make_response('true', 200)
|
||||||
|
response.headers['X-Docker-Registry-Version'] = '0.6.0'
|
||||||
|
response.headers['X-Docker-Registry-Standalone'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
from endpoints.v1 import index
|
||||||
|
from endpoints.v1 import registry
|
||||||
|
from endpoints.v1 import tags
|
|
@ -18,15 +18,15 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
|
||||||
AlwaysFailPermission, repository_read_grant, repository_write_grant)
|
AlwaysFailPermission, repository_read_grant, repository_write_grant)
|
||||||
|
|
||||||
from util.http import abort
|
from util.http import abort
|
||||||
|
from endpoints.v1 import v1_bp
|
||||||
from endpoints.trackhelper import track_and_log
|
from endpoints.trackhelper import track_and_log
|
||||||
from endpoints.notificationhelper import spawn_notification
|
from endpoints.notificationhelper import spawn_notification
|
||||||
from endpoints.decorators import anon_protect, anon_allowed
|
from endpoints.decorators import anon_protect, anon_allowed
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
index = Blueprint('index', __name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GrantType(object):
|
class GrantType(object):
|
||||||
|
@ -73,8 +73,8 @@ def generate_headers(scope=GrantType.READ_REPOSITORY, add_grant_for_status=None)
|
||||||
return decorator_method
|
return decorator_method
|
||||||
|
|
||||||
|
|
||||||
@index.route('/users', methods=['POST'])
|
@v1_bp.route('/users', methods=['POST'])
|
||||||
@index.route('/users/', methods=['POST'])
|
@v1_bp.route('/users/', methods=['POST'])
|
||||||
@anon_allowed
|
@anon_allowed
|
||||||
def create_user():
|
def create_user():
|
||||||
user_data = request.get_json()
|
user_data = request.get_json()
|
||||||
|
@ -123,8 +123,8 @@ def create_user():
|
||||||
abort(400, error_message, issue='login-failure')
|
abort(400, error_message, issue='login-failure')
|
||||||
|
|
||||||
|
|
||||||
@index.route('/users', methods=['GET'])
|
@v1_bp.route('/users', methods=['GET'])
|
||||||
@index.route('/users/', methods=['GET'])
|
@v1_bp.route('/users/', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_allowed
|
@anon_allowed
|
||||||
def get_user():
|
def get_user():
|
||||||
|
@ -146,7 +146,7 @@ def get_user():
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
@index.route('/users/<username>/', methods=['PUT'])
|
@v1_bp.route('/users/<username>/', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_allowed
|
@anon_allowed
|
||||||
def update_user(username):
|
def update_user(username):
|
||||||
|
@ -172,7 +172,7 @@ def update_user(username):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@index.route('/repositories/<path:repository>', methods=['PUT'])
|
@v1_bp.route('/repositories/<path:repository>', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers(scope=GrantType.WRITE_REPOSITORY, add_grant_for_status=201)
|
@generate_headers(scope=GrantType.WRITE_REPOSITORY, add_grant_for_status=201)
|
||||||
|
@ -227,7 +227,7 @@ def create_repository(namespace, repository):
|
||||||
return make_response('Created', 201)
|
return make_response('Created', 201)
|
||||||
|
|
||||||
|
|
||||||
@index.route('/repositories/<path:repository>/images', methods=['PUT'])
|
@v1_bp.route('/repositories/<path:repository>/images', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers(scope=GrantType.WRITE_REPOSITORY)
|
@generate_headers(scope=GrantType.WRITE_REPOSITORY)
|
||||||
|
@ -260,7 +260,7 @@ def update_images(namespace, repository):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@index.route('/repositories/<path:repository>/images', methods=['GET'])
|
@v1_bp.route('/repositories/<path:repository>/images', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers(scope=GrantType.READ_REPOSITORY)
|
@generate_headers(scope=GrantType.READ_REPOSITORY)
|
||||||
|
@ -286,7 +286,7 @@ def get_repository_images(namespace, repository):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@index.route('/repositories/<path:repository>/images', methods=['DELETE'])
|
@v1_bp.route('/repositories/<path:repository>/images', methods=['DELETE'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers(scope=GrantType.WRITE_REPOSITORY)
|
@generate_headers(scope=GrantType.WRITE_REPOSITORY)
|
||||||
|
@ -295,14 +295,14 @@ def delete_repository_images(namespace, repository):
|
||||||
abort(501, 'Not Implemented', issue='not-implemented')
|
abort(501, 'Not Implemented', issue='not-implemented')
|
||||||
|
|
||||||
|
|
||||||
@index.route('/repositories/<path:repository>/auth', methods=['PUT'])
|
@v1_bp.route('/repositories/<path:repository>/auth', methods=['PUT'])
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@anon_allowed
|
@anon_allowed
|
||||||
def put_repository_auth(namespace, repository):
|
def put_repository_auth(namespace, repository):
|
||||||
abort(501, 'Not Implemented', issue='not-implemented')
|
abort(501, 'Not Implemented', issue='not-implemented')
|
||||||
|
|
||||||
|
|
||||||
@index.route('/search', methods=['GET'])
|
@v1_bp.route('/search', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_protect
|
@anon_protect
|
||||||
def get_search():
|
def get_search():
|
||||||
|
@ -337,20 +337,3 @@ def get_search():
|
||||||
resp = make_response(json.dumps(data), 200)
|
resp = make_response(json.dumps(data), 200)
|
||||||
resp.mimetype = 'application/json'
|
resp.mimetype = 'application/json'
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
# Note: This is *not* part of the Docker index spec. This is here for our own health check,
|
|
||||||
# since we have nginx handle the _ping below.
|
|
||||||
@index.route('/_internal_ping')
|
|
||||||
@anon_allowed
|
|
||||||
def internal_ping():
|
|
||||||
return make_response('true', 200)
|
|
||||||
|
|
||||||
@index.route('/_ping')
|
|
||||||
@index.route('/_ping')
|
|
||||||
@anon_allowed
|
|
||||||
def ping():
|
|
||||||
# NOTE: any changes made here must also be reflected in the nginx config
|
|
||||||
response = make_response('true', 200)
|
|
||||||
response.headers['X-Docker-Registry-Version'] = '0.6.0'
|
|
||||||
response.headers['X-Docker-Registry-Standalone'] = '0'
|
|
||||||
return response
|
|
|
@ -16,13 +16,13 @@ from auth.permissions import (ReadRepositoryPermission,
|
||||||
ModifyRepositoryPermission)
|
ModifyRepositoryPermission)
|
||||||
from data import model, database
|
from data import model, database
|
||||||
from util import gzipstream
|
from util import gzipstream
|
||||||
|
from endpoints.v1 import v1_bp
|
||||||
from endpoints.decorators import anon_protect
|
from endpoints.decorators import anon_protect
|
||||||
|
|
||||||
|
|
||||||
registry = Blueprint('registry', __name__)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SocketReader(object):
|
class SocketReader(object):
|
||||||
def __init__(self, fp):
|
def __init__(self, fp):
|
||||||
self._fp = fp
|
self._fp = fp
|
||||||
|
@ -93,7 +93,7 @@ def set_cache_headers(f):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/layer', methods=['HEAD'])
|
@v1_bp.route('/images/<image_id>/layer', methods=['HEAD'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@require_completion
|
@require_completion
|
||||||
|
@ -127,7 +127,7 @@ def head_image_layer(namespace, repository, image_id, headers):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/layer', methods=['GET'])
|
@v1_bp.route('/images/<image_id>/layer', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@require_completion
|
@require_completion
|
||||||
|
@ -171,7 +171,7 @@ def get_image_layer(namespace, repository, image_id, headers):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/layer', methods=['PUT'])
|
@v1_bp.route('/images/<image_id>/layer', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@anon_protect
|
@anon_protect
|
||||||
|
@ -277,7 +277,7 @@ def put_image_layer(namespace, repository, image_id):
|
||||||
return make_response('true', 200)
|
return make_response('true', 200)
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/checksum', methods=['PUT'])
|
@v1_bp.route('/images/<image_id>/checksum', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@anon_protect
|
@anon_protect
|
||||||
|
@ -352,7 +352,7 @@ def put_image_checksum(namespace, repository, image_id):
|
||||||
return make_response('true', 200)
|
return make_response('true', 200)
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/json', methods=['GET'])
|
@v1_bp.route('/images/<image_id>/json', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@require_completion
|
@require_completion
|
||||||
|
@ -384,7 +384,7 @@ def get_image_json(namespace, repository, image_id, headers):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/ancestry', methods=['GET'])
|
@v1_bp.route('/images/<image_id>/ancestry', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@require_completion
|
@require_completion
|
||||||
|
@ -438,7 +438,7 @@ def store_checksum(image_storage, checksum):
|
||||||
image_storage.save()
|
image_storage.save()
|
||||||
|
|
||||||
|
|
||||||
@registry.route('/images/<image_id>/json', methods=['PUT'])
|
@v1_bp.route('/images/<image_id>/json', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@extract_namespace_repo_from_session
|
@extract_namespace_repo_from_session
|
||||||
@anon_protect
|
@anon_protect
|
|
@ -11,14 +11,13 @@ from auth.permissions import (ReadRepositoryPermission,
|
||||||
ModifyRepositoryPermission)
|
ModifyRepositoryPermission)
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.decorators import anon_protect
|
from endpoints.decorators import anon_protect
|
||||||
|
from endpoints.v1 import v1_bp
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
tags = Blueprint('tags', __name__)
|
|
||||||
|
|
||||||
|
@v1_bp.route('/repositories/<path:repository>/tags',
|
||||||
@tags.route('/repositories/<path:repository>/tags',
|
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_protect
|
@anon_protect
|
||||||
|
@ -34,7 +33,7 @@ def get_tags(namespace, repository):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@tags.route('/repositories/<path:repository>/tags/<tag>',
|
@v1_bp.route('/repositories/<path:repository>/tags/<tag>',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_protect
|
@anon_protect
|
||||||
|
@ -51,7 +50,7 @@ def get_tag(namespace, repository, tag):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@tags.route('/repositories/<path:repository>/tags/<tag>',
|
@v1_bp.route('/repositories/<path:repository>/tags/<tag>',
|
||||||
methods=['PUT'])
|
methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_protect
|
@anon_protect
|
||||||
|
@ -74,7 +73,7 @@ def put_tag(namespace, repository, tag):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@tags.route('/repositories/<path:repository>/tags/<tag>',
|
@v1_bp.route('/repositories/<path:repository>/tags/<tag>',
|
||||||
methods=['DELETE'])
|
methods=['DELETE'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@anon_protect
|
@anon_protect
|
62
endpoints/v2/__init__.py
Normal file
62
endpoints/v2/__init__.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, make_response
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from endpoints.decorators import anon_protect, anon_allowed
|
||||||
|
from auth.jwt_auth import process_jwt_auth
|
||||||
|
from auth.auth_context import get_grant_user_context
|
||||||
|
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
|
||||||
|
AdministerRepositoryPermission)
|
||||||
|
from data import model
|
||||||
|
from util.http import abort
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
v2_bp = Blueprint('v2', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_repo_permission(permission_class, allow_public=False):
|
||||||
|
def wrapper(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapped(namespace, repo_name, *args, **kwargs):
|
||||||
|
logger.debug('Checking permission %s for repo: %s/%s', permission_class, namespace, repo_name)
|
||||||
|
permission = permission_class(namespace, repo_name)
|
||||||
|
if (permission.can() or
|
||||||
|
(allow_public and
|
||||||
|
model.repository_is_public(namespace, repo_name))):
|
||||||
|
return func(namespace, repo_name, *args, **kwargs)
|
||||||
|
raise abort(401)
|
||||||
|
return wrapped
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
require_repo_read = _require_repo_permission(ReadRepositoryPermission, True)
|
||||||
|
require_repo_write = _require_repo_permission(ModifyRepositoryPermission)
|
||||||
|
require_repo_admin = _require_repo_permission(AdministerRepositoryPermission)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_stream(flask_request):
|
||||||
|
if flask_request.headers.get('transfer-encoding') == 'chunked':
|
||||||
|
return flask_request.environ['wsgi.input']
|
||||||
|
return flask_request.stream
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/')
|
||||||
|
@process_jwt_auth
|
||||||
|
@anon_allowed
|
||||||
|
def v2_support_enabled():
|
||||||
|
response = make_response('true', 200)
|
||||||
|
|
||||||
|
if get_grant_user_context() is None:
|
||||||
|
response = make_response('true', 401)
|
||||||
|
response.headers['WWW-Authenticate'] = 'Bearer realm="192.168.59.3:5000/v2/auth",service="quay"'
|
||||||
|
|
||||||
|
response.headers['Docker-Distribution-API-Version'] = 'registry/2.0'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
from endpoints.v2 import v2auth
|
||||||
|
from endpoints.v2 import manifest
|
||||||
|
from endpoints.v2 import blobs
|
20
endpoints/v2/blobs.py
Normal file
20
endpoints/v2/blobs.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import make_response
|
||||||
|
|
||||||
|
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write, require_repo_admin
|
||||||
|
from auth.jwt_auth import process_jwt_auth
|
||||||
|
from auth.permissions import ReadRepositoryPermission
|
||||||
|
from endpoints.decorators import anon_protect
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/<namespace>/<repo_name>/blobs/<tarsum>', methods=['HEAD'])
|
||||||
|
@process_jwt_auth
|
||||||
|
@require_repo_read
|
||||||
|
@anon_protect
|
||||||
|
def check_blob_existence(namespace, repo_name, tarsum):
|
||||||
|
logger.debug('Fetching blob with tarsum: %s', tarsum)
|
||||||
|
return make_response('Blob {0}'.format(tarsum))
|
46
endpoints/v2/digest_tools.py
Normal file
46
endpoints/v2/digest_tools.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import re
|
||||||
|
import os.path
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
|
||||||
|
Digest = namedtuple('Digest', ['is_tarsum', 'tarsum_version', 'hash_alg', 'hash_bytes'])
|
||||||
|
|
||||||
|
|
||||||
|
DIGEST_PATTERN = r'(tarsum\.(v[\w]+)\+)?([\w]+):([0-9a-f]+)'
|
||||||
|
DIGEST_REGEX = re.compile(DIGEST_PATTERN)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidDigestException(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_digest(digest):
|
||||||
|
""" Returns the digest parsed out to its components. """
|
||||||
|
match = DIGEST_REGEX.match(digest)
|
||||||
|
if match is None or match.end() != len(digest):
|
||||||
|
raise InvalidDigestException('Not a valid digest: %s', digest)
|
||||||
|
|
||||||
|
is_tarsum = match.group(1) is not None
|
||||||
|
return Digest(is_tarsum, match.group(2), match.group(3), match.group(4))
|
||||||
|
|
||||||
|
|
||||||
|
def content_path(digest):
|
||||||
|
""" Returns a relative path to the parsed digest. """
|
||||||
|
parsed = parse_digest(digest)
|
||||||
|
components = []
|
||||||
|
|
||||||
|
if parsed.is_tarsum:
|
||||||
|
components.extend(['tarsum', parsed.tarsum_version])
|
||||||
|
|
||||||
|
prefix = parsed.hash_bytes[0:2].zfill(2)
|
||||||
|
components.extend([parsed.hash_alg, prefix, parsed.hash_bytes])
|
||||||
|
|
||||||
|
return os.path.join(*components)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_digest(content):
|
||||||
|
""" Returns a sha256 hash of the content bytes in digest form. """
|
||||||
|
digest = hashlib.sha256(content)
|
||||||
|
return 'sha256:{0}'.format(digest.hexdigest())
|
63
endpoints/v2/manifest.py
Normal file
63
endpoints/v2/manifest.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from flask import make_response, request
|
||||||
|
|
||||||
|
from app import storage
|
||||||
|
from auth.jwt_auth import process_jwt_auth
|
||||||
|
from auth.permissions import ReadRepositoryPermission
|
||||||
|
from endpoints.decorators import anon_protect
|
||||||
|
from endpoints.v2 import (v2_bp, require_repo_read, require_repo_write, require_repo_admin,
|
||||||
|
get_input_stream)
|
||||||
|
from endpoints.v2 import digest_tools
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_TAG_PATTERN = r'[\w][\w.-]{0,127}'
|
||||||
|
VALID_TAG_REGEX = re.compile(VALID_TAG_PATTERN)
|
||||||
|
|
||||||
|
|
||||||
|
def is_tag_name(reference):
|
||||||
|
match = VALID_TAG_REGEX.match(reference)
|
||||||
|
return match is not None and match.end() == len(reference)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/<namespace>/<repo_name>/manifests/<regex("' + VALID_TAG_PATTERN + '"):tag_name>',
|
||||||
|
methods=['GET'])
|
||||||
|
@process_jwt_auth
|
||||||
|
@require_repo_read
|
||||||
|
@anon_protect
|
||||||
|
def fetch_manifest_by_tagname(namespace, repo_name, tag_name):
|
||||||
|
logger.debug('Fetching tag manifest with name: %s', tag_name)
|
||||||
|
return make_response('Manifest {0}'.format(tag_name))
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/<namespace>/<repo_name>/manifests/<regex("' + VALID_TAG_PATTERN + '"):tag_name>',
|
||||||
|
methods=['PUT'])
|
||||||
|
@process_jwt_auth
|
||||||
|
@require_repo_write
|
||||||
|
@anon_protect
|
||||||
|
def write_manifest_by_tagname(namespace, repo_name, tag_name):
|
||||||
|
manifest_data = request.data
|
||||||
|
logger.debug('Manifest data: %s', manifest_data)
|
||||||
|
response = make_response('OK', 202)
|
||||||
|
response.headers['Docker-Content-Digest'] = digest_tools.sha256_digest(manifest_data)
|
||||||
|
response.headers['Location'] = 'https://fun.com'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/<namespace>/<repo_name>/manifests/<regex("' + digest_tools.DIGEST_PATTERN + '"):tag_digest>',
|
||||||
|
methods=['PUT'])
|
||||||
|
@process_jwt_auth
|
||||||
|
@require_repo_write
|
||||||
|
@anon_protect
|
||||||
|
def write_manifest(namespace, repo_name, tag_digest):
|
||||||
|
logger.debug('Writing tag manifest with name: %s', tag_digest)
|
||||||
|
|
||||||
|
manifest_path = digest_tools.content_path(tag_digest)
|
||||||
|
storage.stream_write('local_us', manifest_path, get_input_stream(request))
|
||||||
|
|
||||||
|
return make_response('Manifest {0}'.format(tag_digest))
|
12
endpoints/v2/registry.py
Normal file
12
endpoints/v2/registry.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from endpoints.v2 import v2_bp
|
||||||
|
|
||||||
|
|
||||||
|
logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route()
|
||||||
|
|
||||||
|
@process_auth
|
||||||
|
@anon_protect
|
94
endpoints/v2/v2auth.py
Normal file
94
endpoints/v2/v2auth.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from flask import request, jsonify, abort
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
from auth.auth import process_auth
|
||||||
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
|
||||||
|
CreateRepositoryPermission)
|
||||||
|
from endpoints.v2 import v2_bp
|
||||||
|
from util.cache import no_cache
|
||||||
|
from util.names import parse_namespace_repository
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SCOPE_REGEX = re.compile(
|
||||||
|
r'repository:([\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+):(((push|pull|\*),)*(push|pull|\*))'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_bp.route('/auth')
|
||||||
|
@process_auth
|
||||||
|
@no_cache
|
||||||
|
def generate_registry_jwt():
|
||||||
|
audience_param = request.args.get('service')
|
||||||
|
logger.debug('Request audience: %s', audience_param)
|
||||||
|
|
||||||
|
scope_param = request.args.get('scope')
|
||||||
|
logger.debug('Scope request: %s', scope_param)
|
||||||
|
|
||||||
|
user = get_authenticated_user()
|
||||||
|
|
||||||
|
access = []
|
||||||
|
if scope_param is not None:
|
||||||
|
match = SCOPE_REGEX.match(scope_param)
|
||||||
|
if match is None or match.end() != len(scope_param):
|
||||||
|
logger.debug('Match: %s', match)
|
||||||
|
logger.debug('End: %s', match.end())
|
||||||
|
logger.debug('len: %s', len(scope_param))
|
||||||
|
logger.warning('Unable to decode repository and actions: %s', scope_param)
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
logger.debug('Match: %s', match.groups())
|
||||||
|
|
||||||
|
namespace_and_repo = match.group(1)
|
||||||
|
actions = match.group(2).split(',')
|
||||||
|
|
||||||
|
namespace, reponame = parse_namespace_repository(namespace_and_repo)
|
||||||
|
if 'pull' in actions and 'push' in actions:
|
||||||
|
repo = model.get_repository(namespace, reponame)
|
||||||
|
if repo:
|
||||||
|
if not ModifyRepositoryPermission(namespace, reponame):
|
||||||
|
abort(403)
|
||||||
|
else:
|
||||||
|
if not CreateRepositoryPermission(namespace):
|
||||||
|
abort(403)
|
||||||
|
logger.debug('Creating repository: %s/%s', namespace, reponame)
|
||||||
|
model.create_repository(namespace, reponame, user)
|
||||||
|
elif 'pull' in actions:
|
||||||
|
if not ReadRepositoryPermission(namespace, reponame):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
|
access.append({
|
||||||
|
'type': 'repository',
|
||||||
|
'name': namespace_and_repo,
|
||||||
|
'actions': actions,
|
||||||
|
})
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
'iss': 'token-issuer',
|
||||||
|
'aud': audience_param,
|
||||||
|
'nbf': int(time.time()),
|
||||||
|
'exp': int(time.time() + 60),
|
||||||
|
'sub': user.username,
|
||||||
|
'access': access,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open('/Users/jake/Projects/registry-v2/ca/quay.host.crt') as cert_file:
|
||||||
|
certificate = ''.join(cert_file.readlines()[1:-1]).rstrip('\n')
|
||||||
|
|
||||||
|
token_headers = {
|
||||||
|
'x5c': [certificate],
|
||||||
|
}
|
||||||
|
|
||||||
|
with open('/Users/jake/Projects/registry-v2/ca/quay.host.key.insecure') as private_key_file:
|
||||||
|
private_key = private_key_file.read()
|
||||||
|
|
||||||
|
return jsonify({'token':jwt.encode(token_data, private_key, 'RS256', headers=token_headers)})
|
10
registry.py
10
registry.py
|
@ -6,10 +6,8 @@ from app import app as application
|
||||||
# Note: We need to import this module to make sure the decorators are registered.
|
# Note: We need to import this module to make sure the decorators are registered.
|
||||||
import endpoints.decorated
|
import endpoints.decorated
|
||||||
|
|
||||||
from endpoints.index import index
|
from endpoints.v1 import v1_bp
|
||||||
from endpoints.tags import tags
|
from endpoints.v2 import v2_bp
|
||||||
from endpoints.registry import registry
|
|
||||||
|
|
||||||
application.register_blueprint(index, url_prefix='/v1')
|
application.register_blueprint(v1_bp, url_prefix='/v1')
|
||||||
application.register_blueprint(tags, url_prefix='/v1')
|
application.register_blueprint(v2_bp, url_prefix='/v2')
|
||||||
application.register_blueprint(registry, url_prefix='/v1')
|
|
||||||
|
|
48
test/test_digest_tools.py
Normal file
48
test/test_digest_tools.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from endpoints.v2.digest_tools import parse_digest, content_path, InvalidDigestException
|
||||||
|
|
||||||
|
class TestParseDigest(unittest.TestCase):
|
||||||
|
def test_parse_good(self):
|
||||||
|
examples = [
|
||||||
|
('tarsum.v123123+sha1:123deadbeef', (True, 'v123123', 'sha1', '123deadbeef')),
|
||||||
|
('tarsum.v1+sha256:123123', (True, 'v1', 'sha256', '123123')),
|
||||||
|
('tarsum.v0+md5:abc', (True, 'v0', 'md5', 'abc')),
|
||||||
|
('sha1:123deadbeef', (False, None, 'sha1', '123deadbeef')),
|
||||||
|
('sha256:123123', (False, None, 'sha256', '123123')),
|
||||||
|
('md5:abc', (False, None, 'md5', 'abc')),
|
||||||
|
]
|
||||||
|
|
||||||
|
for digest, output in examples:
|
||||||
|
self.assertEquals(parse_digest(digest), output)
|
||||||
|
|
||||||
|
def test_parse_fail(self):
|
||||||
|
examples = [
|
||||||
|
'tarsum.v++sha1:123deadbeef',
|
||||||
|
'.v1+sha256:123123',
|
||||||
|
'tarsum.v+md5:abc',
|
||||||
|
'sha1:123deadbeefzxczxv',
|
||||||
|
'sha256123123',
|
||||||
|
'tarsum.v1+',
|
||||||
|
'tarsum.v1123+sha1:',
|
||||||
|
]
|
||||||
|
|
||||||
|
for bad_digest in examples:
|
||||||
|
with self.assertRaises(InvalidDigestException):
|
||||||
|
parse_digest(bad_digest)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDigestPath(unittest.TestCase):
|
||||||
|
def test_paths(self):
|
||||||
|
examples = [
|
||||||
|
('tarsum.v123123+sha1:123deadbeef', 'tarsum/v123123/sha1/12/123deadbeef'),
|
||||||
|
('tarsum.v1+sha256:123123', 'tarsum/v1/sha256/12/123123'),
|
||||||
|
('tarsum.v0+md5:abc', 'tarsum/v0/md5/ab/abc'),
|
||||||
|
('sha1:123deadbeef', 'sha1/12/123deadbeef'),
|
||||||
|
('sha256:123123', 'sha256/12/123123'),
|
||||||
|
('md5:abc', 'md5/ab/abc'),
|
||||||
|
('md5:1', 'md5/01/1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for digest, path in examples:
|
||||||
|
self.assertEquals(content_path(digest), path)
|
|
@ -2,7 +2,7 @@ import logging
|
||||||
|
|
||||||
from app import image_diff_queue
|
from app import image_diff_queue
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.registry import process_image_changes
|
from endpoints.v1.registry import process_image_changes
|
||||||
from workers.worker import Worker
|
from workers.worker import Worker
|
||||||
|
|
||||||
|
|
||||||
|
|
Reference in a new issue