From a7bc4463a88ced03e5ed7d61d6fc66ef2d3f26d1 Mon Sep 17 00:00:00 2001
From: gordon chung <gord@live.ca>
Date: Sun, 30 Aug 2015 22:37:32 -0400
Subject: [PATCH] restrict admin event access

this patch restricts the scope of returned events. if admin role,
user is allowed to query all events which have traits.project_id
value that match it's own project OR any event without any
project_id trait.

Implements: blueprint events-rbac
Change-Id: I41d30b2fc11c226e109c499e7a35fdb2fa057e0b
---
 ceilometer/api/controllers/v2/events.py       | 29 +++++++-----
 .../event/storage/impl_elasticsearch.py       | 11 +++--
 ceilometer/event/storage/impl_sqlalchemy.py   | 11 ++++-
 ceilometer/storage/__init__.py                |  5 ++-
 ceilometer/storage/mongo/utils.py             | 29 ++++++------
 .../functional/api/v2/test_event_scenarios.py | 45 +++++++++++++++++++
 6 files changed, 102 insertions(+), 28 deletions(-)

diff --git a/ceilometer/api/controllers/v2/events.py b/ceilometer/api/controllers/v2/events.py
index b314dde0e4..1b93ac7c28 100644
--- a/ceilometer/api/controllers/v2/events.py
+++ b/ceilometer/api/controllers/v2/events.py
@@ -157,16 +157,19 @@ class Event(base.Base):
         )
 
 
-def _add_user_proj_filter():
-    traits_filter = []
+def _build_rbac_query_filters():
+    filters = {'t_filter': [], 'admin_proj': None}
     # Returns user_id, proj_id for non-admins
     user_id, proj_id = rbac.get_limited_to(pecan.request.headers)
     # If non-admin, filter events by user and project
-    if (user_id and proj_id):
-        traits_filter.append({"key": "project_id", "string": proj_id,
-                              "op": "eq"})
-        traits_filter.append({"key": "user_id", "string": user_id, "op": "eq"})
-    return traits_filter
+    if user_id and proj_id:
+        filters['t_filter'].append({"key": "project_id", "string": proj_id,
+                                    "op": "eq"})
+        filters['t_filter'].append({"key": "user_id", "string": user_id,
+                                    "op": "eq"})
+    elif not user_id and not proj_id:
+        filters['admin_proj'] = pecan.request.headers.get('X-Project-Id')
+    return filters
 
 
 def _event_query_to_event_filter(q):
@@ -176,7 +179,9 @@ def _event_query_to_event_filter(q):
         'start_timestamp': None,
         'end_timestamp': None
     }
-    traits_filter = _add_user_proj_filter()
+    filters = _build_rbac_query_filters()
+    traits_filter = filters['t_filter']
+    admin_proj = filters['admin_proj']
 
     for i in q:
         if not i.op:
@@ -193,7 +198,8 @@ def _event_query_to_event_filter(q):
             traits_filter.append({"key": i.field,
                                   trait_type: i._get_value_as_type(),
                                   "op": i.op})
-    return storage.EventFilter(traits_filter=traits_filter, **evt_model_filter)
+    return storage.EventFilter(traits_filter=traits_filter,
+                               admin_proj=admin_proj, **evt_model_filter)
 
 
 class TraitsController(rest.RestController):
