diff --git a/invenio_config_tugraz/__init__.py b/invenio_config_tugraz/__init__.py index 70ad72f..5a50bb0 100644 --- a/invenio_config_tugraz/__init__.py +++ b/invenio_config_tugraz/__init__.py @@ -9,6 +9,7 @@ """invenio module that adds tugraz configs.""" from .ext import invenioconfigtugraz +from .generators import RecordIp from .version import __version__ -__all__ = ('__version__', 'invenioconfigtugraz') +__all__ = ('__version__', 'invenioconfigtugraz', 'RecordIp') diff --git a/invenio_config_tugraz/config.py b/invenio_config_tugraz/config.py index b4833e7..c723832 100644 --- a/invenio_config_tugraz/config.py +++ b/invenio_config_tugraz/config.py @@ -13,6 +13,20 @@ from flask_babelex import gettext as _ INVENIO_CONFIG_TUGRAZ_SHIBBOLETH = True """Set True if SAML is configured""" +INVENIO_CONFIG_TUGRAZ_SINGLE_IP = [] +"""Allows access to users whose IP address is listed. + +INVENIO_CONFIG_TUGRAZ_SINGLE_IP = + ["127.0.0.1", "127.0.0.2"] +""" + +INVENIO_CONFIG_TUGRAZ_IP_RANGES = [] +"""Allows access to users whose range of IP address is listed. + +INVENIO_CONFIG_TUGRAZ_IP_RANGES = +[["127.0.0.2", "127.0.0.99"], ["127.0.1.3", "127.0.1.5"]] +""" + # Invenio-App # =========== # See https://invenio-app.readthedocs.io/en/latest/configuration.html @@ -66,8 +80,11 @@ SECURITY_EMAIL_SENDER = "info@invenio-test.tugraz.at" SECURITY_EMAIL_SUBJECT_REGISTER = _("Welcome to RDM!") """Email subject for account registration emails.""" -MAIL_SUPPRESS_SEND = False -"""Enable email sending by default.""" +MAIL_SUPPRESS_SEND = True +"""Enable email sending by default. + +Set this to False when sending actual emails. +""" # CORS - Cross-origin resource sharing # =========== @@ -163,3 +180,14 @@ to render your overriden login.html RECAPTCHA_PUBLIC_KEY = None #: Recaptcha private key (change to enable). RECAPTCHA_PRIVATE_KEY = None + +# invenio-records-permissions +# ======= +# See: +# https://invenio-records-permissions.readthedocs.io/en/latest/configuration.html +# + +RECORDS_PERMISSIONS_RECORD_POLICY = ( + 'invenio_config_tugraz.permissions.TUGRAZPermissionPolicy' +) +"""Access control configuration for records.""" diff --git a/invenio_config_tugraz/generators.py b/invenio_config_tugraz/generators.py new file mode 100644 index 0000000..7429679 --- /dev/null +++ b/invenio_config_tugraz/generators.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Mojib Wali. +# +# invenio-config-tugraz is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. + +r"""Permission generators and policies for Invenio records. + +Invenio-records-permissions provides a means to fully customize access control +for Invenio records. It does so by defining and providing three layers of +permission constructs that build on each other: +Generators and Policies. You can extend or override them for maximum +control. Thankfully we provide default ones that cover most cases. + +Invenio-records-permissions conveniently structures (and relies on) +functionalities from +`invenio-access `_ and +`flask-principal `_ . + + +Generators +---------- + +Generators are the lowest level of abstraction provided by +invenio-records-permissions. A +:py:class:`~invenio_records_permissions.generators.Generator` represents +identities via +`Needs `_ that +are allowed or disallowed to act on a kind of object. A Generator does not +specify the action, but it does specify who is allowed and the kind of object +of concern (typically records). Generators *generate* required and forbidden +Needs at the object-of-concern level and *generate* query filters +at the search-for-objects-of-concern level. + +A Generator object defines 3 methods in addition to its constructor: + +- ``needs(self, **kwargs)``: returns Needs, one of which a provider is + required to have to be allowed +- ``excludes(self, **kwargs)``: returns a list of Needs disallowing any + provider of a single one +- ``query_filter(self, **kwargs)``: returns a query filter to enable retrieval + of records + +The ``needs`` and ``excludes`` methods specify access conditions from +the point-of-view of the object-of-concern; whereas, the ``query_filter`` +method specifies those from the actor's point-of-view in search scenarios. + +A simple example of a Generator is the provided +:py:class:`~invenio_records_permissions.generators.RecordOwners` Generator: + +.. code-block:: python + + from flask_principal import UserNeed + + + class RecordOwners(Generator): + '''Allows record owners.''' + + def needs(self, record=None, **kwargs): + '''Enabling Needs.''' + return [UserNeed(owner) for owner in record.get('owners', [])] + + def query_filter(self, record=None, **kwargs): + '''Filters for current identity as owner.''' + # NOTE: implementation subject to change until permissions metadata + # settled + provides = g.identity.provides + for need in provides: + if need.method == 'id': + return Q('term', owners=need.value) + return [] + +``RecordOwners`` allows any identity providing a `UserNeed +`_ +of value found in the ``owners`` metadata of a record. The +``query_filter(self, **kwargs)`` +method outputs a query that returns all owned records of the current user. +Not included in the above, because it doesn't apply to ``RecordOwners``, is +the ``excludes(self, **kwargs)`` method. + +.. Note:: + + Exclusion has priority over inclusion. If a Need is returned by both + ``needs`` and ``excludes``, providers of that Need will be **excluded**. + +If implementation of Generators seems daunting, fear not! A collection of +them has already been implemented in +:py:mod:`~invenio_records_permissions.generators` +and they cover most cases you may have. + +To fully understand how they work, we have to show where Generators are used. +That is in the Policies. + + +Policies +-------- + +Classes inheriting from +:py:class:`~invenio_records_permissions.policies.base.BasePermissionPolicy` are +referred to as Policies. They list **what actions** can be done **by whom** +over an implied category of objects (typically records). A Policy is +instantiated on a per action basis and is a descendant of `Permission +`_ in +`invenio-access `_ . +Generators are used to provide the "by whom" part and the implied category of +object. + +Here is an example of a custom record Policy: + +.. code-block:: python + + from invenio_records_permissions.generators import AnyUser, RecordOwners, \ + SuperUser + from invenio_records_permissions.policies.base import BasePermissionPolicy + + class ExampleRecordPermissionPolicy(BasePermissionPolicy): + can_create = [AnyUser()] + can_search = [AnyUser()] + can_read = [AnyUser()] + can_update = [RecordOwners()] + can_foo_bar = [SuperUser()] + +The actions are class variables of the form: ``can_`` and the +corresponding (dis-)allowed identities are a list of Generator instances. +One can define any action as long as it follows that pattern and +is verified at the moment it is undertaken. + +In the example above, any user can create, list and read records, but only +a record's owner can edit it and only super users can perform the "foo_bar" +action. + +We recommend you extend the provided +:py:class:`invenio_records_permissions.policies.records.RecordPermissionPolicy` +to customize record permissions for your instance. +This way you benefit from sane defaults. + +After you have defined your own Policy, set it in your configuration: + +.. code-block:: python + + RECORDS_PERMISSIONS_RECORD_POLICY = ( + 'module.to.ExampleRecordPermissionPolicy' + ) + +The succinct encoding of the permissions for your instance gives you + - one central location where your permissions are defined + - exact control + - great flexibility by defining your own actions, generators and policies +""" + +from elasticsearch_dsl.query import Q +from flask import current_app, request +from invenio_records_permissions.generators import Generator + + +class RecordIp(Generator): + """Allowed any user with accessing with the IP.""" + + # TODO: Implement + def needs(self, **kwargs): + """Enabling Needs, Set of Needs granting permission. + + If ANY of the Needs are matched, permission is granted. + + .. note:: + + ``_load_permissions()`` method from `Permission + `_ adds by default the + ``superuser_access`` Need (if tied to a User or Role) for us. + It also expands ActionNeeds into the Users/Roles that + provide them. + """ + return [] + + def excludes(self, **kwargs): + """Preventing Needs, Set of Needs denying permission. + + If ANY of the Needs are matched, permission is revoked. + + .. note:: + + ``_load_permissions()`` method from `Permission + `_ adds by default the + ``superuser_access`` Need (if tied to a User or Role) for us. + + It also expands ActionNeeds into the Users/Roles that + provide them. + + If the same Need is returned by `needs` and `excludes`, then that + Need provider is disallowed. + """ + return [] + + def query_filter(self, **kwargs): + """Elasticsearch filters, List of ElasticSearch query filters. + + These filters consist of additive queries mapping to what the current + user should be able to retrieve via search. + """ + return Q('match_all') + + def check_permission(self): + """Check for User IP address in config variable.""" + # Get user IP + user_ip = request.remote_addr + # Checks if the user IP is among single IPs + if user_ip in current_app.config['INVENIO_CONFIG_TUGRAZ_SINGLE_IP']: + return True + return False diff --git a/invenio_config_tugraz/permissions.py b/invenio_config_tugraz/permissions.py new file mode 100644 index 0000000..dd06f27 --- /dev/null +++ b/invenio_config_tugraz/permissions.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Mojib Wali. +# +# invenio-config-tugraz is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. + +""" +Records permission policies. + +Default policies for records: + +.. code-block:: python + + # Read access given to everyone. + can_search = [AnyUser()] + # Create action given to no one (Not even superusers) bc Deposits should + # be used. + can_create = [Disable()] + # Read access given to everyone if public record/files and owners always. + can_read = [AnyUserIfPublic(), RecordOwners()] + # Update access given to record owners. + can_update = [RecordOwners()] + # Delete access given to admins only. + can_delete = [Admin()] + # Associated files permissions (which are really bucket permissions) + can_read_files = [AnyUserIfPublic(), RecordOwners()] + can_update_files = [RecordOwners()] + +How to override default policies for records. + +Using Custom Generator for a policy: + +.. code-block:: python + + from invenio_rdm_records.permissions import RDMRecordPermissionPolicy + from invenio_config_tugraz.generators import RecordIp + + class TUGRAZPermissionPolicy(RDMRecordPermissionPolicy): + + # Delete access given to RecordIp only. + + can_delete = [RecordIp()] + + RECORDS_PERMISSIONS_RECORD_POLICY = TUGRAZPermissionPolicy + + +Permissions for Invenio (RDM) Records. +""" + +from invenio_records_permissions.generators import Admin, AnyUser, \ + AnyUserIfPublic, Disable, RecordOwners +from invenio_records_permissions.policies.base import BasePermissionPolicy + +from .generators import RecordIp + + +class TUGRAZPermissionPolicy(BasePermissionPolicy): + """Access control configuration for records. + + This overrides the /api/records endpoint. + + """ + + # Read access to API given to everyone. + can_search = [AnyUser(), RecordIp()] + + # Read access given to everyone if public record/files and owners always. + can_read = [AnyUserIfPublic(), RecordOwners(), RecordIp()] + + # Create action given to no one (Not even superusers) bc Deposits should + # be used. + can_create = [AnyUser()] + + # Update access given to record owners. + can_update = [RecordOwners()] + + # Delete access given to admins only. + can_delete = [Admin()] + + # Associated files permissions (which are really bucket permissions) + can_read_files = [AnyUserIfPublic(), RecordOwners()] + can_update_files = [RecordOwners()] diff --git a/setup.py b/setup.py index ff8098e..a3c6207 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,9 @@ setup_requires = [ install_requires = [ 'Flask-BabelEx>=0.9.4', + 'elasticsearch_dsl>=7.2.1', + 'invenio-rdm-records~=0.18.3', + 'invenio_search>=1.3.1', ] diff --git a/tests/test_generators.py b/tests/test_generators.py new file mode 100644 index 0000000..dda6fc1 --- /dev/null +++ b/tests/test_generators.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Mojib Wali. +# +# invenio-config-tugraz is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. + +from invenio_config_tugraz.generators import RecordIp + + +def test_recordip(): + generator = RecordIp() + + assert generator.needs() == [] + assert generator.excludes() == [] + assert generator.query_filter().to_dict() == {'match_all': {}}