Repository: fossasia/open-event-server

Issue:
Restrict GET API output to other users
https://github.com/fossasia/open-event-orga-server/issues/1825#issuecomment-235013731

Right now, every data is available in the public GET APIs. This is good but there are certain fields like speaker's email, contact etc which are best kept private i.e. not accessible to users not part of that event. 
Here is the list of fields I found.
1. speaker - email, mobile



Discussion:
How can we limit access to it? What comes to my mind:
a) API needs an additional hashkey to also put out this data
b) On the export section of events we should have an option to "Include export of private (email, phone numbers, address etc.) in files."

> a) API needs an additional hashkey to also put out this data

Assuming that only event personnel (organizers, mods etc) should have access to private data, we won't need to do that. There is a way to do it automatically by the API. 

> b) On the export section of events we should have an option to "Include export of private (email, phone numbers, address etc.) in files."

Will exporting event **without** _private data_ make sense ? I say this because when the export is further imported, some data will be missing. 

> Will exporting event without private data make sense ?

The use case is: I have an Open Event system on provider A, but I want to move it to provider B. Just like wordpress posts (including all author data with emails) I want to be able to move it completely. I do not want to enter all the emails of speakers again. I want to export the whole data set.


Resolving patch:
diff --git a/app/api/helpers/helpers.py b/app/api/helpers/helpers.py
--- a/app/api/helpers/helpers.py
+++ b/app/api/helpers/helpers.py
@@ -64,6 +64,7 @@ def get_list_or_404(klass, **kwargs):
         raise NotFoundError(message='Object list is empty')
     return obj_list
 
