Integrate flask-principal in order to provide RBAC.
This commit is contained in:
parent
8e169b1026
commit
458b69953a
8 changed files with 237 additions and 103 deletions
5
app.py
5
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
|
32
auth.py
32
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:
|
||||
|
@ -43,13 +41,15 @@ def check_basic_auth():
|
|||
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)
|
||||
|
||||
|
@ -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
|
||||
|
|
39
database.py
39
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()
|
||||
|
|
91
index.py
91
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,9 +36,11 @@ 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)
|
||||
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
|
||||
|
@ -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/<username>/', methods=['PUT'])
|
||||
@requires_auth
|
||||
@process_auth
|
||||
def update_user(username):
|
||||
if authenticated_user.username != username:
|
||||
return make_response('Forbidden', 403)
|
||||
permission = UserPermission(username)
|
||||
|
||||
if permission.can():
|
||||
update_request = request.get_json()
|
||||
|
||||
if update_request.has_key('password'):
|
||||
if 'password' in update_request:
|
||||
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')
|
||||
model.update_email(authenticated_user, update_request['email'])
|
||||
model.update_email(get_authenticated_user(), update_request['email'])
|
||||
|
||||
return jsonify({
|
||||
'username': authenticated_user.username,
|
||||
'email': authenticated_user.email,
|
||||
'username': get_authenticated_user().username,
|
||||
'email': get_authenticated_user().email,
|
||||
})
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/v1/repositories/<path:repository>', 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,10 +143,13 @@ def create_repository(namespace, repository):
|
|||
|
||||
|
||||
@app.route('/v1/repositories/<path:repository>/images', methods=['PUT'])
|
||||
@requires_auth
|
||||
@process_auth
|
||||
@parse_repository_name
|
||||
@generate_headers(access='write')
|
||||
def update_images(namespace, repository):
|
||||
permission = ModifyRepositoryPermission(namespace, repository)
|
||||
|
||||
if permission.can():
|
||||
image_with_checksums = json.loads(request.data)
|
||||
|
||||
for image in image_with_checksums:
|
||||
|
@ -125,20 +157,19 @@ def update_images(namespace, repository):
|
|||
|
||||
return make_response('Updated', 204)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/v1/repositories/<path:repository>/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 = {
|
||||
|
@ -151,13 +182,13 @@ def get_repository_images(namespace, repository):
|
|||
resp = make_response(json.dumps(all_images), 200)
|
||||
resp.mimetype = 'application/json'
|
||||
|
||||
repo = model.create_or_fetch_repository(namespace, repository)
|
||||
|
||||
return resp
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/v1/repositories/<path:repository>/images', methods=['DELETE'])
|
||||
@requires_auth
|
||||
@process_auth
|
||||
@parse_repository_name
|
||||
@generate_headers(access='delete')
|
||||
def delete_repository_images(namespace, repository):
|
||||
|
|
57
model.py
57
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
|
||||
|
||||
|
||||
|
@ -69,22 +96,12 @@ def get_repository_images(namespace_name, repository_name):
|
|||
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)
|
||||
|
|
60
permissions.py
Normal file
60
permissions.py
Normal 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)
|
|
@ -1,3 +1,4 @@
|
|||
peewee
|
||||
flask
|
||||
py-bcrypt
|
||||
Flask-Principal
|
3
wsgi.py
3
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)
|
||||
|
|
Reference in a new issue