Integrate flask-principal in order to provide RBAC.

This commit is contained in:
yackob03 2013-09-20 18:38:17 -04:00
parent 8e169b1026
commit 458b69953a
8 changed files with 237 additions and 103 deletions

5
app.py
View file

@ -1,10 +1,13 @@
from flask import Flask, make_response, jsonify from flask import Flask, make_response, jsonify
from flask.ext.principal import Principal
app = Flask(__name__) app = Flask(__name__)
Principal(app, use_sessions=False)
@app.route('/_ping') @app.route('/_ping')
@app.route('/v1/_ping') @app.route('/v1/_ping')
def ping(): def ping():
response = make_response('true', 200); response = make_response('true', 200)
response.headers['X-Docker-Registry-Version'] = '0.6.0' response.headers['X-Docker-Registry-Version'] = '0.6.0'
return response return response

32
auth.py
View file

@ -1,30 +1,28 @@
import logging import logging
from functools import wraps from functools import wraps
from werkzeug import LocalProxy from flask import request, make_response, _request_ctx_stack, abort
from flask import request, make_response, _request_ctx_stack from flask.ext.principal import identity_changed, Identity
from base64 import b64decode from base64 import b64decode
import model import model
from app import app
from util import parse_namespace_repository from util import parse_namespace_repository
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_user(): def get_authenticated_user():
return getattr(_request_ctx_stack.top, 'authenticated_user', None) 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) return getattr(_request_ctx_stack.top, 'validated_token', None)
authenticated_user = LocalProxy(_get_user) def process_basic_auth():
validated_token = LocalProxy(_get_token)
def check_basic_auth():
auth = request.headers.get('authorization', '') auth = request.headers.get('authorization', '')
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:
@ -43,13 +41,15 @@ def check_basic_auth():
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.authenticated_user = authenticated ctx.authenticated_user = authenticated
identity_changed.send(app, identity=Identity(authenticated.username))
return True return True
# We weren't able to authenticate via basic auth. # We weren't able to authenticate via basic auth.
return False return False
def check_token(): def process_token():
auth = request.headers.get('authorization', '') auth = request.headers.get('authorization', '')
logger.debug('Validating auth token: %s' % auth) logger.debug('Validating auth token: %s' % auth)
@ -74,14 +74,15 @@ def check_token():
unquoted = token_vals['repository'][1:-1] unquoted = token_vals['repository'][1:-1]
namespace, repository = parse_namespace_repository(unquoted) namespace, repository = parse_namespace_repository(unquoted)
logger.debug('Validing signature: %s' % token_vals['signature']) logger.debug('Validing signature: %s' % token_vals['signature'])
validated = model.verify_token(namespace, repository, validated = model.verify_token(token_vals['signature'])
token_vals['signature'])
if validated: if validated:
logger.debug('Successfully validated token: %s' % validated.code) logger.debug('Successfully validated token: %s' % validated.code)
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.validated_token = validated ctx.validated_token = validated
identity_changed.send(app, identity=Identity(validated.code))
return True return True
# WE weren't able to authenticate the token # WE weren't able to authenticate the token
@ -89,11 +90,10 @@ def check_token():
return False return False
def requires_auth(f): def process_auth(f):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if not check_basic_auth() and not check_token(): process_token()
return make_response('Invalid credentials.', 401) process_basic_auth()
logger.debug('Successfully authenticated user or token.')
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper

View file