+
 def get_object_or_404(klass, id_):
     """Returns a specific object of a model class given its identifier. In case
     the object is not found, 404 is returned.
diff --git a/app/api/speakers.py b/app/api/speakers.py
--- a/app/api/speakers.py
+++ b/app/api/speakers.py
@@ -1,4 +1,7 @@
-from flask.ext.restplus import Namespace
+from flask.ext.restplus import Namespace, marshal_with
+from flask_login import current_user
+from functools import wraps
+from flask import g
 
 from app.models.speaker import Speaker as SpeakerModel
 
@@ -49,6 +52,14 @@
 del SPEAKER_POST['id']
 del SPEAKER_POST['sessions']  # don't allow adding sessions
 
+SPEAKER_PRIVATE = api.clone('SpeakerPrivate', SPEAKER)
+del SPEAKER_PRIVATE['email']
+del SPEAKER_PRIVATE['mobile']
+
+SPEAKER_PAGINATED_PRIVATE = api.clone('SpeakerPaginatedPrivate', PAGINATED_MODEL, {
+    'results': fields.List(fields.Nested(SPEAKER_PRIVATE))
+})
+
 
 # Create DAO
 class SpeakerDAO(ServiceDAO):
@@ -73,28 +84,58 @@ def validate(self, data, event_id, check_required=True):
 DAO = SpeakerDAO(SpeakerModel, SPEAKER_POST)
 
 
+# ############
+# Response Marshaller
+# ############
+
+def speakers_marshal_with(fields=None, fields_private=None):
+    """
+    Response marshalling for speakers. Doesn't update apidoc
+    """
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            user = getattr(g, 'user', None)
+            event_id = kwargs.get('event_id')
+            if user is None and current_user.is_authenticated:
+                # in case of GET requests. No requires_auth there
+                user = current_user
+            if user and (user.has_role(event_id) or user.is_staff):
+                model = fields if fields else SPEAKER
+            else:
+                model = fields_private if fields_private else SPEAKER_PRIVATE
+            func2 = marshal_with(model)(func)
+            return func2(*args, **kwargs)
+        return wrapper
+    return decorator
+
+
+# ############
+# API Resource
+# ############
+
 @api.route('/events/<int:event_id>/speakers/<int:speaker_id>')
 @api.doc(responses=SERVICE_RESPONSES)
 class Speaker(Resource):
-    @api.doc('get_speaker')
+    @api.doc('get_speaker', model=SPEAKER)
     @api.header(*ETAG_HEADER_DEFN)
-    @api.marshal_with(SPEAKER)
+    @speakers_marshal_with()
     def get(self, event_id, speaker_id):
         """Fetch a speaker given its id"""
         return DAO.get(event_id, speaker_id)
 
     @requires_auth
     @can_delete(DAO)
-    @api.doc('delete_speaker')
-    @api.marshal_with(SPEAKER)
+    @api.doc('delete_speaker', model=SPEAKER)
+    @speakers_marshal_with()
     def delete(self, event_id, speaker_id):
         """Delete a speaker given its id"""
         return DAO.delete(event_id, speaker_id)
 
     @requires_auth
     @can_update(DAO)
-    @api.doc('update_speaker', responses=PUT_RESPONSES)
-    @api.marshal_with(SPEAKER)
+    @api.doc('update_speaker', responses=PUT_RESPONSES, model=SPEAKER)
+    @speakers_marshal_with()
     @api.expect(SPEAKER_POST)
     def put(self, event_id, speaker_id):
         """Update a speaker given its id"""
@@ -103,17 +144,17 @@ def put(self, event_id, speaker_id):
 
 @api.route('/events/<int:event_id>/speakers')
 class SpeakerList(Resource):
-    @api.doc('list_speakers')
+    @api.doc('list_speakers', model=[SPEAKER])
     @api.header(*ETAG_HEADER_DEFN)
-    @api.marshal_list_with(SPEAKER)
+    @speakers_marshal_with()
     def get(self, event_id):
         """List all speakers"""
         return DAO.list(event_id)
 
     @requires_auth
     @can_create(DAO)
-    @api.doc('create_speaker', responses=POST_RESPONSES)
-    @api.marshal_with(SPEAKER)
+    @api.doc('create_speaker', responses=POST_RESPONSES, model=SPEAKER)
+    @speakers_marshal_with()
     @api.expect(SPEAKER_POST)
     def post(self, event_id):
         """Create a speaker"""
@@ -127,8 +168,9 @@ def post(self, event_id):
 @api.route('/events/<int:event_id>/speakers/page')
 class SpeakerListPaginated(Resource, PaginatedResourceBase):
     @api.doc('list_speakers_paginated', params=PAGE_PARAMS)
+    @api.doc(model=SPEAKER_PAGINATED)
     @api.header(*ETAG_HEADER_DEFN)
-    @api.marshal_with(SPEAKER_PAGINATED)
+    @speakers_marshal_with(fields=SPEAKER_PAGINATED, fields_private=SPEAKER_PAGINATED_PRIVATE)
     def get(self, event_id):
         """List speakers in a paginated manner"""
         args = self.parser.parse_args()


Test patch:
diff --git a/tests/api/test_get_api_200.py b/tests/api/test_get_api_200.py
--- a/tests/api/test_get_api_200.py
+++ b/tests/api/test_get_api_200.py
@@ -2,7 +2,7 @@
 
 from tests.setup_database import Setup
 from tests.utils import OpenEventTestCase
-from tests.auth_helper import register, login
+from tests.auth_helper import register, logout
 from tests.api.utils import get_path, create_event, create_services
 
 from app import current_app as app
@@ -27,7 +27,6 @@ def _test_path(self, path, *strings):
         contains event/service name.
         """
         with app.test_request_context():
-            login(self.app, u'test@example.com', u'test')
             response = self.app.get(path, follow_redirects=True)
             self.assertEqual(response.status_code, 200)
             for string in strings:
@@ -51,7 +50,13 @@ def test_session_api(self):
 
     def test_speaker_api(self):
         path = get_path(1, 'speakers', 1)
-        self._test_path(path, 'TestSpeaker_1')
+        # logged in and check
+        self._test_path(path, 'TestSpeaker_1', 'email', 'mobile')
+        # logged out, private fields not present
+        logout(self.app)
+        resp = self.app.get(path)
+        self.assertNotIn('email', resp.data)
+        self.assertNotIn('mobile', resp.data)
 
     def test_sponsor_api(self):
         path = get_path(1, 'sponsors', 1)