Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

global: adds permissions #34

Merged
merged 1 commit into from
Mar 9, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ cache:
services:
- mysql
- postgresql
- redis

# Note: dist/sudo/api is required for MySQL 5.6 which adds support for
# fractional seconds in datetime columns.
Expand Down
3 changes: 3 additions & 0 deletions invenio_files_rest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@

FILES_REST_STORAGE_FACTORY = None
"""Import path of factory used to create a storage instance."""

FILES_REST_DEFAULT_PERMISSION_FACTORY = "invenio_files_rest.permissions" \
":permission_factory"
18 changes: 15 additions & 3 deletions invenio_files_rest/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@

from __future__ import absolute_import, print_function

from werkzeug.utils import import_string
from werkzeug.utils import cached_property, import_string

from . import config
from .permissions import permission_factory
from .storage import storage_factory
from .views import blueprint

Expand All @@ -40,16 +41,27 @@ def __init__(self, app):
"""Initialize state."""
self.app = app
self._storage_factory = None
self._read_permission_factory = None

@property
@cached_property
def storage_factory(self):
"""Load default permission factory."""
"""Load default storage factory."""
if self._storage_factory is None:
imp = self.app.config["FILES_REST_STORAGE_FACTORY"]
self._storage_factory = import_string(imp) if imp else \
storage_factory
return self._storage_factory

@cached_property
def permission_factory(self):
"""Load default read permission factory."""
if self._read_permission_factory is None:
imp = self.app.config.get(
'FILES_REST_DEFAULT_PERMISSION_FACTORY')
self._read_permission_factory = import_string(imp) if imp else \
permission_factory
return self._read_permission_factory


class InvenioFilesREST(object):
"""Invenio-Files-REST extension."""
Expand Down
29 changes: 21 additions & 8 deletions invenio_files_rest/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,35 @@
from functools import partial

from flask_principal import ActionNeed
from invenio_access.permissions import ParameterizedActionNeed
from invenio_access.permissions import DynamicPermission, \
ParameterizedActionNeed

BucketRead = partial(ParameterizedActionNeed, 'files-rest-bucket-read')
BucketUpdate = partial(ParameterizedActionNeed, 'files-rest-bucket-update')
BucketDelete = partial(ParameterizedActionNeed, 'files-rest-bucket-delete')

ObjectRead = partial(ParameterizedActionNeed, 'files-rest-object-read')
ObjectUpdate = partial(ParameterizedActionNeed, 'files-rest-object-update')
ObjectDelete = partial(ParameterizedActionNeed, 'files-rest-object-delete')
ObjectsRead = partial(ParameterizedActionNeed, 'files-rest-objects-read')
ObjectsUpdate = partial(ParameterizedActionNeed, 'files-rest-objects-update')
ObjectsDelete = partial(ParameterizedActionNeed, 'files-rest-objects-delete')

bucket_create = ActionNeed('files-rest-bucket-create')
bucket_read_all = BucketRead(None)
bucket_update_all = BucketUpdate(None)
bucket_delete_all = BucketDelete(None)

object_create = ActionNeed('files-rest-object-create')
object_read_all = ObjectRead(None)
object_update_all = ObjectUpdate(None)
object_delete_all = ObjectDelete(None)
objects_create = ActionNeed('files-rest-objects-create')
objects_read_all = ObjectsRead(None)
objects_update_all = ObjectsUpdate(None)
objects_delete_all = ObjectsDelete(None)

_action2need_map = {
'objects-read': ObjectsRead,
'objects-update': ObjectsUpdate,
'objects-delete': ObjectsRead,
}


def permission_factory(bucket, action='objects-read'):
"""Permission factory for the actions on Bucket and ObjectVersion items."""
Need = _action2need_map[action]
return DynamicPermission(Need(str(bucket.id)))
25 changes: 23 additions & 2 deletions invenio_files_rest/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
from __future__ import absolute_import, print_function

from flask import Blueprint, abort, current_app, request, url_for
from flask_login import current_user
from invenio_db import db
from invenio_rest import ContentNegotiatedMethodView
from sqlalchemy.exc import SQLAlchemyError
from webargs import fields
from webargs.flaskparser import parser, use_kwargs
from werkzeug.local import LocalProxy

from .models import Bucket, Location, ObjectVersion
from .serializer import json_serializer
Expand All @@ -42,6 +44,9 @@
url_prefix='/files'
)

permission_factory = LocalProxy(
lambda: current_app.extensions['invenio-files-rest'].permission_factory)


class BucketCollectionResource(ContentNegotiatedMethodView):
""""Bucket collection resource."""
Expand Down Expand Up @@ -431,13 +436,24 @@ def get(self, bucket_id, key, version_id=None, **kwargs):

