Merge branch 'master' of github.com:coreos-inc/quay

This commit is contained in:
Jimmy Zelinskie 2015-02-20 16:07:12 -05:00
commit 0f62adfa09
11 changed files with 122 additions and 156 deletions

View file

@ -6,6 +6,7 @@ from datetime import datetime
from flask import request, session from flask import request, session
from flask.ext.principal import identity_changed, Identity from flask.ext.principal import identity_changed, Identity
from flask.ext.login import current_user from flask.ext.login import current_user
from flask.sessions import SecureCookieSessionInterface, BadSignature
from base64 import b64decode from base64 import b64decode
import scopes import scopes
@ -22,6 +23,9 @@ from util.http import abort
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SIGNATURE_PREFIX = 'signature='
def _load_user_from_cookie(): def _load_user_from_cookie():
if not current_user.is_anonymous(): if not current_user.is_anonymous():
try: try:
@ -69,7 +73,7 @@ def _validate_and_apply_oauth_token(token):
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
def process_basic_auth(auth): def _process_basic_auth(auth):
normalized = [part.strip() for part in auth.split(' ') if part] normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'basic' or len(normalized) != 2: if normalized[0].lower() != 'basic' or len(normalized) != 2:
logger.debug('Invalid basic auth format.') logger.debug('Invalid basic auth format.')
@ -127,44 +131,41 @@ def process_basic_auth(auth):
logger.debug('Basic auth present but could not be validated.') logger.debug('Basic auth present but could not be validated.')
def process_token(auth): def generate_signed_token(grants):
ser = SecureCookieSessionInterface().get_signing_serializer(app)
data_to_sign = {
'grants': grants,
}
encrypted = ser.dumps(data_to_sign)
return '{0}{1}'.format(SIGNATURE_PREFIX, encrypted)
def _process_signed_grant(auth):
normalized = [part.strip() for part in auth.split(' ') if part] normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'token' or len(normalized) != 2: if normalized[0].lower() != 'token' or len(normalized) != 2:
logger.debug('Not an auth token: %s' % auth) logger.debug('Not a token: %s', auth)
return return
token_details = normalized[1].split(',') if not normalized[1].startswith(SIGNATURE_PREFIX):
logger.debug('Not a signed grant token: %s', auth)
return
if len(token_details) != 1: encrypted = normalized[1][len(SIGNATURE_PREFIX):]
logger.warning('Invalid token format: %s' % auth) ser = SecureCookieSessionInterface().get_signing_serializer(app)
abort(401, message='Invalid token format: %(auth)s', issue='invalid-auth-token', auth=auth)
def safe_get(lst, index, default_value):
try:
return lst[index]
except IndexError:
return default_value
token_vals = {val[0]: safe_get(val, 1, '') for val in
(detail.split('=') for detail in token_details)}
if 'signature' not in token_vals:
logger.warning('Token does not contain signature: %s' % auth)
abort(401, message='Token does not contain a valid signature: %(auth)s',
issue='invalid-auth-token', auth=auth)
try: try:
token_data = model.load_token_data(token_vals['signature']) token_data = ser.loads(encrypted, max_age=app.config['SIGNED_GRANT_EXPIRATION_SEC'])
except BadSignature:
except model.InvalidTokenException: logger.warning('Signed grant could not be validated: %s', encrypted)
logger.warning('Token could not be validated: %s', token_vals['signature']) abort(401, message='Signed grant could not be validated: %(auth)s', issue='invalid-auth-token',
abort(401, message='Token could not be validated: %(auth)s', issue='invalid-auth-token',
auth=auth) auth=auth)
logger.debug('Successfully validated token: %s', token_data.code) logger.debug('Successfully validated signed grant with data: %s', token_data)
set_validated_token(token_data)
identity_changed.send(app, identity=Identity(token_data.code, 'token')) loaded_identity = Identity(None, 'signed_grant')
loaded_identity.provides.update(token_data['grants'])
identity_changed.send(app, identity=loaded_identity)
def process_oauth(func): def process_oauth(func):
@ -192,8 +193,8 @@ def process_auth(func):
if auth: if auth:
logger.debug('Validating auth header: %s' % auth) logger.debug('Validating auth header: %s' % auth)
process_token(auth) _process_signed_grant(auth)
process_basic_auth(auth) _process_basic_auth(auth)
else: else:
logger.debug('No auth header.') logger.debug('No auth header.')

View file