@@ -279,8 +285,11 @@ class EventsController(rest.RestController):
         :param message_id: Message ID of the Event to be returned
         """
         rbac.enforce("events:show", pecan.request)
-        t_filter = _add_user_proj_filter()
+        filters = _build_rbac_query_filters()
+        t_filter = filters['t_filter']
+        admin_proj = filters['admin_proj']
         event_filter = storage.EventFilter(traits_filter=t_filter,
+                                           admin_proj=admin_proj,
                                            message_id=message_id)
         events = [event for event
                   in pecan.request.event_storage_conn.get_events(event_filter)]
diff --git a/ceilometer/event/storage/impl_elasticsearch.py b/ceilometer/event/storage/impl_elasticsearch.py
index 9a992ccc6a..ab7ee8670a 100644
--- a/ceilometer/event/storage/impl_elasticsearch.py
+++ b/ceilometer/event/storage/impl_elasticsearch.py
@@ -141,9 +141,10 @@ class Connection(base.Connection):
             q_args['doc_type'] = ev_filter.event_type
         if ev_filter.message_id:
             filters.append({'term': {'_id': ev_filter.message_id}})
-        if ev_filter.traits_filter:
+        if ev_filter.traits_filter or ev_filter.admin_proj:
             trait_filters = []
-            for t_filter in ev_filter.traits_filter:
+            or_cond = []
+            for t_filter in ev_filter.traits_filter or []:
                 value = None
                 for val_type in ['integer', 'string', 'float', 'datetime']:
                     if t_filter.get(val_type):
@@ -164,9 +165,13 @@ class Connection(base.Connection):
                     if t_filter.get('op') == 'ne':
                         tf = {"not": tf}
                     trait_filters.append(tf)
+            if ev_filter.admin_proj:
+                or_cond = [{'missing': {'field': 'project_id'}},
+                           {'term': {'project_id': ev_filter.admin_proj}}]
             filters.append(
                 {'nested': {'path': 'traits', 'query': {'filtered': {
-                    'filter': {'bool': {'must': trait_filters}}}}}})
+                    'filter': {'bool': {'must': trait_filters,
+                                        'should': or_cond}}}}}})
 
         q_args['body'] = {'query': {'filtered':
                                     {'filter': {'bool': {'must': filters}}}}}
diff --git a/ceilometer/event/storage/impl_sqlalchemy.py b/ceilometer/event/storage/impl_sqlalchemy.py
index a34831a56a..e0497b420e 100644
--- a/ceilometer/event/storage/impl_sqlalchemy.py
+++ b/ceilometer/event/storage/impl_sqlalchemy.py
@@ -265,6 +265,15 @@ class Connection(base.Connection):
             if trait_subq is not None:
                 query = query.join(trait_subq,
                                    trait_subq.c.ev_id == models.Event.id)
+            if event_filter.admin_proj:
+                admin_q = session.query(models.TraitText).filter(
+                    models.TraitText.key == 'project_id')
+                query = query.filter(sa.or_(~sa.exists().where(
+                    models.Event.id == admin_q.subquery().c.event_id),
+                    sa.and_(
+                        models.TraitText.key == 'project_id',
+                        models.TraitText.value == event_filter.admin_proj,
+                        models.Event.id == models.TraitText.event_id)))
             if event_filter_conditions:
                 query = query.filter(sa.and_(*event_filter_conditions))
 
@@ -274,7 +283,7 @@ class Connection(base.Connection):
             for (id_, generated, message_id,
                  desc, raw) in query.add_columns(
                      models.Event.generated, models.Event.message_id,
-                     models.EventType.desc, models.Event.raw).all():
+                     models.EventType.desc, models.Event.raw).distinct().all():
                 event_list[id_] = api_models.Event(message_id, desc,
                                                    generated, [], raw)
             # Query all traits related to events.
diff --git a/ceilometer/storage/__init__.py b/ceilometer/storage/__init__.py
index 2e576f47e4..a62e1cc147 100644
--- a/ceilometer/storage/__init__.py
+++ b/ceilometer/storage/__init__.py
@@ -208,6 +208,7 @@ class EventFilter(object):
     :param end_timestamp: UTC end datetime (mandatory)
     :param event_type: the name of the event. None for all.
     :param message_id: the message_id of the event. None for all.
+    :param admin_proj: the project_id of admin role. None if non-admin user.
     :param traits_filter: the trait filter dicts, all of which are optional.
       This parameter is a list of dictionaries that specify trait values:
 
@@ -222,12 +223,14 @@ class EventFilter(object):
     """
 
     def __init__(self, start_timestamp=None, end_timestamp=None,
-                 event_type=None, message_id=None, traits_filter=None):
+                 event_type=None, message_id=None, traits_filter=None,
+                 admin_proj=None):
         self.start_timestamp = utils.sanitize_timestamp(start_timestamp)
         self.end_timestamp = utils.sanitize_timestamp(end_timestamp)
         self.message_id = message_id
         self.event_type = event_type
         self.traits_filter = traits_filter or []