:resheader Content-Type: application/json
:statuscode 200: no error
:statuscode 401: authentication required
:statuscode 403: access denied
:statuscode 404: Object does not exist
"""
# TODO: Check access permission on bucket.
# TODO: Support partial range requests.
# TODO: Support for access token
# TODO: Exception if file is not found (deleted by hand or accident)

# Retrieve bucket.
bucket = Bucket.get(bucket_id)
if bucket is None:
abort(404, 'Bucket does not exist.')

if not current_user.is_authenticated:
abort(401, 'Authentication required.')
if not permission_factory(bucket, action='objects-read').can():
abort(403, 'Permission denied.')

obj = ObjectVersion.get(bucket_id, key, version_id=version_id)
if obj is None:
abort(404, 'Object does not exist.')
Expand Down Expand Up @@ -504,7 +520,12 @@ def put(self, bucket_id, key, content_length=None, content_md5=None):
if bucket is None:
abort(404, 'Bucket does not exist.')

# TODO: Check access permission on bucket.
if not current_user.is_authenticated:
abort(401, 'Authentication required.')
if not permission_factory(bucket, action='objects-update').can():
abort(403, 'Permission denied.')

# TODO: Check access permission on the bucket
# TODO: Check quota on bucket using content length
# TODO: Support checking incoming MD5 header
# TODO: Support setting content-type
Expand Down
13 changes: 7 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,13 @@ def run_tests(self):
'invenio_files_rest.permissions:bucket_update_all',
'bucket_delete_all = '
'invenio_files_rest.permissions:bucket_delete_all',
'object_create = invenio_files_rest.permissions:object_create',
'object_read_all = invenio_files_rest.permissions:object_read_all',
'object_update_all = '
'invenio_files_rest.permissions:object_update_all',
'object_delete_all = '
'invenio_files_rest.permissions:object_delete_all',
'objects_create = invenio_files_rest.permissions:objects_create',
'objects_read_all = '
'invenio_files_rest.permissions:objects_read_all',
'objects_update_all = '
'invenio_files_rest.permissions:objects_update_all',
'objects_delete_all = '
'invenio_files_rest.permissions:objects_delete_all',
],
},
extras_require=extras_require,
Expand Down
58 changes: 58 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,28 @@
import os
import shutil
import tempfile
from hashlib import md5
from os.path import dirname, join

import pytest
from flask import Flask
from flask_babelex import Babel
from flask_cli import FlaskCLI
from flask_menu import Menu
from invenio_access import InvenioAccess
from invenio_access.models import ActionUsers
from invenio_accounts import InvenioAccounts
from invenio_accounts.testutils import create_test_user
from invenio_accounts.views import blueprint as accounts_blueprint
from invenio_db import db as db_
from invenio_db import InvenioDB
from six import BytesIO
from sqlalchemy_utils.functions import create_database, database_exists

from invenio_files_rest import InvenioFilesREST
from invenio_files_rest.models import Bucket, Location, ObjectVersion
from invenio_files_rest.permissions import objects_create, \
objects_delete_all, objects_read_all, objects_update_all
from invenio_files_rest.views import blueprint


Expand All @@ -54,9 +65,18 @@ def app(request):
SQLALCHEMY_DATABASE_URI=os.environ.get(
'SQLALCHEMY_DATABASE_URI',
'sqlite:///:memory:'),
WTF_CSRF_ENABLED=False,
SERVER_NAME='invenio.org',
SECURITY_PASSWORD_SALT='TEST_SECURITY_PASSWORD_SALT',
SECRET_KEY='TEST_SECRET_KEY',
)
FlaskCLI(app_)
InvenioDB(app_)
Babel(app_)
Menu(app_)
InvenioAccounts(app_)
InvenioAccess(app_)
app_.register_blueprint(accounts_blueprint)
InvenioFilesREST(app_)
app_.register_blueprint(blueprint)

Expand Down Expand Up @@ -106,3 +126,41 @@ def objects(dummy_location):
objects.append(ObjectVersion.create(b1, f, stream=fp))

yield objects


@pytest.yield_fixture()
def test_data(dummy_location, db):
"""Create some test data."""
# Create the bucket with a single file
buc1 = Bucket.create()
key1 = "key1" # Object key
stream1 = b'contents1' # Contents of the data stream
o1 = ObjectVersion.create(buc1, key1, stream=BytesIO(stream1))
md5_1 = md5(BytesIO(stream1).read()).hexdigest()

# Create the test users
u1_data = dict(email='user1@invenio-software.org', password='pass1')
u2_data = dict(email='user2@invenio-software.org', password='pass1')
u1 = create_test_user(active=True, **u1_data) # User with permissions
u2 = create_test_user(active=True, **u2_data) # User w/o permissions

# Give permissions to user 'user1', but not to 'user2'
perms = [objects_create, objects_read_all, objects_update_all,
objects_delete_all]
for perm in perms:
au = ActionUsers(action=perm.value,
argument=str(buc1.id),
user=u1)
db.session.add(au)
db.session.commit()

data = {'bucket': buc1,
'files': (o1, ), # List of files
'files_streams': (stream1, ), # List of files contents
'files_md5': (md5_1, ), # List of files md5
'user1': u1, # User object
'user1_data': u1_data, # User data (used for login)
'user2': u2,
'user2_data': u2_data, }

yield data
89 changes: 61 additions & 28 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@

from __future__ import absolute_import, print_function

from hashlib import md5

from flask import json
from flask import json, url_for
from six import BytesIO


def test_get_buckets(app, dummy_location):
Expand Down Expand Up @@ -138,33 +137,67 @@ def test_post_bucket(app, dummy_location):
# def test_get_object_list(app, dummy_objects):


def test_get_object_get(app, objects):
"""Test object download"""
def test_get_object(app, test_data):
"""Test ObjectResource view GET method."""
bucket1 = test_data['bucket']
key1 = test_data['files'][0].key
login_url = url_for('security.login')
u1_data = test_data['user1_data'] # Privileged user
u2_data = test_data['user2_data'] # Unprivileged user
object_url = "/files/{0}/{1}".format(bucket1.id, key1)
object_url_invalid = "/files/{0}/{1}".format(bucket1.id, key1 + "XYZ")
headers = {'Content-Type': 'application/json', 'Accept': '*/*'}

with app.test_client() as client:
# Get object that doesn't exist. Gets the "401 Unauthorized" before 404
resp = client.get(object_url_invalid, headers=headers)
assert resp.status_code == 401
# Get 404 after login
client.post(login_url, data=u1_data)
resp = client.get(object_url_invalid, headers=headers)
assert resp.status_code == 404

with app.test_client() as client:
# Request the object anonymously, get "401 Unauthorized"
resp = client.get(object_url, headers=headers)
assert resp.status_code == 401

# Request the object with user2 (no permissions), get "403 Forbidden"
client.post(login_url, data=u2_data) # Login with user2
resp = client.get(object_url, headers=headers)
assert resp.status_code == 403

with app.test_client() as client:
for obj in objects:
resp = client.get(
"/files/{0}/{1}".format(obj.bucket_id, obj.key),
headers={'Content-Type': 'application/json', 'Accept': '*/*'}
)
assert resp.status_code == 200

# Check md5
md5_local = "md5:{}".format(md5(open(obj.file.uri[7:], 'rb')
.read()).hexdigest())
assert resp.content_md5 == md5_local
# Check etag
assert resp.get_etag()[0] == md5_local


def test_get_object_get_404(app, objects):
"""Test object download 404 error"""
client.post(login_url, data=u1_data)
resp = client.get(object_url, headers=headers)
assert resp.status_code == 200

# Check md5
md5_local = "md5:{}".format(test_data['files_md5'][0])
assert resp.content_md5 == md5_local
assert resp.get_etag()[0] == md5_local


def test_put_object(app, test_data):
"""Test ObjectResource view PUT method."""
bucket1 = test_data['bucket']
key1 = test_data['files'][0].key
login_url = url_for('security.login')
u2_data = test_data['user2_data'] # Unprivileged user
object_url = "/files/{0}/{1}".format(bucket1.id, key1)
headers = {'Accept': '*/*'}

with app.test_client() as client:
for obj in objects:
resp = client.get(
"/files/{0}/{1}".format(obj.bucket_id, obj.key + "Missing"),
headers={'Content-Type': 'application/json', 'Accept': '*/*'}
)
assert resp.status_code == 404
# Get object that doesn't exist. Gets the "401 Unauthorized" before 404
# Try to update the file under 'key1' (with 'contents2')
data = {'file': (BytesIO(b'contents2'), 'file.dat')}
resp = client.put(object_url, data=data, headers=headers)
assert resp.status_code == 401
# Login with 'user2' (no permissions), try to PUT, receive 403
client.post(login_url, data=u2_data)
data = {'file': (BytesIO(b'contents2'), 'file.dat')}
resp = client.put(object_url, data=data, headers=headers)
assert resp.status_code == 403
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test should be extended to test for valid PUT (i.e. assert resp.status_code == 200 and check if file is correct), but that's blocked by serialization feature: https://github.com/inveniosoftware/invenio-files-rest/blob/master/invenio_files_rest/views.py#L519



# def test_get_object_get_access_denied_403(app, objects):
Expand Down