From 0f21a37d06f3b4d7b2477aff7ba34ef092f1ce4f Mon Sep 17 00:00:00 2001
From: Gauvain Pocentek <gauvain.pocentek@objectif-libre.com>
Date: Sat, 9 May 2015 19:05:04 +0200
Subject: [PATCH] Enforce a default policy

Provide a default policy which requires to be admin for every API call except
rating/quote, report/total and storage/list_data_frames (which will require a
bit more control to return only the current tenant data for non-admin users).

Also provide a custom Context class to extract roles information from the API
request.

hashmap controllers now use a new base class which provides a custom
_route method. This will avoid to define policies for core and external
configuration APIs.

Change-Id: Ie3feb4e926270b95ab813a5d24854d1df1758a5e
---
 cloudkitty/api/hooks.py                       |  3 +-
 cloudkitty/api/v1/controllers/collector.py    |  5 ++
 cloudkitty/api/v1/controllers/rating.py       | 16 ++++++
 cloudkitty/api/v1/controllers/report.py       |  4 ++
 cloudkitty/api/v1/controllers/storage.py      |  3 ++
 cloudkitty/common/context.py                  | 53 +++++++++++++++++++
 cloudkitty/rating/__init__.py                 | 14 +++++
 cloudkitty/rating/hash/controllers/field.py   |  4 +-
 cloudkitty/rating/hash/controllers/group.py   |  4 +-
 cloudkitty/rating/hash/controllers/mapping.py |  4 +-
 cloudkitty/rating/hash/controllers/root.py    |  4 +-
 cloudkitty/rating/hash/controllers/service.py |  4 +-
 .../rating/hash/controllers/threshold.py      |  4 +-
 etc/cloudkitty/policy.json                    | 20 ++++++-
 14 files changed, 128 insertions(+), 14 deletions(-)
 create mode 100644 cloudkitty/common/context.py

diff --git a/cloudkitty/api/hooks.py b/cloudkitty/api/hooks.py
index 9b7a1618..56fa9bf7 100644
--- a/cloudkitty/api/hooks.py
+++ b/cloudkitty/api/hooks.py
@@ -15,9 +15,9 @@
 #
 # @author: Stéphane Albert
 #
-from oslo_context import context
 from pecan import hooks
 
+from cloudkitty.common import context
 from cloudkitty.common import policy
 
 
@@ -49,6 +49,7 @@ class ContextHook(hooks.PecanHook):
             'tenant': headers.get('X-Tenant') or headers.get('X-Tenant-Id'),
             'auth_token': headers.get('X-Auth-Token'),
             'is_admin': is_admin,
+            'roles': roles,
         }
 
         state.request.context = context.RequestContext(**creds)
diff --git a/cloudkitty/api/v1/controllers/collector.py b/cloudkitty/api/v1/controllers/collector.py
index e14e5509..0b9eb43f 100644
--- a/cloudkitty/api/v1/controllers/collector.py
+++ b/cloudkitty/api/v1/controllers/collector.py
@@ -21,6 +21,7 @@ from wsme import types as wtypes
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1.datamodels import collector as collector_models
+from cloudkitty.common import policy
 from cloudkitty.db import api as db_api
 
 
@@ -36,6 +37,7 @@ class MappingController(rest.RestController):
 
         :return: List of every services mapped.
         """
+        policy.enforce(pecan.request.context, 'collector:list_mappings', {})
         return [mapping.service for mapping in self._db.list_services()]
 
     @wsme_pecan.wsexpose(collector_models.ServiceToCollectorMapping,
@@ -45,6 +47,7 @@ class MappingController(rest.RestController):
 
         :param service: Name of the service to filter on.
         """
+        policy.enforce(pecan.request.context, 'collector:get_mapping', {})
         try:
             return self._db.get_mapping(service)
         except db_api.NoSuchMapping as e:
@@ -70,6 +73,7 @@ class CollectorController(rest.RestController):
         :param collector: Name of the collector.
         :return: State of the collector.
         """
+        policy.enforce(pecan.request.context, 'collector:get_state', {})
         return self._db.get_state('collector_{}'.format(collector))
 
     @wsme_pecan.wsexpose(bool, wtypes.text, body=bool)
@@ -80,4 +84,5 @@ class CollectorController(rest.RestController):
         :param state: New state for the collector.
         :return: State of the collector.
         """
+        policy.enforce(pecan.request.context, 'collector:update_state', {})
         return self._db.set_state('collector_{}'.format(collector), state)