@ -15,16 +15,20 @@ class BaseModel(Model):
class User(BaseModel): class User(BaseModel):
username = CharField(primary_key=True) username = CharField(unique=True)
password_hash = CharField() password_hash = CharField()
email = CharField() email = CharField(unique=True)
verified = BooleanField(default=False) verified = BooleanField(default=False)
class Visibility(BaseModel):
name = CharField()
class Repository(BaseModel): class Repository(BaseModel):
repository_id = PrimaryKeyField()
namespace = CharField() namespace = CharField()
name = CharField() name = CharField()
visibility = ForeignKeyField(Visibility)
class Meta: class Meta:
database = db 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_generator(length=16):
def random_string(): def random_string():
random = SystemRandom() random = SystemRandom()
@ -43,14 +57,14 @@ def random_string_generator(length=16):
class AccessToken(BaseModel): class AccessToken(BaseModel):
token_id = PrimaryKeyField() code = CharField(default=random_string_generator(), unique=True)
code = CharField(default=random_string_generator()) user = ForeignKeyField(User)
repository = ForeignKeyField(Repository) repository = ForeignKeyField(Repository)
created = DateTimeField(default=datetime.now) created = DateTimeField(default=datetime.now)
class Image(BaseModel): class Image(BaseModel):
image_id = CharField(primary_key=True) image_id = CharField(unique=True)
checksum = CharField(null=True) checksum = CharField(null=True)
@ -67,8 +81,15 @@ class RepositoryImage(BaseModel):
) )
def create_tables(): def intiialize_db():
create_model_tables([User, Repository, Image, RepositoryImage, AccessToken]) 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__': if __name__ == '__main__':
create_tables() intiialize_db()

View file