@ -57,6 +57,14 @@ SCOPE_MAX_USER_ROLES.update({
}) })
def repository_read_grant(namespace, repository):
return _RepositoryNeed(namespace, repository, 'read')
def repository_write_grant(namespace, repository):
return _RepositoryNeed(namespace, repository, 'write')
class QuayDeferredPermissionUser(Identity): class QuayDeferredPermissionUser(Identity):
def __init__(self, uuid, auth_type, scopes): def __init__(self, uuid, auth_type, scopes):
super(QuayDeferredPermissionUser, self).__init__(uuid, auth_type) super(QuayDeferredPermissionUser, self).__init__(uuid, auth_type)
@ -226,6 +234,11 @@ class ViewTeamPermission(Permission):
team_member, admin_org) team_member, admin_org)
class AlwaysFailPermission(Permission):
def can(self):
return False
@identity_loaded.connect_via(app) @identity_loaded.connect_via(app)
def on_identity_loaded(sender, identity): def on_identity_loaded(sender, identity):
logger.debug('Identity loaded: %s' % identity) logger.debug('Identity loaded: %s' % identity)
@ -249,5 +262,8 @@ def on_identity_loaded(sender, identity):
logger.debug('Delegate token added permission: {0}'.format(repo_grant)) logger.debug('Delegate token added permission: {0}'.format(repo_grant))
identity.provides.add(repo_grant) identity.provides.add(repo_grant)
elif identity.auth_type == 'signed_grant':
logger.debug('Loaded signed grants identity')
else: else:
logger.error('Unknown identity auth type: %s', identity.auth_type) logger.error('Unknown identity auth type: %s', identity.auth_type)

View file