diff --git a/cloudkitty/api/v1/controllers/rating.py b/cloudkitty/api/v1/controllers/rating.py
index 773b5cf6..d3f6b697 100644
--- a/cloudkitty/api/v1/controllers/rating.py
+++ b/cloudkitty/api/v1/controllers/rating.py
@@ -22,6 +22,7 @@ from wsme import types as wtypes
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1.datamodels import rating as rating_models
+from cloudkitty.common import policy
 from cloudkitty.openstack.common import log as logging
 
 LOG = logging.getLogger(__name__)
@@ -40,12 +41,21 @@ class ModulesController(rest.RestController):
             invoke_on_load=True
         )
 
+    def route(self, *args):
+        route = args[0]
+        if route.startswith('/v1/module_config'):
+            policy.enforce(pecan.request.context, 'rating:module_config', {})
+
+        super(ModulesController, self).route(*args)
+
     @wsme_pecan.wsexpose(rating_models.CloudkittyModuleCollection)
     def get_all(self):
         """return the list of loaded modules.
 
         :return: name of every loaded modules.
         """
+        policy.enforce(pecan.request.context, 'rating:list_modules', {})
+
         modules_list = []
         for module in self.extensions:
             infos = module.obj.module_info.copy()
@@ -61,6 +71,8 @@ class ModulesController(rest.RestController):
 
         :return: CloudKittyModule
         """
+        policy.enforce(pecan.request.context, 'rating:get_module', {})
+
         try:
             module = self.extensions[module_id]
         except KeyError:
@@ -79,6 +91,8 @@ class ModulesController(rest.RestController):
         :param module_id: name of the module to modify
         :param module: CloudKittyModule object describing the new desired state
         """
+        policy.enforce(pecan.request.context, 'rating:update_module', {})
+
         try:
             ext = self.extensions[module_id].obj
         except KeyError:
@@ -153,6 +167,8 @@ class RatingController(rest.RestController):
         :param res_data: List of resource descriptions.
         :return: Total price for these descriptions.
         """
+        policy.enforce(pecan.request.context, 'rating:quote', {})
+
         client = pecan.request.rpc_client.prepare(namespace='rating')
         res_dict = {}
         for res in res_data.resources:
diff --git a/cloudkitty/api/v1/controllers/report.py b/cloudkitty/api/v1/controllers/report.py
index 750c683a..96efb973 100644
--- a/cloudkitty/api/v1/controllers/report.py
+++ b/cloudkitty/api/v1/controllers/report.py
@@ -23,6 +23,8 @@ from pecan import rest
 from wsme import types as wtypes
 import wsmeext.pecan as wsme_pecan
 
+from cloudkitty.common import policy
+
 
 class ReportController(rest.RestController):
     """REST Controller managing the reporting.
@@ -41,6 +43,7 @@ class ReportController(rest.RestController):
         """Return the list of rated tenants.
 
         """
+        policy.enforce(pecan.request.context, 'report:list_tenants', {})
         storage = pecan.request.storage_backend
         tenants = storage.get_tenants(begin, end)
         return tenants
@@ -53,6 +56,7 @@ class ReportController(rest.RestController):
         """Return the amount to pay for a given period.
 
         """
+        policy.enforce(pecan.request.context, 'report:get_total', {})
         storage = pecan.request.storage_backend
         # FIXME(sheeprine): We should filter on user id.
         # Use keystone token information by default but make it overridable and
diff --git a/cloudkitty/api/v1/controllers/storage.py b/cloudkitty/api/v1/controllers/storage.py
index e6a6c94a..2aba6245 100644
--- a/cloudkitty/api/v1/controllers/storage.py
+++ b/cloudkitty/api/v1/controllers/storage.py
@@ -24,6 +24,7 @@ from wsme import types as wtypes
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1.datamodels import storage as storage_models
+from cloudkitty.common import policy
 from cloudkitty import storage as ck_storage
 from cloudkitty import utils as ck_utils
 
