From 458b69953ad9cebb697a220cf1df2feacee367b5 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Fri, 20 Sep 2013 18:38:17 -0400 Subject: [PATCH] Integrate flask-principal in order to provide RBAC. --- app.py | 7 ++- auth.py | 36 ++++++------- database.py | 39 ++++++++++---- index.py | 133 +++++++++++++++++++++++++++++------------------ model.py | 59 +++++++++++++-------- permissions.py | 60 +++++++++++++++++++++ requirements.txt | 3 +- wsgi.py | 3 +- 8 files changed, 237 insertions(+), 103 deletions(-) create mode 100644 permissions.py diff --git a/app.py b/app.py index 9ea362887..29f43d9bc 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,13 @@ from flask import Flask, make_response, jsonify +from flask.ext.principal import Principal app = Flask(__name__) +Principal(app, use_sessions=False) + @app.route('/_ping') @app.route('/v1/_ping') def ping(): - response = make_response('true', 200); + response = make_response('true', 200) response.headers['X-Docker-Registry-Version'] = '0.6.0' - return response \ No newline at end of file + return response diff --git a/auth.py b/auth.py index e362c4c64..b3cb6d59f 100644 --- a/auth.py +++ b/auth.py @@ -1,30 +1,28 @@ import logging from functools import wraps -from werkzeug import LocalProxy -from flask import request, make_response, _request_ctx_stack +from flask import request, make_response, _request_ctx_stack, abort +from flask.ext.principal import identity_changed, Identity from base64 import b64decode import model +from app import app from util import parse_namespace_repository + logger = logging.getLogger(__name__) -def _get_user(): +def get_authenticated_user(): return getattr(_request_ctx_stack.top, 'authenticated_user', None) -def _get_token(): +def get_validated_token(): return getattr(_request_ctx_stack.top, 'validated_token', None) -authenticated_user = LocalProxy(_get_user) -validated_token = LocalProxy(_get_token) - - -def check_basic_auth(): +def process_basic_auth(): auth = request.headers.get('authorization', '') normalized = [part.strip() for part in auth.split(' ') if part] if normalized[0].lower() != 'basic' or len(normalized) != 2: @@ -36,20 +34,22 @@ def check_basic_auth(): if len(credentials) != 2: logger.debug('Invalid basic auth credential formet.') - authenticated = model.verify_user(credentials[0], credentials[1]) + authenticated = model.verify_user(credentials[0], credentials[1]) if authenticated: logger.debug('Successfully validated user: %s' % authenticated.username) ctx = _request_ctx_stack.top ctx.authenticated_user = authenticated + identity_changed.send(app, identity=Identity(authenticated.username)) + return True # We weren't able to authenticate via basic auth. return False -def check_token(): +def process_token(): auth = request.headers.get('authorization', '') logger.debug('Validating auth token: %s' % auth) @@ -64,7 +64,7 @@ def check_token(): logger.debug('Invalid token format.') return False - token_vals = {val[0]: val[1] for val in + token_vals = {val[0]: val[1] for val in (detail.split('=') for detail in token_details)} if ('signature' not in token_vals or 'access' not in token_vals or 'repository' not in token_vals): @@ -74,14 +74,15 @@ def check_token(): unquoted = token_vals['repository'][1:-1] namespace, repository = parse_namespace_repository(unquoted) logger.debug('Validing signature: %s' % token_vals['signature']) - validated = model.verify_token(namespace, repository, - token_vals['signature']) + validated = model.verify_token(token_vals['signature']) if validated: logger.debug('Successfully validated token: %s' % validated.code) ctx = _request_ctx_stack.top ctx.validated_token = validated + identity_changed.send(app, identity=Identity(validated.code)) + return True # WE weren't able to authenticate the token @@ -89,11 +90,10 @@ def check_token(): return False -def requires_auth(f): +def process_auth(f): @wraps(f) def wrapper(*args, **kwargs): - if not check_basic_auth() and not check_token(): - return make_response('Invalid credentials.', 401) - logger.debug('Successfully authenticated user or token.') + process_token() + process_basic_auth() return f(*args, **kwargs) return wrapper diff --git a/database.py b/database.py index 9aba27678..68e4c4417 100644 --- a/database.py +++ b/database.py @@ -15,16 +15,20 @@ class BaseModel(Model): class User(BaseModel): - username = CharField(primary_key=True) + username = CharField(unique=True) password_hash = CharField() - email = CharField() + email = CharField(unique=True) verified = BooleanField(default=False) +class Visibility(BaseModel): + name = CharField() + + class Repository(BaseModel): - repository_id = PrimaryKeyField() namespace = CharField() name = CharField() + visibility = ForeignKeyField(Visibility) class Meta: database = db @@ -34,6 +38,16 @@ class Repository(BaseModel): ) +class Role(BaseModel): + name = CharField() + + +class RepositoryPermission(BaseModel): + user = ForeignKeyField(User) + repository = ForeignKeyField(Repository) + role = ForeignKeyField(Role) + + def random_string_generator(length=16): def random_string(): random = SystemRandom() @@ -43,14 +57,14 @@ def random_string_generator(length=16): class AccessToken(BaseModel): - token_id = PrimaryKeyField() - code = CharField(default=random_string_generator()) + code = CharField(default=random_string_generator(), unique=True) + user = ForeignKeyField(User) repository = ForeignKeyField(Repository) created = DateTimeField(default=datetime.now) class Image(BaseModel): - image_id = CharField(primary_key=True) + image_id = CharField(unique=True) checksum = CharField(null=True) @@ -67,8 +81,15 @@ class RepositoryImage(BaseModel): ) -def create_tables(): - create_model_tables([User, Repository, Image, RepositoryImage, AccessToken]) +def intiialize_db(): + create_model_tables([User, Repository, Image, RepositoryImage, AccessToken, + Role, RepositoryPermission, Visibility]) + Role.create(name='admin') + Role.create(name='write') + Role.create(name='read') + Visibility.create(name='public') + Visibility.create(name='private') + if __name__ == '__main__': - create_tables() + intiialize_db() diff --git a/index.py b/index.py index 2ff583feb..f9b0cf541 100644 --- a/index.py +++ b/index.py @@ -3,12 +3,14 @@ import urllib import json import logging -from flask import request, make_response, jsonify +from flask import request, make_response, jsonify, abort from functools import wraps from app import app -from auth import requires_auth, authenticated_user, validated_token +from auth import process_auth, get_authenticated_user, get_validated_token from util import parse_namespace_repository +from permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, + UserPermission) import model @@ -34,10 +36,12 @@ def generate_headers(access): response.headers['X-Docker-Endpoints'] = REGISTRY_SERVER - if request.headers.get('X-Docker-Token', ''): - repo = model.create_or_fetch_repository(namespace, repository) - token = model.create_access_token(repo) - token_str = ('Token signature=%s,repository="%s/%s",access=%s' % + has_token_request = request.headers.get('X-Docker-Token', '') + + if has_token_request and get_authenticated_user(): + repo = model.get_repository(namespace, repository) + token = model.create_access_token(get_authenticated_user(), repo) + token_str = ('Token signature=%s,repository="%s/%s",access=%s' % (token.code, namespace, repository, access)) response.headers['WWW-Authenticate'] = token_str response.headers['X-Docker-Token'] = token_str @@ -58,44 +62,69 @@ def create_user(): @app.route('/v1/users', methods=['GET']) @app.route('/v1/users/', methods=['GET']) -@requires_auth +@process_auth def get_user(): + if not get_authenticated_user(): + abort(401) + return jsonify({ - 'username': authenticated_user.username, - 'email': authenticated_user.email, + 'username': get_authenticated_user().username, + 'email': get_authenticated_user().email, }) @app.route('/v1/users//', methods=['PUT']) -@requires_auth +@process_auth def update_user(username): - if authenticated_user.username != username: - return make_response('Forbidden', 403) + permission = UserPermission(username) - update_request = request.get_json() + if permission.can(): + update_request = request.get_json() - if update_request.has_key('password'): - logger.debug('Updating user password.') - model.change_password(authenticated_user, update_request['password']) + if 'password' in update_request: + logger.debug('Updating user password.') + model.change_password(get_authenticated_user(), + update_request['password']) - if update_request.has_key('email'): - logger.debug('Updating user email') - model.update_email(authenticated_user, update_request['email']) + if 'email' in update_request: + logger.debug('Updating user email') + model.update_email(get_authenticated_user(), update_request['email']) - return jsonify({ - 'username': authenticated_user.username, - 'email': authenticated_user.email, - }) + return jsonify({ + 'username': get_authenticated_user().username, + 'email': get_authenticated_user().email, + }) + + abort(403) @app.route('/v1/repositories/', methods=['PUT']) -@requires_auth +@process_auth @parse_repository_name @generate_headers(access='write') def create_repository(namespace, repository): + # TODO check that the user is the same as indicated by the namespace + image_descriptions = json.loads(request.data) - repo = model.create_or_fetch_repository(namespace, repository) + repo = model.get_repository(namespace, repository) + + auth_fail_response = 403 + if not get_validated_token() or get_authenticated_user(): + auth_fail_response = 401 + + if repo: + permission = ModifyRepositoryPermission(namespace, repository) + if not permission.can(): + abort(auth_fail_response) + else: + if not get_authenticated_user(): + abort(auth_fail_response) + + logger.debug('Creaing repository with owner: %s' % + get_authenticated_user().username) + repo = model.create_repository(namespace, repository, + get_authenticated_user()) new_repo_images = {desc['id']: desc for desc in image_descriptions} added_images = dict(new_repo_images) @@ -114,50 +143,52 @@ def create_repository(namespace, repository): @app.route('/v1/repositories//images', methods=['PUT']) -@requires_auth +@process_auth @parse_repository_name @generate_headers(access='write') def update_images(namespace, repository): - image_with_checksums = json.loads(request.data) + permission = ModifyRepositoryPermission(namespace, repository) - for image in image_with_checksums: - model.set_image_checksum(image['id'], image['checksum']) + if permission.can(): + image_with_checksums = json.loads(request.data) - return make_response('Updated', 204) + for image in image_with_checksums: + model.set_image_checksum(image['id'], image['checksum']) + + return make_response('Updated', 204) + + abort(403) @app.route('/v1/repositories//images', methods=['GET']) -@requires_auth +@process_auth @parse_repository_name @generate_headers(access='read') def get_repository_images(namespace, repository): - if validated_token and (validated_token.repository.name != repository or - validated_token.repository.namespace != namespace): - return make_response('Forbidden', 403) + permission = ReadRepositoryPermission(namespace, repository) - #TODO check user has permission for repository + # TODO invalidate token? - #TODO invalidate token + if permission.can(): + all_images = [] + for image in model.get_repository_images(namespace, repository): + new_image_view = { + 'id': image.image_id, + 'tag': image.repositoryimage.tag, + 'checksum': image.checksum, + } + all_images.append(new_image_view) - all_images = [] - for image in model.get_repository_images(namespace, repository): - new_image_view = { - 'id': image.image_id, - 'tag': image.repositoryimage.tag, - 'checksum': image.checksum, - } - all_images.append(new_image_view) + resp = make_response(json.dumps(all_images), 200) + resp.mimetype = 'application/json' - resp = make_response(json.dumps(all_images), 200) - resp.mimetype = 'application/json' + return resp - repo = model.create_or_fetch_repository(namespace, repository) - - return resp + abort(403) @app.route('/v1/repositories//images', methods=['DELETE']) -@requires_auth +@process_auth @parse_repository_name @generate_headers(access='delete') def delete_repository_images(namespace, repository): @@ -172,4 +203,4 @@ def put_repository_auth(namespace, repository): @app.route('/v1/search', methods=['GET']) def get_search(): - pass \ No newline at end of file + pass diff --git a/model.py b/model.py index 1ba35c60a..b5532ce98 100644 --- a/model.py +++ b/model.py @@ -1,6 +1,11 @@ import bcrypt +import logging -from database import User, Repository, Image, RepositoryImage, AccessToken +from database import (User, Repository, Image, RepositoryImage, AccessToken, + RepositoryPermission, Visibility, Role) + + +logger = logging.getLogger(__name__) def create_user(username, password, email): @@ -23,6 +28,13 @@ def verify_user(username, password): return None +def verify_token(code): + try: + return AccessToken.get(code=code) + except AccessToken.DoesNotExist: + return None + + def change_password(user, new_password): pw_hash = bcrypt.hashpw(new_password, bcrypt.gensalt()) user.password_hash = pw_hash @@ -35,12 +47,27 @@ def update_email(user, new_email): user.save() -def create_or_fetch_repository(namespace, name): +def get_all_repo_permissions(user): + select = User.select(User, Repository, RepositoryPermission) + joined = select.join(RepositoryPermission).join(Repository) + return joined.where(User.username == user.username) + + +def get_repository(namespace, name): try: - repo = Repository.get(Repository.name == name and + return Repository.get(Repository.name == name and Repository.namespace == namespace) except Repository.DoesNotExist: - repo = Repository.create(namespace=namespace, name=name) + return None + + +def create_repository(namespace, name, owner): + private = Visibility.get(name='private') + repo = Repository.create(namespace=namespace, name=name, + visibility=private) + admin = Role.get(name='admin') + permission = RepositoryPermission.create(user=owner, repository=repo, + role=admin) return repo @@ -65,26 +92,16 @@ def assign_image_repository(repository, image, tag): def get_repository_images(namespace_name, repository_name): select = Image.select(Image, RepositoryImage) joined = select.join(RepositoryImage).join(Repository) - return joined.where(Repository.name == repository_name and + return joined.where(Repository.name == repository_name and Repository.namespace == namespace_name) -def create_access_token(repository): - new_token = AccessToken.create(repository=repository) +def create_access_token(repository, user): + new_token = AccessToken.create(user=user, repository=repository) return new_token -def verify_token(namespace_name, repository_name, code): - try: - fetched_repo = Repository.get(Repository.namespace == namespace_name and - Repository.name == repository_name) - except Repository.DoesNotExist: - return None - - try: - fetched = AccessToken.get(AccessToken.code == code and - AccessToken.repository == fetched_repo) - except AccessToken.DoesNotExist: - return None - - return fetched +def get_user_repo_permissions(user, repository): + select = RepositoryPermission.select() + return select.where(RepositoryPermission.user == user and + RepositoryPermission.repository == repository) diff --git a/permissions.py b/permissions.py new file mode 100644 index 000000000..0b678ceb1 --- /dev/null +++ b/permissions.py @@ -0,0 +1,60 @@ +import logging + +from flask.ext.principal import identity_loaded, UserNeed, Permission +from collections import namedtuple + +import model + +from app import app +from auth import get_authenticated_user, get_validated_token + + +logger = logging.getLogger(__name__) + + +_RepositoryNeed = namedtuple('repository', ['namespace', 'name', 'role']) + + +class ModifyRepositoryPermission(Permission): + def __init__(self, namespace, name): + admin_need = _RepositoryNeed(namespace, name, 'admin') + write_need = _RepositoryNeed(namespace, name, 'write') + super(ModifyRepositoryPermission, self).__init__(admin_need, write_need) + + +class ReadRepositoryPermission(Permission): + def __init__(self, namespace, name): + admin_need = _RepositoryNeed(namespace, name, 'admin') + write_need = _RepositoryNeed(namespace, name, 'write') + read_need = _RepositoryNeed(namespace, name, 'read') + super(ReadRepositoryPermission, self).__init__(admin_need, write_need) + + +class UserPermission(Permission): + def __init__(self, username): + user_need = UserNeed(username) + super(UserPermission, self).__init__(user_need) + + +@identity_loaded.connect_via(app) +def on_identity_loaded(sender, identity): + # We have verified an identity, load in all of the permissions + if get_authenticated_user(): + identity.provides.add(UserNeed(get_authenticated_user().username)) + + for user in model.get_all_repo_permissions(get_authenticated_user()): + grant = _RepositoryNeed(user.repositorypermission.repository.namespace, + user.repositorypermission.repository.name, + user.repositorypermission.role.name) + logger.debug('User added permission: {0}'.format(grant)) + identity.provides.add(grant) + + if get_validated_token(): + query = model.get_user_repo_permissions(get_validated_token().user, + get_validated_token().repository) + for permission in query: + t_grant = _RepositoryNeed(get_validated_token().repository.namespace, + get_validated_token().repository.name, + permission.role.name) + logger.debug('Token added permission: {0}'.format(t_grant)) + identity.provides.add(t_grant) diff --git a/requirements.txt b/requirements.txt index 6bf2e9686..253d526d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ peewee flask -py-bcrypt \ No newline at end of file +py-bcrypt +Flask-Principal \ No newline at end of file diff --git a/wsgi.py b/wsgi.py index 7762c1ae5..66f29524d 100644 --- a/wsgi.py +++ b/wsgi.py @@ -5,7 +5,8 @@ from app import app import index if __name__ == '__main__': - FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - %(funcName)s - %(message)s' + FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - ' + \ + '%(funcName)s - %(message)s' logging.basicConfig(format=FORMAT, level=logging.DEBUG) app.run(port=5002, debug=True)