@ -16,6 +16,11 @@ gzip_types text/plain text/xml text/css
text/javascript application/x-javascript text/javascript application/x-javascript
application/octet-stream; application/octet-stream;
map $proxy_protocol_addr $proper_forwarded_for {
"" $proxy_add_x_forwarded_for;
default $proxy_protocol_addr;
}
upstream web_app_server { upstream web_app_server {
server unix:/tmp/gunicorn_web.sock fail_timeout=0; server unix:/tmp/gunicorn_web.sock fail_timeout=0;
} }
@ -33,3 +38,4 @@ upstream build_manager_controller_server {
upstream build_manager_websocket_server { upstream build_manager_websocket_server {
server localhost:8787; server localhost:8787;
} }

View file

@ -4,7 +4,6 @@ include root-base.conf;
http { http {
include http-base.conf; include http-base.conf;
include rate-limiting.conf; include rate-limiting.conf;
server { server {

View file

@ -4,9 +4,7 @@ include root-base.conf;
http { http {
include http-base.conf; include http-base.conf;
include hosted-http-base.conf; include hosted-http-base.conf;
include rate-limiting.conf; include rate-limiting.conf;
server { server {
@ -25,8 +23,7 @@ http {
server { server {
include proxy-protocol.conf; include proxy-protocol.conf;
include server-base.conf;
include proxy-server-base.conf;
listen 8443 default proxy_protocol; listen 8443 default proxy_protocol;

View file

@ -1,87 +0,0 @@
# vim: ft=nginx
client_body_temp_path /var/log/nginx/client_body 1 2;
server_name _;
keepalive_timeout 5;
if ($args ~ "_escaped_fragment_") {
rewrite ^ /snapshot$uri;
}
proxy_set_header X-Forwarded-For $proxy_protocol_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_set_header Transfer-Encoding $http_transfer_encoding;
location / {
proxy_pass http://web_app_server;
limit_req zone=webapp burst=25 nodelay;
}
location /realtime {
proxy_pass http://web_app_server;
proxy_buffering off;
proxy_request_buffering off;
}
location /v1/repositories/ {
proxy_buffering off;
proxy_request_buffering off;
proxy_pass http://registry_app_server;
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
client_max_body_size 20G;
limit_req zone=repositories burst=5 nodelay;
}
location /v1/ {
proxy_buffering off;
proxy_request_buffering off;
proxy_pass http://registry_app_server;
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
client_max_body_size 20G;
}
location /c1/ {
proxy_buffering off;
proxy_request_buffering off;
proxy_pass http://verbs_app_server;
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
limit_req zone=api burst=5 nodelay;
}
location /static/ {
# checks for static file, if not found proxy to app
alias /static/;
}
location /v1/_ping {
add_header Content-Type text/plain;
add_header X-Docker-Registry-Version 0.6.0;
add_header X-Docker-Registry-Standalone 0;
return 200 'true';
}
location ~ ^/b1/controller(/?)(.*) {
proxy_pass http://build_manager_controller_server/$2;
}
location ~ ^/b1/socket(/?)(.*) {
proxy_pass http://build_manager_websocket_server/$2;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}

View file

@ -1,7 +1,16 @@
# vim: ft=nginx # vim: ft=nginx
# Check the Authorization header and, if it is empty, use their proxy protocol
# IP, else use the header as their unique identifier for rate limiting.
# Enterprise users will never be using proxy protocol, thus the value will be
# empty string. This means they will not get rate limited.
map $http_authorization $registry_bucket {
"" $proxy_protocol_addr;
default $http_authorization;
}
limit_req_zone $proxy_protocol_addr zone=webapp:10m rate=25r/s; limit_req_zone $proxy_protocol_addr zone=webapp:10m rate=25r/s;
limit_req_zone $proxy_protocol_addr zone=repositories:10m rate=1r/s;
limit_req_zone $proxy_protocol_addr zone=api:10m rate=1r/s; limit_req_zone $proxy_protocol_addr zone=api:10m rate=1r/s;
limit_req_zone $registry_bucket zone=repositories:10m rate=1r/s;
limit_req_status 429; limit_req_status 429;
limit_req_log_level warn; limit_req_log_level warn;

View file

@ -3,16 +3,13 @@
client_body_temp_path /var/log/nginx/client_body 1 2; client_body_temp_path /var/log/nginx/client_body 1 2;
server_name _; server_name _;
set_real_ip_from 172.17.0.0/16;
real_ip_header X-Forwarded-For;
keepalive_timeout 5; keepalive_timeout 5;
if ($args ~ "_escaped_fragment_") { if ($args ~ "_escaped_fragment_") {
rewrite ^ /snapshot$uri; rewrite ^ /snapshot$uri;
} }
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proper_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_redirect off; proxy_redirect off;
@ -21,6 +18,8 @@ proxy_set_header Transfer-Encoding $http_transfer_encoding;
location / { location / {
proxy_pass http://web_app_server; proxy_pass http://web_app_server;
limit_req zone=webapp;
} }
location /realtime { location /realtime {
@ -29,6 +28,18 @@ location /realtime {
proxy_request_buffering off; proxy_request_buffering off;
} }
location /v1/repositories/ {
proxy_buffering off;
proxy_request_buffering off;
proxy_pass http://registry_app_server;
proxy_read_timeout 2000;
proxy_temp_path /var/log/nginx/proxy_temp 1 2;
limit_req zone=repositories;
}
location /v1/ { location /v1/ {
proxy_buffering off; proxy_buffering off;
@ -47,6 +58,8 @@ location /c1/ {
proxy_pass http://verbs_app_server; proxy_pass http://verbs_app_server;
proxy_temp_path /var/log/nginx/proxy_temp 1 2; proxy_temp_path /var/log/nginx/proxy_temp 1 2;
limit_req zone=api;
} }
location /static/ { location /static/ {

View file

@ -197,4 +197,7 @@ class DefaultConfig(object):
SYSTEM_SERVICE_BLACKLIST = [] SYSTEM_SERVICE_BLACKLIST = []
# Temporary tag expiration in seconds, this may actually be longer based on GC policy # Temporary tag expiration in seconds, this may actually be longer based on GC policy
PUSH_TEMP_TAG_EXPIRATION_SEC = 60 * 60 PUSH_TEMP_TAG_EXPIRATION_SEC = 60 * 60 # One hour per layer
# Signed registry grant token expiration in seconds
SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull

View file

@ -9,12 +9,13 @@ from collections import OrderedDict
from data import model from data import model
from data.model import oauth from data.model import oauth
from app import app, authentication, userevents, storage from app import app, authentication, userevents, storage
from auth.auth import process_auth from auth.auth import process_auth, generate_signed_token
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
from util.names import parse_repository_name from util.names import parse_repository_name
from util.useremails import send_confirmation_email from util.useremails import send_confirmation_email
from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission, from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
ReadRepositoryPermission, CreateRepositoryPermission) ReadRepositoryPermission, CreateRepositoryPermission,
AlwaysFailPermission, repository_read_grant, repository_write_grant)
from util.http import abort from util.http import abort
from endpoints.trackhelper import track_and_log from endpoints.trackhelper import track_and_log
@ -26,7 +27,13 @@ logger = logging.getLogger(__name__)
index = Blueprint('index', __name__) index = Blueprint('index', __name__)
def generate_headers(role='read'):
class GrantType(object):
READ_REPOSITORY = 'read'
WRITE_REPOSITORY = 'write'
def generate_headers(scope=GrantType.READ_REPOSITORY):
def decorator_method(f): def decorator_method(f):
@wraps(f) @wraps(f)
def wrapper(namespace, repository, *args, **kwargs): def wrapper(namespace, repository, *args, **kwargs):
@ -35,12 +42,6 @@ def generate_headers(role='read'):
# Setting session namespace and repository # Setting session namespace and repository
session['namespace'] = namespace session['namespace'] = namespace
session['repository'] = repository session['repository'] = repository
if get_authenticated_user():
session['username'] = get_authenticated_user().username
else:
session.pop('username', None)
# We run our index and registry on the same hosts for now # We run our index and registry on the same hosts for now
registry_server = urlparse.urlparse(request.url).netloc registry_server = urlparse.urlparse(request.url).netloc
response.headers['X-Docker-Endpoints'] = registry_server response.headers['X-Docker-Endpoints'] = registry_server
@ -48,16 +49,23 @@ def generate_headers(role='read'):
has_token_request = request.headers.get('X-Docker-Token', '') has_token_request = request.headers.get('X-Docker-Token', '')
if has_token_request: if has_token_request:
repo = model.get_repository(namespace, repository) permission = AlwaysFailPermission()
if repo: grants = []
token = model.create_access_token(repo, role, 'pushpull-token') if scope == GrantType.READ_REPOSITORY:
token_str = 'signature=%s' % token.code permission = ReadRepositoryPermission(namespace, repository)
response.headers['WWW-Authenticate'] = token_str grants.append(repository_read_grant(namespace, repository))
response.headers['X-Docker-Token'] = token_str elif scope == GrantType.WRITE_REPOSITORY:
else: permission = ModifyRepositoryPermission(namespace, repository)
logger.info('Token request in non-existing repo: %s/%s' % grants.append(repository_write_grant(namespace, repository))
(namespace, repository))
if permission.can():
# Generate a signed grant which expires here
signature = generate_signed_token(grants)
response.headers['WWW-Authenticate'] = signature
response.headers['X-Docker-Token'] = signature
else:
logger.warning('Registry request with invalid credentials on repository: %s/%s',
namespace, repository)
return response return response
return wrapper return wrapper
return decorator_method return decorator_method
@ -186,7 +194,7 @@ def update_user(username):
@index.route('/repositories/<path:repository>', methods=['PUT']) @index.route('/repositories/<path:repository>', methods=['PUT'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(role='write') @generate_headers(scope=GrantType.WRITE_REPOSITORY)
def create_repository(namespace, repository): def create_repository(namespace, repository):
logger.debug('Parsing image descriptions') logger.debug('Parsing image descriptions')
image_descriptions = json.loads(request.data.decode('utf8')) image_descriptions = json.loads(request.data.decode('utf8'))
@ -228,7 +236,7 @@ def create_repository(namespace, repository):
@index.route('/repositories/<path:repository>/images', methods=['PUT']) @index.route('/repositories/<path:repository>/images', methods=['PUT'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(role='write') @generate_headers(scope=GrantType.WRITE_REPOSITORY)
def update_images(namespace, repository): def update_images(namespace, repository):
permission = ModifyRepositoryPermission(namespace, repository) permission = ModifyRepositoryPermission(namespace, repository)
@ -273,7 +281,7 @@ def update_images(namespace, repository):
@index.route('/repositories/<path:repository>/images', methods=['GET']) @index.route('/repositories/<path:repository>/images', methods=['GET'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(role='read') @generate_headers(scope=GrantType.READ_REPOSITORY)
def get_repository_images(namespace, repository): def get_repository_images(namespace, repository):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -307,7 +315,7 @@ def get_repository_images(namespace, repository):
@index.route('/repositories/<path:repository>/images', methods=['DELETE']) @index.route('/repositories/<path:repository>/images', methods=['DELETE'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(role='write') @generate_headers(scope=GrantType.WRITE_REPOSITORY)
def delete_repository_images(namespace, repository): def delete_repository_images(namespace, repository):
abort(501, 'Not Implemented', issue='not-implemented') abort(501, 'Not Implemented', issue='not-implemented')

View file

@ -455,14 +455,15 @@ def put_image_json(namespace, repository, image_id):
issue='invalid-request', image_id=image_id) issue='invalid-request', image_id=image_id)
logger.debug('Looking up repo image') logger.debug('Looking up repo image')
repo_image = model.get_repo_image_extended(namespace, repository, image_id)
if not repo_image:
logger.debug('Image not found, creating image')
repo = model.get_repository(namespace, repository) repo = model.get_repository(namespace, repository)
if repo is None: if repo is None:
abort(404, 'Repository does not exist: %(namespace)s/%(repository)s', issue='no-repo', abort(404, 'Repository does not exist: %(namespace)s/%(repository)s', issue='no-repo',
namespace=namespace, repository=repository) namespace=namespace, repository=repository)
repo_image = model.get_repo_image_extended(namespace, repository, image_id)
if not repo_image:
logger.debug('Image not found, creating image')
username = get_authenticated_user() and get_authenticated_user().username username = get_authenticated_user() and get_authenticated_user().username
repo_image = model.find_create_or_link_image(image_id, repo, username, {}, repo_image = model.find_create_or_link_image(image_id, repo, username, {},
store.preferred_locations[0]) store.preferred_locations[0])