@ -3,12 +3,14 @@ import urllib
import json import json
import logging import logging
from flask import request, make_response, jsonify from flask import request, make_response, jsonify, abort
from functools import wraps from functools import wraps
from app import app 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 util import parse_namespace_repository
from permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
UserPermission)
import model import model
@ -34,9 +36,11 @@ def generate_headers(access):
response.headers['X-Docker-Endpoints'] = REGISTRY_SERVER response.headers['X-Docker-Endpoints'] = REGISTRY_SERVER
if request.headers.get('X-Docker-Token', ''): has_token_request = request.headers.get('X-Docker-Token', '')
repo = model.create_or_fetch_repository(namespace, repository)
token = model.create_access_token(repo) 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_str = ('Token signature=%s,repository="%s/%s",access=%s' %
(token.code, namespace, repository, access)) (token.code, namespace, repository, access))
response.headers['WWW-Authenticate'] = token_str response.headers['WWW-Authenticate'] = token_str
@ -58,44 +62,69 @@ def create_user():
@app.route('/v1/users', methods=['GET']) @app.route('/v1/users', methods=['GET'])
@app.route('/v1/users/', methods=['GET']) @app.route('/v1/users/', methods=['GET'])
@requires_auth @process_auth
def get_user(): def get_user():
if not get_authenticated_user():
abort(401)
return jsonify({ return jsonify({
'username': authenticated_user.username, 'username': get_authenticated_user().username,
'email': authenticated_user.email, 'email': get_authenticated_user().email,
}) })
@app.route('/v1/users/<username>/', methods=['PUT']) @app.route('/v1/users/<username>/', methods=['PUT'])
@requires_auth @process_auth
def update_user(username): def update_user(username):
if authenticated_user.username != username: permission = UserPermission(username)
return make_response('Forbidden', 403)
if permission.can():
update_request = request.get_json() update_request = request.get_json()
if update_request.has_key('password'): if 'password' in update_request:
logger.debug('Updating user password.') logger.debug('Updating user password.')
model.change_password(authenticated_user, update_request['password']) model.change_password(get_authenticated_user(),
update_request['password'])
if update_request.has_key('email'): if 'email' in update_request:
logger.debug('Updating user email') logger.debug('Updating user email')
model.update_email(authenticated_user, update_request['email']) model.update_email(get_authenticated_user(), update_request['email'])
return jsonify({ return jsonify({
'username': authenticated_user.username, 'username': get_authenticated_user().username,
'email': authenticated_user.email, 'email': get_authenticated_user().email,
}) })
abort(403)
@app.route('/v1/repositories/<path:repository>', methods=['PUT']) @app.route('/v1/repositories/<path:repository>', methods=['PUT'])
@requires_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(access='write') @generate_headers(access='write')
def create_repository(namespace, repository): def create_repository(namespace, repository):
# TODO check that the user is the same as indicated by the namespace
image_descriptions = json.loads(request.data) 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} new_repo_images = {desc['id']: desc for desc in image_descriptions}
added_images = dict(new_repo_images) added_images = dict(new_repo_images)
@ -114,10 +143,13 @@ def create_repository(namespace, repository):
@app.route('/v1/repositories/<path:repository>/images', methods=['PUT']) @app.route('/v1/repositories/<path:repository>/images', methods=['PUT'])
@requires_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(access='write') @generate_headers(access='write')
def update_images(namespace, repository): def update_images(namespace, repository):
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
image_with_checksums = json.loads(request.data) image_with_checksums = json.loads(request.data)
for image in image_with_checksums: for image in image_with_checksums:
@ -125,20 +157,19 @@ def update_images(namespace, repository):
return make_response('Updated', 204) return make_response('Updated', 204)
abort(403)
@app.route('/v1/repositories/<path:repository>/images', methods=['GET']) @app.route('/v1/repositories/<path:repository>/images', methods=['GET'])
@requires_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(access='read') @generate_headers(access='read')
def get_repository_images(namespace, repository): def get_repository_images(namespace, repository):
if validated_token and (validated_token.repository.name != repository or permission = ReadRepositoryPermission(namespace, repository)
validated_token.repository.namespace != namespace):
return make_response('Forbidden', 403)
#TODO check user has permission for repository # TODO invalidate token?
#TODO invalidate token
if permission.can():
all_images = [] all_images = []
for image in model.get_repository_images(namespace, repository): for image in model.get_repository_images(namespace, repository):
new_image_view = { new_image_view = {
@ -151,13 +182,13 @@ def get_repository_images(namespace, repository):
resp = make_response(json.dumps(all_images), 200) resp = make_response(json.dumps(all_images), 200)
resp.mimetype = 'application/json' resp.mimetype = 'application/json'
repo = model.create_or_fetch_repository(namespace, repository)
return resp return resp
abort(403)
@app.route('/v1/repositories/<path:repository>/images', methods=['DELETE']) @app.route('/v1/repositories/<path:repository>/images', methods=['DELETE'])
@requires_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(access='delete') @generate_headers(access='delete')
def delete_repository_images(namespace, repository): def delete_repository_images(namespace, repository):

View file

@ -1,6 +1,11 @@
import bcrypt 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): def create_user(username, password, email):
@ -23,6 +28,13 @@ def verify_user(username, password):
return None return None
def verify_token(code):
try:
return AccessToken.get(code=code)
except AccessToken.DoesNotExist:
return None
def change_password(user, new_password): def change_password(user, new_password):
pw_hash = bcrypt.hashpw(new_password, bcrypt.gensalt()) pw_hash = bcrypt.hashpw(new_password, bcrypt.gensalt())
user.password_hash = pw_hash user.password_hash = pw_hash
@ -35,12 +47,27 @@ def update_email(user, new_email):
user.save() 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: try:
repo = Repository.get(Repository.name == name and return Repository.get(Repository.name == name and
Repository.namespace == namespace) Repository.namespace == namespace)
except Repository.DoesNotExist: 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 return repo
@ -69,22 +96,12 @@ def get_repository_images(namespace_name, repository_name):
Repository.namespace == namespace_name) Repository.namespace == namespace_name)
def create_access_token(repository): def create_access_token(repository, user):
new_token = AccessToken.create(repository=repository) new_token = AccessToken.create(user=user, repository=repository)
return new_token return new_token
def verify_token(namespace_name, repository_name, code): def get_user_repo_permissions(user, repository):
try: select = RepositoryPermission.select()
fetched_repo = Repository.get(Repository.namespace == namespace_name and return select.where(RepositoryPermission.user == user and
Repository.name == repository_name) RepositoryPermission.repository == repository)
except Repository.DoesNotExist:
return None
try:
fetched = AccessToken.get(AccessToken.code == code and
AccessToken.repository == fetched_repo)
except AccessToken.DoesNotExist:
return None
return fetched

60
permissions.py Normal file
View file

@ -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)

View file

@ -1,3 +1,4 @@
peewee peewee
flask flask
py-bcrypt py-bcrypt
Flask-Principal

View file

@ -5,7 +5,8 @@ from app import app
import index import index
if __name__ == '__main__': 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) logging.basicConfig(format=FORMAT, level=logging.DEBUG)
app.run(port=5002, debug=True) app.run(port=5002, debug=True)