@@ -46,6 +47,8 @@ class DataFramesController(rest.RestController):
         :return: Collection of DataFrame objects.
         """
 
+        policy.enforce(pecan.request.context, 'storage:list_data_frames', {})
+
         begin_ts = ck_utils.dt2ts(begin)
         end_ts = ck_utils.dt2ts(end)
         backend = pecan.request.storage_backend
diff --git a/cloudkitty/common/context.py b/cloudkitty/common/context.py
new file mode 100644
index 00000000..0724c2ad
--- /dev/null
+++ b/cloudkitty/common/context.py
@@ -0,0 +1,53 @@
+# -*- encoding: utf-8 -*-
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from oslo_context import context
+
+
+class RequestContext(context.RequestContext):
+    """Extends security contexts from the OpenStack common library."""
+
+    def __init__(self, auth_token=None, user=None, tenant=None, domain=None,
+                 user_domain=None, project_domain=None, is_admin=False,
+                 read_only=False, show_deleted=False, request_id=None,
+                 resource_uuid=None, overwrite=True, roles=None):
+        """Extra parameter:
+
+        :param roles: List of user's roles if any.
+
+        """
+        self.roles = roles or []
+
+        super(RequestContext, self).__init__(auth_token=auth_token,
+                                             user=user, tenant=tenant,
+                                             domain=domain,
+                                             user_domain=user_domain,
+                                             project_domain=project_domain,
+                                             is_admin=is_admin,
+                                             read_only=read_only,
+                                             show_deleted=show_deleted,
+                                             request_id=request_id,
+                                             resource_uuid=resource_uuid,
+                                             overwrite=overwrite)
+
+    def to_dict(self):
+        d = super(RequestContext, self).to_dict()
+        d['roles'] = self.roles
+        return d
+
+    @classmethod
+    def from_dict(cls, values):
+        values.pop('user', None)
+        values.pop('tenant', None)
+        return cls(**values)
diff --git a/cloudkitty/rating/__init__.py b/cloudkitty/rating/__init__.py
index fb125e41..61cd4325 100644
--- a/cloudkitty/rating/__init__.py
+++ b/cloudkitty/rating/__init__.py
@@ -17,8 +17,11 @@
 #
 import abc
 
+import pecan
+from pecan import rest
 import six
 
+from cloudkitty.common import policy
 from cloudkitty.db import api as db_api
 from cloudkitty import rpc
 
@@ -132,3 +135,14 @@ class RatingProcessorBase(object):
     def notify_reload(self):
         client = rpc.get_client().prepare(namespace='rating', fanout=True)
         client.cast({}, 'reload_module', name=self.module_name)
+
+
+class RatingRestControllerBase(rest.RestController):
+    @pecan.expose()
+    def _route(self, args, request):
+        try:
+            policy.enforce(request.context, 'rating:module_config', {})
+        except policy.PolicyNotAuthorized as e:
+            pecan.abort(403, str(e))
+
+        return super(RatingRestControllerBase, self)._route(args, request)
diff --git a/cloudkitty/rating/hash/controllers/field.py b/cloudkitty/rating/hash/controllers/field.py
index 2e7809c4..fa9a57fa 100644
--- a/cloudkitty/rating/hash/controllers/field.py
+++ b/cloudkitty/rating/hash/controllers/field.py
@@ -16,15 +16,15 @@
 # @author: Stéphane Albert
 #
 import pecan
-from pecan import rest
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1 import types as ck_types
+from cloudkitty import rating
 from cloudkitty.rating.hash.datamodels import field as field_models
 from cloudkitty.rating.hash.db import api as db_api
 
 
-class HashMapFieldsController(rest.RestController):
+class HashMapFieldsController(rating.RatingRestControllerBase):
     """Controller responsible of fields management.
 
     """
diff --git a/cloudkitty/rating/hash/controllers/group.py b/cloudkitty/rating/hash/controllers/group.py
index 1dd22592..54a89f49 100644
--- a/cloudkitty/rating/hash/controllers/group.py
+++ b/cloudkitty/rating/hash/controllers/group.py
@@ -16,16 +16,16 @@
 # @author: Stéphane Albert
 #
 import pecan
-from pecan import rest
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1 import types as ck_types
+from cloudkitty import rating
 from cloudkitty.rating.hash.datamodels import group as group_models
 from cloudkitty.rating.hash.datamodels import mapping as mapping_models
 from cloudkitty.rating.hash.db import api as db_api
 
 
-class HashMapGroupsController(rest.RestController):
+class HashMapGroupsController(rating.RatingRestControllerBase):
     """Controller responsible of groups management.
 
     """
diff --git a/cloudkitty/rating/hash/controllers/mapping.py b/cloudkitty/rating/hash/controllers/mapping.py
index d7f47d5e..f510b051 100644
--- a/cloudkitty/rating/hash/controllers/mapping.py
+++ b/cloudkitty/rating/hash/controllers/mapping.py
@@ -16,16 +16,16 @@
 # @author: Stéphane Albert
 #
 import pecan
-from pecan import rest
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1 import types as ck_types
+from cloudkitty import rating
 from cloudkitty.rating.hash.datamodels import group as group_models
 from cloudkitty.rating.hash.datamodels import mapping as mapping_models
 from cloudkitty.rating.hash.db import api as db_api
 
 
-class HashMapMappingsController(rest.RestController):
+class HashMapMappingsController(rating.RatingRestControllerBase):
     """Controller responsible of mappings management.
 
     """
diff --git a/cloudkitty/rating/hash/controllers/root.py b/cloudkitty/rating/hash/controllers/root.py
index 267c8f2d..42b250dd 100644
--- a/cloudkitty/rating/hash/controllers/root.py
+++ b/cloudkitty/rating/hash/controllers/root.py
@@ -15,10 +15,10 @@
 #
 # @author: Stéphane Albert
 #
-from pecan import rest
 from wsme import types as wtypes
 import wsmeext.pecan as wsme_pecan
 
+from cloudkitty import rating
 from cloudkitty.rating.hash.controllers import field as field_api
 from cloudkitty.rating.hash.controllers import group as group_api
 from cloudkitty.rating.hash.controllers import mapping as mapping_api
@@ -27,7 +27,7 @@ from cloudkitty.rating.hash.controllers import threshold as threshold_api
 from cloudkitty.rating.hash.datamodels import mapping as mapping_models
 
 
-class HashMapConfigController(rest.RestController):
+class HashMapConfigController(rating.RatingRestControllerBase):
     """Controller exposing all management sub controllers.
 
     """
diff --git a/cloudkitty/rating/hash/controllers/service.py b/cloudkitty/rating/hash/controllers/service.py
index 3d70870d..71d108c0 100644
--- a/cloudkitty/rating/hash/controllers/service.py
+++ b/cloudkitty/rating/hash/controllers/service.py
@@ -16,16 +16,16 @@
 # @author: Stéphane Albert
 #
 import pecan
-from pecan import rest
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1 import types as ck_types
+from cloudkitty import rating
 from cloudkitty.rating.hash.controllers import field as field_api
 from cloudkitty.rating.hash.datamodels import service as service_models
 from cloudkitty.rating.hash.db import api as db_api
 
 
-class HashMapServicesController(rest.RestController):
+class HashMapServicesController(rating.RatingRestControllerBase):
     """Controller responsible of services management.
 
     """
diff --git a/cloudkitty/rating/hash/controllers/threshold.py b/cloudkitty/rating/hash/controllers/threshold.py
index 4f3030d2..390dc066 100644
--- a/cloudkitty/rating/hash/controllers/threshold.py
+++ b/cloudkitty/rating/hash/controllers/threshold.py
@@ -16,16 +16,16 @@
 # @author: Stéphane Albert
 #
 import pecan
-from pecan import rest
 import wsmeext.pecan as wsme_pecan
 
 from cloudkitty.api.v1 import types as ck_types
+from cloudkitty import rating
 from cloudkitty.rating.hash.datamodels import group as group_models
 from cloudkitty.rating.hash.datamodels import threshold as threshold_models
 from cloudkitty.rating.hash.db import api as db_api
 
 
-class HashMapThresholdsController(rest.RestController):
+class HashMapThresholdsController(rating.RatingRestControllerBase):
     """Controller responsible of thresholds management.
 
     """
diff --git a/etc/cloudkitty/policy.json b/etc/cloudkitty/policy.json
index 155cfdc3..4b77deac 100644
--- a/etc/cloudkitty/policy.json
+++ b/etc/cloudkitty/policy.json
@@ -1,3 +1,21 @@
 {
-    "context_is_admin": "role:admin"
+    "context_is_admin":  "role:admin",
+    "default": "",
+
+    "rating:list_modules": "role:admin",
+    "rating:get_module": "role:admin",
+    "rating:update_module": "role:admin",
+    "rating:quote": "",
+
+    "report:list_tenants": "role:admin",
+    "report:get_total": "",
+
+    "collector:list_mappings": "role:admin",
+    "collector:get_mapping": "role:admin",
+    "collector:get_state": "role:admin",
+    "collector:update_state": "role:admin",
+
+    "storage:list_data_frames": "",
+
+    "rating:module_config": "role:admin"
 }