+        self.admin_proj = admin_proj
 
     def __repr__(self):
         return ("<EventFilter(start_timestamp: %s,"
diff --git a/ceilometer/storage/mongo/utils.py b/ceilometer/storage/mongo/utils.py
index 08fc7768a9..8cc588b4b3 100644
--- a/ceilometer/storage/mongo/utils.py
+++ b/ceilometer/storage/mongo/utils.py
@@ -88,18 +88,18 @@ def make_events_query_from_filter(event_filter):
 
     :param event_filter: storage.EventFilter object.
     """
-    q = {}
+    query = {}
+    q_list = []
     ts_range = make_timestamp_range(event_filter.start_timestamp,
                                     event_filter.end_timestamp)
     if ts_range:
-        q['timestamp'] = ts_range
+        q_list.append({'timestamp': ts_range})
     if event_filter.event_type:
-        q['event_type'] = event_filter.event_type
+        q_list.append({'event_type': event_filter.event_type})
     if event_filter.message_id:
-        q['_id'] = event_filter.message_id
+        q_list.append({'_id': event_filter.message_id})
 
     if event_filter.traits_filter:
-        q.setdefault('traits')
         for trait_filter in event_filter.traits_filter:
             op = trait_filter.pop('op', 'eq')
             dict_query = {}
@@ -116,14 +116,17 @@ def make_events_query_from_filter(event_filter):
                                               v if op == 'eq'
                                               else {OP_SIGN[op]: v})
             dict_query = {'$elemMatch': dict_query}
-            if q['traits'] is None:
-                q['traits'] = dict_query
-            elif q.get('$and') is None:
-                q.setdefault('$and', [{'traits': q.pop('traits')},
-                                      {'traits': dict_query}])
-            else:
-                q['$and'].append({'traits': dict_query})
-    return q
+            q_list.append({'traits': dict_query})
+    if event_filter.admin_proj:
+        q_list.append({'$or': [
+            {'traits': {'$not': {'$elemMatch': {'trait_name': 'project_id'}}}},
+            {'traits': {
+                '$elemMatch': {'trait_name': 'project_id',
+                               'trait_value': event_filter.admin_proj}}}]})
+    if q_list:
+        query = {'$and': q_list}
+
+    return query
 
 
 def make_query_from_filter(sample_filter, require_meter=True):
diff --git a/ceilometer/tests/functional/api/v2/test_event_scenarios.py b/ceilometer/tests/functional/api/v2/test_event_scenarios.py
index aba01f0d7e..76ad707577 100644
--- a/ceilometer/tests/functional/api/v2/test_event_scenarios.py
+++ b/ceilometer/tests/functional/api/v2/test_event_scenarios.py
@@ -540,6 +540,51 @@ class AclRestrictedEventTestBase(v2.FunctionalTest,
                              expect_errors=True)
         self.assertEqual(404, data.status_int)
 
+    @tests_db.run_with('sqlite', 'mysql', 'pgsql', 'mongodb', 'es', 'db2')
+    def test_admin_access(self):
+        a_headers = {"X-Roles": "admin",
+                     "X-User-Id": self.admin_user_id,
+                     "X-Project-Id": self.admin_proj_id}
+        data = self.get_json('/events', headers=a_headers)
+        self.assertEqual(2, len(data))
+        self.assertEqual(set(['empty_ev', 'admin_ev']),
+                         set(ev['event_type'] for ev in data))
+
+    @tests_db.run_with('sqlite', 'mysql', 'pgsql', 'mongodb', 'es', 'db2')
+    def test_admin_access_trait_filter(self):
+        a_headers = {"X-Roles": "admin",
+                     "X-User-Id": self.admin_user_id,
+                     "X-Project-Id": self.admin_proj_id}
+        data = self.get_json('/events', headers=a_headers,
+                             q=[{'field': 'random',
+                                 'value': 'blah',
+                                 'type': 'string',
+                                 'op': 'eq'}])
+        self.assertEqual(1, len(data))
+        self.assertEqual('empty_ev', data[0]['event_type'])
+
+    @tests_db.run_with('sqlite', 'mysql', 'pgsql', 'mongodb', 'es', 'db2')
+    def test_admin_access_single(self):
+        a_headers = {"X-Roles": "admin",
+                     "X-User-Id": self.admin_user_id,
+                     "X-Project-Id": self.admin_proj_id}
+        data = self.get_json('/events/1', headers=a_headers)
+        self.assertEqual('empty_ev', data['event_type'])
+        data = self.get_json('/events/2', headers=a_headers)
+        self.assertEqual('admin_ev', data['event_type'])
+
+    @tests_db.run_with('sqlite', 'mysql', 'pgsql', 'mongodb', 'es', 'db2')
+    def test_admin_access_trait_filter_no_access(self):
+        a_headers = {"X-Roles": "admin",
+                     "X-User-Id": self.admin_user_id,
+                     "X-Project-Id": self.admin_proj_id}
+        data = self.get_json('/events', headers=a_headers,
+                             q=[{'field': 'user_id',
+                                 'value': self.user_id,
+                                 'type': 'string',
+                                 'op': 'eq'}])
+        self.assertEqual(0, len(data))
+
 
 class EventRestrictionTestBase(v2.FunctionalTest,
                                tests_db.MixinTestsWithBackendScenarios):