Skip to content

Commit

Permalink
views: access control
Browse files Browse the repository at this point in the history
* NEW Adds customizable access control to record views. Allow
  configuring different permissions per endpoint. (reference inveniosoftware#14)

Signed-off-by: Nicolas Harraudeau <[email protected]>
  • Loading branch information
Nicolas Harraudeau committed Jan 11, 2016
1 parent 26ae5f7 commit e6c6b9c
Show file tree
Hide file tree
Showing 8 changed files with 550 additions and 22 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ docs/_build/

# PyBuilder
target/

# Sqlite databases
*.db
53 changes: 43 additions & 10 deletions examples/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2015 CERN.
# Copyright (C) 2015, 2016 CERN.
#
# Invenio is free software; you can redistribute it
# and/or modify it under the terms of the GNU General Public License as
Expand All @@ -25,19 +25,35 @@

"""Minimal Flask application example for development.
For this example the access control is disabled.
Run example development server:
.. code-block:: console
$ cd examples
$ flask -a app.py db init
$ flask -a app.py db create
$ flask -a app.py load_fixture
$ flask -a app.py --debug run
"""
$ cd examples
$ flask -a app.py db init
$ flask -a app.py db create
$ flask -a app.py fixture records
$ flask -a app.py --debug run
Try to get some records
.. code-block:: console
$ curl -XGET http://localhost:5000/records/1
$ curl -XGET http://localhost:5000/records/2
$ curl -XGET http://localhost:5000/records/3
$ curl -XGET http://localhost:5000/records/4
$ curl -XGET http://localhost:5000/records/5
$ curl -XGET http://localhost:5000/records/6
$ curl -XGET http://localhost:5000/records/7
""" # noqa

from __future__ import absolute_import, print_function

import os

from flask import Flask
from flask_celeryext import FlaskCeleryExt
from flask_cli import FlaskCLI
Expand All @@ -48,13 +64,25 @@

from invenio_records_rest import InvenioRecordsREST


# create application's instance directory. Needed for this example only.
current_dir = os.path.dirname(os.path.realpath(__file__))
instance_dir = os.path.join(current_dir, 'app')
if not os.path.exists(instance_dir):
os.makedirs(instance_dir)

# Create Flask application
app = Flask(__name__)
app = Flask(__name__, instance_path=instance_dir)
app.config.update(
CELERY_ALWAYS_EAGER=True,
CELERY_CACHE_BACKEND="memory",
CELERY_EAGER_PROPAGATES_EXCEPTIONS=True,
CELERY_RESULT_BACKEND="cache",
# No permission checking
RECORDS_REST_DEFAULT_CREATE_PERMISSION_FACTORY=None,
RECORDS_REST_DEFAULT_READ_PERMISSION_FACTORY=None,
RECORDS_REST_DEFAULT_UPDATE_PERMISSION_FACTORY=None,
RECORDS_REST_DEFAULT_DELETE_PERMISSION_FACTORY=None,
)
FlaskCLI(app)
FlaskCeleryExt(app)
Expand All @@ -65,8 +93,13 @@
InvenioRecordsREST(app)


@app.cli.command()
def load_fixture():
@app.cli.group()
def fixtures():
"""Command for working with test data."""


@fixtures.command()
def records():
"""Load test data fixture."""
import uuid
from invenio_records.api import Record
Expand Down
61 changes: 59 additions & 2 deletions invenio_records_rest/ext.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2015 CERN.
# Copyright (C) 2015, 2016 CERN.
#
# Invenio is free software; you can redistribute it
# and/or modify it under the terms of the GNU General Public License as
Expand All @@ -26,9 +26,66 @@

from __future__ import absolute_import, print_function

from werkzeug.utils import import_string, cached_property

from .views import create_blueprint


class _RecordRESTState(object):
"""Record REST state."""

def __init__(self, app):
"""Initialize state."""
self.app = app
self._read_permission_factory = None
self._create_permission_factory = None
self._update_permission_factory = None
self._delete_permission_factory = None

@cached_property
def read_permission_factory(self):
"""Load default read permission factory."""
if self._read_permission_factory is None:
imp = self.app.config[
"RECORDS_REST_DEFAULT_READ_PERMISSION_FACTORY"
]
self._read_permission_factory = import_string(imp) if imp else None
return self._read_permission_factory

@cached_property
def create_permission_factory(self):
"""Load default create permission factory."""
if self._create_permission_factory is None:
imp = self.app.config[
"RECORDS_REST_DEFAULT_CREATE_PERMISSION_FACTORY"
]
self._create_permission_factory = import_string(imp) \
if imp else None
return self._create_permission_factory

@cached_property
def update_permission_factory(self):
"""Load default update permission factory."""
if self._update_permission_factory is None:
imp = self.app.config[
"RECORDS_REST_DEFAULT_UPDATE_PERMISSION_FACTORY"
]
self._update_permission_factory = import_string(imp) \
if imp else None
return self._update_permission_factory

@cached_property
def delete_permission_factory(self):
"""Load default delete permission factory."""
if self._delete_permission_factory is None:
imp = self.app.config[
"RECORDS_REST_DEFAULT_DELETE_PERMISSION_FACTORY"
]
self._delete_permission_factory = import_string(imp) \
if imp else None
return self._delete_permission_factory


class InvenioRecordsREST(object):
"""Invenio-Records-REST extension."""

Expand All @@ -44,7 +101,7 @@ def init_app(self, app):
app.register_blueprint(
create_blueprint(app.config["RECORDS_REST_ENDPOINTS"])
)
app.extensions['invenio-records-rest'] = self
app.extensions['invenio-records-rest'] = _RecordRESTState(app)

def init_config(self, app):
"""Initialize configuration."""
Expand Down
106 changes: 101 additions & 5 deletions invenio_records_rest/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,16 @@
from jsonpatch import JsonPatchException, JsonPointerException
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.routing import BuildError
from werkzeug.local import LocalProxy
from werkzeug.utils import import_string

from .serializers import record_to_json_serializer


current_records_rest = LocalProxy(
lambda: current_app.extensions['invenio-records-rest'])


def create_blueprint(endpoints):
"""Create Invenio-Records-REST blueprint."""
blueprint = Blueprint(
Expand All @@ -62,12 +68,45 @@ def create_blueprint(endpoints):


def create_url_rules(endpoint, list_route=None, item_route=None,
pid_type=None, pid_minter=None):
"""Create Werkzeug URL rules."""
pid_type=None, pid_minter=None,
read_permission_factory_imp=None,
create_permission_factory_imp=None,
update_permission_factory_imp=None,
delete_permission_factory_imp=None):
"""Create Werkzeug URL rules.
:param endpoint: Name of endpoint.
:param list_route: record listing URL route . Required.
:param item_route: record URL route (must include ``<pid_value>`` pattern).
Required.
:param pid_type: Persistent identifier type for endpoint. Required.
:param template: Template to render. Defaults to
``invenio_records_ui/detail.html``.
:param read_permission_factory: Import path to factory that creates a read
permission object for a given record.
:param create_permission_factory: Import path to factory that creates a
create permission object for a given record.
:param update_permission_factory: Import path to factory that creates a
update permission object for a given record.
:param delete_permission_factory: Import path to factory that creates a
delete permission object for a given record.
:returns: a list of dictionaries with can each be passed as keywords
arguments to ``Blueprint.add_url_rule``.
"""
assert list_route
assert item_route
assert pid_type

read_permission_factory = import_string(read_permission_factory_imp) \
if read_permission_factory_imp else None
create_permission_factory = import_string(create_permission_factory_imp) \
if create_permission_factory_imp else None
update_permission_factory = import_string(update_permission_factory_imp) \
if update_permission_factory_imp else None
delete_permission_factory = import_string(delete_permission_factory_imp) \
if delete_permission_factory_imp else None

resolver = Resolver(pid_type=pid_type, object_type='rec',
getter=Record.get_record)

Expand All @@ -77,10 +116,15 @@ def create_url_rules(endpoint, list_route=None, item_route=None,
RecordsListResource.view_name.format(endpoint),
resolver=resolver,
minter_name=pid_minter,
read_permission_factory=read_permission_factory,
create_permission_factory=create_permission_factory,
serializers=serializers)
item_view = RecordResource.as_view(
RecordResource.view_name.format(endpoint),
resolver=resolver,
read_permission_factory=read_permission_factory,
update_permission_factory=update_permission_factory,
delete_permission_factory=delete_permission_factory,
serializers=serializers)

return [
Expand Down Expand Up @@ -129,20 +173,58 @@ def inner(self, pid_value, *args, **kwargs):
})
abort(500)

return f(self, pid, record, *args, **kwargs)
return f(self, pid=pid, record=record, *args, **kwargs)
return inner


def verify_record_permission(permission_factory, record):
"""Check that the current user has the required permissions on record.
:param permission_factory: permission factory used to check permissions.
:param record: record whose access is limited.
"""
# Note, cannot be done in one line due overloading of boolean
# operations permission object.
if not permission_factory(record).can():
from flask_login import current_user
if not current_user.is_authenticated:
abort(401)
abort(403)


def need_record_permission(factory_name):
"""Decorator checking that the user has the required permissions on record.
:param factory_name: name of the factory to retrieve.
"""
def need_record_permission_builder(f):
@wraps(f)
def need_record_permission_decorator(self, record, *args, **kwargs):
permission_factory = (
getattr(self, factory_name) or
getattr(current_records_rest, factory_name)
)
if permission_factory:
verify_record_permission(permission_factory, record)
return f(self, record=record, *args, **kwargs)
return need_record_permission_decorator
return need_record_permission_builder


class RecordsListResource(ContentNegotiatedMethodView):
"""Resource for records listing."""

view_name = '{0}_list'

def __init__(self, resolver=None, minter_name=None, **kwargs):
def __init__(self, resolver=None, minter_name=None,
read_permission_factory=None, create_permission_factory=None,
**kwargs):
"""Constructor."""
super(RecordsListResource, self).__init__(**kwargs)
self.resolver = resolver
self.minter = current_pidstore.minters[minter_name]
self.read_permission_factory = read_permission_factory
self.create_permission_factory = create_permission_factory

def post(self, **kwargs):
"""Create a record.
Expand All @@ -165,6 +247,12 @@ def post(self, **kwargs):
# Create record
record = Record.create(data, id_=record_uuid)

# Check permissions
permission_factory = self.create_permission_factory or \
current_records_rest.create_permission_factory
if permission_factory:
verify_record_permission(permission_factory, record)

db.session.commit()
except SQLAlchemyError:
db.session.rollback()
Expand All @@ -178,15 +266,21 @@ class RecordResource(ContentNegotiatedMethodView):

view_name = '{0}_item'

def __init__(self, resolver=None, **kwargs):
def __init__(self, resolver=None, read_permission_factory=None,
update_permission_factory=None,
delete_permission_factory=None, **kwargs):
"""Constructor.
:param resolver: Persistent identifier resolver instance.
"""
super(RecordResource, self).__init__(**kwargs)
self.resolver = resolver
self.read_permission_factory = read_permission_factory
self.update_permission_factory = update_permission_factory
self.delete_permission_factory = delete_permission_factory

@pass_record
@need_record_permission('read_permission_factory')
def get(self, pid, record, **kwargs):
"""Get a record.
Expand All @@ -199,6 +293,7 @@ def get(self, pid, record, **kwargs):

@require_content_types('application/json-patch+json')
@pass_record
@need_record_permission('update_permission_factory')
def patch(self, pid, record, **kwargs):
"""Modify a record.
Expand Down Expand Up @@ -226,6 +321,7 @@ def patch(self, pid, record, **kwargs):

@require_content_types('application/json')
@pass_record
@need_record_permission('update_permission_factory')
def put(self, pid, record, **kwargs):
"""Replace a record.
Expand Down
Loading

0 comments on commit e6c6b9c

Please sign in to comment.