From 7eca6726453b8c07acbb67bc276b77ec32e5ec1b Mon Sep 17 00:00:00 2001 From: Jeremy Liu Date: Tue, 5 Sep 2017 17:52:45 +0800 Subject: [PATCH] Policy in code This patch introduces the implementation for registering default policy rules in code. Default rules are defined under cloudkitty.common.policies. Each API's policies are defined in a sub-folder under that path and __init__.py contains all the default policies in code which are registered in the ``init`` enforcer function in cloudkitty/common/policy.py. This commit does the following: - Creates the ``policies`` module that contains all the default policies in code. - Adds the base policy rules into code (context_is_admin, admin_or_owner and default rules). - Add policies in code for current APIs - Add a tox env to generate default policy sample file - Delete policy.json from repo as policies in code will be used. Change-Id: I257e8cefc2b699fc979c717531cd9ba77233d94b Implements: blueprint policy-in-code --- .gitignore | 1 + cloudkitty/api/v1/controllers/collector.py | 12 +- cloudkitty/api/v1/controllers/info.py | 6 +- cloudkitty/api/v1/controllers/rating.py | 12 +- cloudkitty/api/v1/controllers/report.py | 10 +- cloudkitty/api/v1/controllers/storage.py | 2 +- cloudkitty/common/policies/__init__.py | 34 +++++ cloudkitty/common/policies/base.py | 36 ++++++ cloudkitty/common/policies/collector.py | 57 ++++++++ cloudkitty/common/policies/info.py | 43 ++++++ cloudkitty/common/policies/rating.py | 56 ++++++++ cloudkitty/common/policies/report.py | 43 ++++++ cloudkitty/common/policies/storage.py | 32 +++++ cloudkitty/common/policy.py | 85 +++++++++++- cloudkitty/rating/__init__.py | 2 +- cloudkitty/tests/test_policy.py | 122 ++++++++++++++++++ cloudkitty/utils.py | 18 +++ devstack/plugin.sh | 6 +- doc/source/conf.py | 5 + doc/source/configuration/policy.rst | 12 ++ doc/source/configuration/samples/index.rst | 11 ++ .../configuration/samples/policy-yaml.rst | 8 ++ doc/source/sample_policy.rst | 15 +++ etc/cloudkitty/policy.json | 28 ---- etc/oslo-policy-generator/cloudkitty.conf | 3 + setup.cfg | 6 + tox.ini | 3 + 27 files changed, 611 insertions(+), 57 deletions(-) create mode 100644 cloudkitty/common/policies/__init__.py create mode 100644 cloudkitty/common/policies/base.py create mode 100644 cloudkitty/common/policies/collector.py create mode 100644 cloudkitty/common/policies/info.py create mode 100644 cloudkitty/common/policies/rating.py create mode 100644 cloudkitty/common/policies/report.py create mode 100644 cloudkitty/common/policies/storage.py create mode 100644 cloudkitty/tests/test_policy.py create mode 100644 doc/source/configuration/policy.rst create mode 100644 doc/source/configuration/samples/index.rst create mode 100644 doc/source/configuration/samples/policy-yaml.rst create mode 100644 doc/source/sample_policy.rst delete mode 100644 etc/cloudkitty/policy.json create mode 100644 etc/oslo-policy-generator/cloudkitty.conf diff --git a/.gitignore b/.gitignore index 574dd04c..5a1a6257 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ cloudkitty.egg-info # Configuration file etc/cloudkitty/cloudkitty.conf.sample +etc/cloudkitty/policy.yaml.sample # Installer logs pip-log.txt diff --git a/cloudkitty/api/v1/controllers/collector.py b/cloudkitty/api/v1/controllers/collector.py index 3ea6d37e..a138bfbc 100644 --- a/cloudkitty/api/v1/controllers/collector.py +++ b/cloudkitty/api/v1/controllers/collector.py @@ -39,7 +39,7 @@ class MappingController(rest.RestController): :param service: Name of the service to filter on. """ - policy.enforce(pecan.request.context, 'collector:get_mapping', {}) + policy.authorize(pecan.request.context, 'collector:get_mapping', {}) try: mapping = self._db.get_mapping(service) return collector_models.ServiceToCollectorMapping( @@ -55,7 +55,7 @@ class MappingController(rest.RestController): :param collector: Filter on the collector name. :return: Service to collector mappings collection. """ - policy.enforce(pecan.request.context, 'collector:list_mappings', {}) + policy.authorize(pecan.request.context, 'collector:list_mappings', {}) mappings = [collector_models.ServiceToCollectorMapping( **mapping.as_dict()) for mapping in self._db.list_mappings(collector)] @@ -71,7 +71,7 @@ class MappingController(rest.RestController): :param collector: Name of the collector to apply mapping on. :param service: Name of the service to apply mapping on. """ - policy.enforce(pecan.request.context, 'collector:manage_mapping', {}) + policy.authorize(pecan.request.context, 'collector:manage_mapping', {}) new_mapping = self._db.set_mapping(service, collector) return collector_models.ServiceToCollectorMapping( service=new_mapping.service, @@ -85,7 +85,7 @@ class MappingController(rest.RestController): :param service: Name of the service to filter on. """ - policy.enforce(pecan.request.context, 'collector:manage_mapping', {}) + policy.authorize(pecan.request.context, 'collector:manage_mapping', {}) try: self._db.delete_mapping(service) except db_api.NoSuchMapping as e: @@ -105,7 +105,7 @@ class CollectorStateController(rest.RestController): :param name: Name of the collector. :return: State of the collector. """ - policy.enforce(pecan.request.context, 'collector:get_state', {}) + policy.authorize(pecan.request.context, 'collector:get_state', {}) enabled = self._db.get_state('collector_{}'.format(name)) collector = collector_models.CollectorInfos(name=name, enabled=enabled) @@ -121,7 +121,7 @@ class CollectorStateController(rest.RestController): :param infos: New state informations of the collector. :return: State of the collector. """ - policy.enforce(pecan.request.context, 'collector:update_state', {}) + policy.authorize(pecan.request.context, 'collector:update_state', {}) enabled = self._db.set_state('collector_{}'.format(name), infos.enabled) collector = collector_models.CollectorInfos(name=name, diff --git a/cloudkitty/api/v1/controllers/info.py b/cloudkitty/api/v1/controllers/info.py index 166cbe29..da4192a5 100644 --- a/cloudkitty/api/v1/controllers/info.py +++ b/cloudkitty/api/v1/controllers/info.py @@ -44,7 +44,7 @@ class ServiceInfoController(rest.RestController): :return: List of every services. """ - policy.enforce(pecan.request.context, 'info:list_services_info', {}) + policy.authorize(pecan.request.context, 'info:list_services_info', {}) services_info_list = [] for service, metadata in METADATA.items(): info = metadata.copy() @@ -60,7 +60,7 @@ class ServiceInfoController(rest.RestController): :param service_name: name of the service. """ - policy.enforce(pecan.request.context, 'info:get_service_info', {}) + policy.authorize(pecan.request.context, 'info:get_service_info', {}) try: info = METADATA[service_name].copy() info['service_id'] = service_name @@ -81,7 +81,7 @@ class InfoController(rest.RestController): }) def config(self): """Return current configuration.""" - policy.enforce(pecan.request.context, 'info:get_config', {}) + policy.authorize(pecan.request.context, 'info:get_config', {}) info = {} info["collect"] = ck_utils.get_metrics_conf(CONF.collect.metrics_conf) return info diff --git a/cloudkitty/api/v1/controllers/rating.py b/cloudkitty/api/v1/controllers/rating.py index fb0b6947..96458745 100644 --- a/cloudkitty/api/v1/controllers/rating.py +++ b/cloudkitty/api/v1/controllers/rating.py @@ -63,7 +63,7 @@ class ModulesController(rest.RestController, RatingModulesMixin): def route(self, *args): route = args[0] if route.startswith('/v1/module_config'): - policy.enforce(pecan.request.context, 'rating:module_config', {}) + policy.authorize(pecan.request.context, 'rating:module_config', {}) super(ModulesController, self).route(*args) @@ -73,7 +73,7 @@ class ModulesController(rest.RestController, RatingModulesMixin): :return: name of every loaded modules. """ - policy.enforce(pecan.request.context, 'rating:list_modules', {}) + policy.authorize(pecan.request.context, 'rating:list_modules', {}) modules_list = [] lock = lockutils.lock('rating-modules') @@ -92,7 +92,7 @@ class ModulesController(rest.RestController, RatingModulesMixin): :return: CloudKittyModule """ - policy.enforce(pecan.request.context, 'rating:get_module', {}) + policy.authorize(pecan.request.context, 'rating:get_module', {}) try: lock = lockutils.lock('rating-modules') @@ -114,7 +114,7 @@ class ModulesController(rest.RestController, RatingModulesMixin): :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', {}) + policy.authorize(pecan.request.context, 'rating:update_module', {}) try: lock = lockutils.lock('rating-modules') @@ -194,7 +194,7 @@ class RatingController(rest.RestController): :param res_data: List of resource descriptions. :return: Total price for these descriptions. """ - policy.enforce(pecan.request.context, 'rating:quote', {}) + policy.authorize(pecan.request.context, 'rating:quote', {}) client = pecan.request.rpc_client.prepare(namespace='rating') res_dict = {} @@ -212,7 +212,7 @@ class RatingController(rest.RestController): """Trigger a rating module list reload. """ - policy.enforce(pecan.request.context, 'rating:module_config', {}) + policy.authorize(pecan.request.context, 'rating:module_config', {}) self.modules.reload_extensions() self.module_config.reload_extensions() self.module_config.expose_modules() diff --git a/cloudkitty/api/v1/controllers/report.py b/cloudkitty/api/v1/controllers/report.py index e5979560..09c83973 100644 --- a/cloudkitty/api/v1/controllers/report.py +++ b/cloudkitty/api/v1/controllers/report.py @@ -46,7 +46,7 @@ class ReportController(rest.RestController): """Return the list of rated tenants. """ - policy.enforce(pecan.request.context, 'report:list_tenants', {}) + policy.authorize(pecan.request.context, 'report:list_tenants', {}) if not begin: begin = ck_utils.get_month_start() @@ -78,8 +78,8 @@ class ReportController(rest.RestController): else: tenant_context = pecan.request.context.tenant tenant_id = tenant_context if not tenant_id else tenant_id - policy.enforce(pecan.request.context, 'report:get_total', - {"tenant_id": tenant_id}) + policy.authorize(pecan.request.context, 'report:get_total', + {"tenant_id": tenant_id}) storage = pecan.request.storage_backend # FIXME(sheeprine): We should filter on user id. @@ -114,8 +114,8 @@ class ReportController(rest.RestController): else: tenant_context = pecan.request.context.tenant tenant_id = tenant_context if not tenant_id else tenant_id - policy.enforce(pecan.request.context, 'report:get_summary', - {"tenant_id": tenant_id}) + policy.authorize(pecan.request.context, 'report:get_summary', + {"tenant_id": tenant_id}) storage = pecan.request.storage_backend summarymodels = [] diff --git a/cloudkitty/api/v1/controllers/storage.py b/cloudkitty/api/v1/controllers/storage.py index 4d6e01d8..4e857c6d 100644 --- a/cloudkitty/api/v1/controllers/storage.py +++ b/cloudkitty/api/v1/controllers/storage.py @@ -48,7 +48,7 @@ class DataFramesController(rest.RestController): :return: Collection of DataFrame objects. """ - policy.enforce(pecan.request.context, 'storage:list_data_frames', {}) + policy.authorize(pecan.request.context, 'storage:list_data_frames', {}) if not begin: begin = ck_utils.get_month_start() diff --git a/cloudkitty/common/policies/__init__.py b/cloudkitty/common/policies/__init__.py new file mode 100644 index 00000000..ddf47f39 --- /dev/null +++ b/cloudkitty/common/policies/__init__.py @@ -0,0 +1,34 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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. + +import itertools + +from cloudkitty.common.policies import base +from cloudkitty.common.policies import collector +from cloudkitty.common.policies import info +from cloudkitty.common.policies import rating +from cloudkitty.common.policies import report +from cloudkitty.common.policies import storage + + +def list_rules(): + return itertools.chain( + base.list_rules(), + collector.list_rules(), + info.list_rules(), + rating.list_rules(), + report.list_rules(), + storage.list_rules() + ) diff --git a/cloudkitty/common/policies/base.py b/cloudkitty/common/policies/base.py new file mode 100644 index 00000000..66c0d38e --- /dev/null +++ b/cloudkitty/common/policies/base.py @@ -0,0 +1,36 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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_policy import policy + +RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' +ROLE_ADMIN = 'role:admin' +UNPROTECTED = '' + +rules = [ + policy.RuleDefault( + name='context_is_admin', + check_str='role:admin'), + policy.RuleDefault( + name='admin_or_owner', + check_str='is_admin:True or tenant:%(tenant_id)s'), + policy.RuleDefault( + name='default', + check_str=UNPROTECTED) +] + + +def list_rules(): + return rules diff --git a/cloudkitty/common/policies/collector.py b/cloudkitty/common/policies/collector.py new file mode 100644 index 00000000..afedc6c1 --- /dev/null +++ b/cloudkitty/common/policies/collector.py @@ -0,0 +1,57 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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_policy import policy + +from cloudkitty.common.policies import base + +collector_policies = [ + policy.DocumentedRuleDefault( + name='collector:list_mappings', + check_str=base.ROLE_ADMIN, + description='Return the list of every services mapped to a collector.', + operations=[{'path': '/v1/collector/mappings', + 'method': 'LIST'}]), + policy.DocumentedRuleDefault( + name='collector:get_mapping', + check_str=base.ROLE_ADMIN, + description='Return a service to collector mapping.', + operations=[{'path': '/v1/collector/mappings/{service_id}', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + name='collector:manage_mapping', + check_str=base.ROLE_ADMIN, + description='Manage a service to collector mapping.', + operations=[{'path': '/v1/collector/mappings', + 'method': 'POST'}, + {'path': '/v1/collector/mappings/{service_id}', + 'method': 'DELETE'}]), + policy.DocumentedRuleDefault( + name='collector:get_state', + check_str=base.ROLE_ADMIN, + description='Query the enable state of a collector.', + operations=[{'path': '/v1/collector/states/{collector_id}', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + name='collector:update_state', + check_str=base.ROLE_ADMIN, + description='Set the enable state of a collector.', + operations=[{'path': '/v1/collector/states/{collector_id}', + 'method': 'PUT'}]) +] + + +def list_rules(): + return collector_policies diff --git a/cloudkitty/common/policies/info.py b/cloudkitty/common/policies/info.py new file mode 100644 index 00000000..b7cf0530 --- /dev/null +++ b/cloudkitty/common/policies/info.py @@ -0,0 +1,43 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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_policy import policy + +from cloudkitty.common.policies import base + +info_policies = [ + policy.DocumentedRuleDefault( + name='info:list_services_info', + check_str=base.UNPROTECTED, + description='List available services information in Cloudkitty.', + operations=[{'path': '/v1/info/services', + 'method': 'LIST'}]), + policy.DocumentedRuleDefault( + name='info:get_service_info', + check_str=base.UNPROTECTED, + description='Get specified service information.', + operations=[{'path': '/v1/info/services/{service_id}', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + name='info:get_config', + check_str=base.UNPROTECTED, + description='Get current configuration in Cloudkitty.', + operations=[{'path': '/v1/info/config', + 'method': 'GET'}]) +] + + +def list_rules(): + return info_policies diff --git a/cloudkitty/common/policies/rating.py b/cloudkitty/common/policies/rating.py new file mode 100644 index 00000000..3132327a --- /dev/null +++ b/cloudkitty/common/policies/rating.py @@ -0,0 +1,56 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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_policy import policy + +from cloudkitty.common.policies import base + +rating_policies = [ + policy.DocumentedRuleDefault( + name='rating:list_modules', + check_str=base.ROLE_ADMIN, + description='Reture the list of loaded modules in Cloudkitty.', + operations=[{'path': '/v1/rating/modules', + 'method': 'LIST'}]), + policy.DocumentedRuleDefault( + name='rating:get_module', + check_str=base.ROLE_ADMIN, + description='Get specified module.', + operations=[{'path': '/v1/rating/modules/{module_id}', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + name='rating:update_module', + check_str=base.ROLE_ADMIN, + description='Change the state and priority of a module.', + operations=[{'path': '/v1/rating/modules/{module_id}', + 'method': 'PUT'}]), + policy.DocumentedRuleDefault( + name='rating:quote', + check_str=base.UNPROTECTED, + description='Get an instant quote based on multiple resource ' + 'descriptions.', + operations=[{'path': '/v1/rating/quote', + 'method': 'POST'}]), + policy.DocumentedRuleDefault( + name='rating:module_config', + check_str=base.ROLE_ADMIN, + description='Trigger a rating module list reload.', + operations=[{'path': '/v1/rating/reload_modules', + 'method': 'GET'}]) +] + + +def list_rules(): + return rating_policies diff --git a/cloudkitty/common/policies/report.py b/cloudkitty/common/policies/report.py new file mode 100644 index 00000000..ae06dd90 --- /dev/null +++ b/cloudkitty/common/policies/report.py @@ -0,0 +1,43 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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_policy import policy + +from cloudkitty.common.policies import base + +report_policies = [ + policy.DocumentedRuleDefault( + name='report:list_tenants', + check_str=base.ROLE_ADMIN, + description='Return the list of rated tenants.', + operations=[{'path': '/v1/report/tenants', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + name='report:get_summary', + check_str=base.RULE_ADMIN_OR_OWNER, + description='Return the summary to pay for a given period.', + operations=[{'path': '/v1/report/summary', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + name='report:get_total', + check_str=base.RULE_ADMIN_OR_OWNER, + description='Return the amount to pay for a given period.', + operations=[{'path': '/v1/report/total', + 'method': 'GET'}]) +] + + +def list_rules(): + return report_policies diff --git a/cloudkitty/common/policies/storage.py b/cloudkitty/common/policies/storage.py new file mode 100644 index 00000000..67710e01 --- /dev/null +++ b/cloudkitty/common/policies/storage.py @@ -0,0 +1,32 @@ +# Copyright 2017 GohighSec. +# All Rights Reserved. +# +# 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_policy import policy + +from cloudkitty.common.policies import base + +storage_policies = [ + policy.DocumentedRuleDefault( + name='storage:list_data_frames', + check_str=base.UNPROTECTED, + description='Return a list of rated resources for a time period ' + 'and a tenant.', + operations=[{'path': '/v1/storage/dataframes', + 'method': 'GET'}]) +] + + +def list_rules(): + return storage_policies diff --git a/cloudkitty/common/policy.py b/cloudkitty/common/policy.py index 217d30a4..44259183 100644 --- a/cloudkitty/common/policy.py +++ b/cloudkitty/common/policy.py @@ -15,13 +15,28 @@ # Borrowed from cinder (cinder/policy.py) +import copy +import sys + from oslo_config import cfg +from oslo_log import log as logging +from oslo_policy import opts as policy_opts from oslo_policy import policy +from oslo_utils import excutils import six +from cloudkitty.common import policies + +LOG = logging.getLogger(__name__) CONF = cfg.CONF +policy_opts.set_defaults(cfg.CONF, 'policy.json') _ENFORCER = None +# oslo_policy will read the policy configuration file again when the file +# is changed in runtime so the old policy rules will be saved to +# saved_file_rules and used to compare with new rules to determine the +# rules whether were updated. +saved_file_rules = [] # TODO(gpocentek): provide a proper parent class to handle such exceptions @@ -37,13 +52,37 @@ class PolicyNotAuthorized(Exception): return six.text_type(self.msg) +def reset(): + global _ENFORCER + if _ENFORCER: + _ENFORCER.clear() + _ENFORCER = None + + def init(): global _ENFORCER + global saved_file_rules if not _ENFORCER: _ENFORCER = policy.Enforcer(CONF) + register_rules(_ENFORCER) + + # Only the rules which are loaded from file may be changed. + current_file_rules = _ENFORCER.file_rules + current_file_rules = _serialize_rules(current_file_rules) + + # Checks whether the rules are updated in the runtime + if saved_file_rules != current_file_rules: + saved_file_rules = copy.deepcopy(current_file_rules) -def enforce(context, action, target): +def _serialize_rules(rules): + """Serialize all the Rule object as string.""" + result = [(rule_name, str(rule)) + for rule_name, rule in rules.items()] + return sorted(result, key=lambda rule: rule[0]) + + +def authorize(context, action, target): """Verifies that the action is valid on the target in this context. :param context: cloudkitty context @@ -65,10 +104,20 @@ def enforce(context, action, target): init() - return _ENFORCER.enforce(action, target, context.to_dict(), - do_raise=True, - exc=PolicyNotAuthorized, - action=action) + try: + return _ENFORCER.authorize(action, target, context.to_dict(), + do_raise=True, + exc=PolicyNotAuthorized, + action=action) + + except policy.PolicyNotRegistered: + with excutils.save_and_reraise_exception(): + LOG.exception('Policy not registered') + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error('Policy check for %(action)s failed with credentials ' + '%(credentials)s', + {'action': action, 'credentials': context.to_dict()}) def check_is_admin(roles): @@ -87,4 +136,28 @@ def check_is_admin(roles): target = {'project_id': ''} credentials = {'roles': roles} - return _ENFORCER.enforce('context_is_admin', target, credentials) + return _ENFORCER.authorize('context_is_admin', target, credentials) + + +def register_rules(enforcer): + enforcer.register_defaults(policies.list_rules()) + + +def get_enforcer(): + # This method is for use by oslopolicy CLI scripts. Those scripts need the + # 'output-file' and 'namespace' options, but having those in sys.argv means + # loading the Cloudkitty config options will fail as those are not expected + # to be present. So we pass in an arg list with those stripped out. + conf_args = [] + # Start at 1 because cfg.CONF expects the equivalent of sys.argv[1:] + i = 1 + while i < len(sys.argv): + if sys.argv[i].strip('-') in ['namespace', 'output-file']: + i += 2 + continue + conf_args.append(sys.argv[i]) + i += 1 + + cfg.CONF(conf_args, project='cloudkitty') + init() + return _ENFORCER diff --git a/cloudkitty/rating/__init__.py b/cloudkitty/rating/__init__.py index 590b1658..2b030878 100644 --- a/cloudkitty/rating/__init__.py +++ b/cloudkitty/rating/__init__.py @@ -142,7 +142,7 @@ class RatingRestControllerBase(rest.RestController): @pecan.expose() def _route(self, args, request): try: - policy.enforce(request.context, 'rating:module_config', {}) + policy.authorize(request.context, 'rating:module_config', {}) except policy.PolicyNotAuthorized as e: pecan.abort(403, six.text_type(e)) diff --git a/cloudkitty/tests/test_policy.py b/cloudkitty/tests/test_policy.py new file mode 100644 index 00000000..550b4ba2 --- /dev/null +++ b/cloudkitty/tests/test_policy.py @@ -0,0 +1,122 @@ +# Copyright (c) 2017 GohighSec. +# All Rights Reserved. +# +# 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. +import os.path + +from oslo_config import cfg +from oslo_config import fixture as config_fixture +from oslo_context import context +from oslo_policy import policy as oslo_policy + +from cloudkitty.common import policy +from cloudkitty import tests +from cloudkitty import utils + +CONF = cfg.CONF + + +class PolicyFileTestCase(tests.TestCase): + + def setUp(self): + super(PolicyFileTestCase, self).setUp() + self.context = context.RequestContext('fake', 'fake', roles=['member']) + self.target = {} + self.fixture = self.useFixture(config_fixture.Config(CONF)) + self.addCleanup(policy.reset) + CONF(args=[], project='cloudkitty', default_config_files=[]) + + def test_modified_policy_reloads(self): + with utils.tempdir() as tmpdir: + tmpfilename = os.path.join(tmpdir, 'policy') + self.fixture.config(policy_file=tmpfilename, group='oslo_policy') + rule = oslo_policy.RuleDefault('example:test', "") + policy.reset() + policy.init() + policy._ENFORCER.register_defaults([rule]) + + action = "example:test" + with open(tmpfilename, "w") as policyfile: + policyfile.write('{"example:test": ""}') + policy.authorize(self.context, action, self.target) + with open(tmpfilename, "w") as policyfile: + policyfile.write('{"example:test": "!"}') + policy._ENFORCER.load_rules(True) + self.assertRaises(policy.PolicyNotAuthorized, + policy.authorize, + self.context, action, self.target) + + +class PolicyTestCase(tests.TestCase): + + def setUp(self): + super(PolicyTestCase, self).setUp() + rules = [ + oslo_policy.RuleDefault("true", '@'), + oslo_policy.RuleDefault("test:allowed", '@'), + oslo_policy.RuleDefault("test:denied", "!"), + oslo_policy.RuleDefault("test:early_and_fail", "! and @"), + oslo_policy.RuleDefault("test:early_or_success", "@ or !"), + oslo_policy.RuleDefault("test:lowercase_admin", + "role:admin"), + oslo_policy.RuleDefault("test:uppercase_admin", + "role:ADMIN"), + ] + CONF(args=[], project='cloudkitty', default_config_files=[]) + # before a policy rule can be used, its default has to be registered. + policy.reset() + policy.init() + policy._ENFORCER.register_defaults(rules) + self.context = context.RequestContext('fake', + 'fake', + roles=['member']) + self.target = {} + self.addCleanup(policy.reset) + + def test_enforce_nonexistent_action_throws(self): + action = "test:noexist" + self.assertRaises(oslo_policy.PolicyNotRegistered, policy.authorize, + self.context, action, self.target) + + def test_enforce_bad_action_throws(self): + action = "test:denied" + self.assertRaises(policy.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_enforce_bad_action_noraise(self): + action = "test:denied" + self.assertRaises(policy.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_enforce_good_action(self): + action = "test:allowed" + result = policy.authorize(self.context, action, self.target) + self.assertTrue(result) + + def test_early_AND_authorization(self): + action = "test:early_and_fail" + self.assertRaises(policy.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_early_OR_authorization(self): + action = "test:early_or_success" + policy.authorize(self.context, action, self.target) + + def test_ignore_case_role_check(self): + lowercase_action = "test:lowercase_admin" + uppercase_action = "test:uppercase_admin" + admin_context = context.RequestContext('admin', + 'fake', + roles=['AdMiN']) + policy.authorize(admin_context, lowercase_action, self.target) + policy.authorize(admin_context, uppercase_action, self.target) diff --git a/cloudkitty/utils.py b/cloudkitty/utils.py index 163ddee9..0c536de3 100644 --- a/cloudkitty/utils.py +++ b/cloudkitty/utils.py @@ -22,12 +22,17 @@ We're mostly using oslo_utils for time calculations but we're encapsulating it to ease maintenance in case of library modifications. """ import calendar +import contextlib import datetime +import shutil +import six import sys +import tempfile import yaml from oslo_config import cfg from oslo_log import log as logging + from oslo_utils import timeutils from six import moves from stevedore import extension @@ -257,3 +262,16 @@ def get_metrics_conf(conf_path): LOG.error(exc) return res + + +@contextlib.contextmanager +def tempdir(**kwargs): + tmpdir = tempfile.mkdtemp(**kwargs) + try: + yield tmpdir + finally: + try: + shutil.rmtree(tmpdir) + except OSError as e: + LOG.debug('Could not remove tmpdir: %s', + six.text_type(e)) diff --git a/devstack/plugin.sh b/devstack/plugin.sh index e242711f..a6f10bad 100755 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -132,7 +132,11 @@ function configure_cloudkitty { touch $CLOUDKITTY_CONF - cp $CLOUDKITTY_DIR$CLOUDKITTY_CONF_DIR/policy.json $CLOUDKITTY_CONF_DIR + # generate policy sample file + oslopolicy-sample-generator --config-file $CLOUDKITTY_DIR/etc/oslo-policy-generator/cloudkitty.conf --output-file $CLOUDKITTY_DIR/etc/cloudkitty/policy.yaml.sample + cp $CLOUDKITTY_DIR/etc/cloudkitty/policy.yaml.sample "$CLOUDKITTY_CONF_DIR/policy.yaml" + iniset $CLOUDKITTY_CONF oslo_policy policy_file 'policy.yaml' + cp $CLOUDKITTY_DIR$CLOUDKITTY_CONF_DIR/api_paste.ini $CLOUDKITTY_CONF_DIR cp $CLOUDKITTY_DIR$CLOUDKITTY_CONF_DIR/metrics.yml $CLOUDKITTY_CONF_DIR iniset_rpc_backend cloudkitty $CLOUDKITTY_CONF DEFAULT diff --git a/doc/source/conf.py b/doc/source/conf.py index ad4f29cd..96c79b63 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -57,6 +57,8 @@ extensions = ['sphinx.ext.coverage', 'sphinxcontrib.pecanwsme.rest', 'sphinxcontrib.httpdomain', 'openstackdocstheme', + 'oslo_policy.sphinxext', + 'oslo_policy.sphinxpolicygen', ] # openstackdocstheme options @@ -65,6 +67,9 @@ bug_project = 'cloudkitty' bug_tag = '' html_last_updated_fmt = '%Y-%m-%d %H:%M' +policy_generator_config_file = '../../etc/oslo-policy-generator/cloudkitty.conf' +sample_policy_basename = '_static/cloudkitty' + # Add any paths that contain templates here, relative to this directory. # templates_path = [] diff --git a/doc/source/configuration/policy.rst b/doc/source/configuration/policy.rst new file mode 100644 index 00000000..4bf89c20 --- /dev/null +++ b/doc/source/configuration/policy.rst @@ -0,0 +1,12 @@ +==================== +Policy configuration +==================== + +Configuration +~~~~~~~~~~~~~ + +The following is an overview of all available policies in Cloudkitty. For a sample +configuration file, refer to :doc:`samples/policy-yaml`. + +.. show-policy:: + :config-file: ../../etc/oslo-policy-generator/cloudkitty.conf diff --git a/doc/source/configuration/samples/index.rst b/doc/source/configuration/samples/index.rst new file mode 100644 index 00000000..f80f972a --- /dev/null +++ b/doc/source/configuration/samples/index.rst @@ -0,0 +1,11 @@ +========================== +Sample configuration files +========================== + +Configuration files can alter how cloudkitty behaves at runtime and by default +are located in ``/etc/cloudkitty/``. Links to sample configuration files can be +found below: + +.. toctree:: + + policy-yaml.rst diff --git a/doc/source/configuration/samples/policy-yaml.rst b/doc/source/configuration/samples/policy-yaml.rst new file mode 100644 index 00000000..266f1a20 --- /dev/null +++ b/doc/source/configuration/samples/policy-yaml.rst @@ -0,0 +1,8 @@ +=========== +policy.yaml +=========== + +Use the ``policy.yaml`` file to define additional access controls that apply to +the Rating service: + +.. literalinclude:: ../../_static/policy.yaml.sample diff --git a/doc/source/sample_policy.rst b/doc/source/sample_policy.rst new file mode 100644 index 00000000..5ecfdf29 --- /dev/null +++ b/doc/source/sample_policy.rst @@ -0,0 +1,15 @@ +======================== +Cloudkitty Sample Policy +======================== + +The following is a sample Cloudkitty policy file that has been auto-generated +from default policy values in code. If you're using the default policies, then +the maintenance of this file is not necessary, and it should not be copied into +a deployment. Doing so will result in duplicate policy definitions. It is here +to help explain which policy operations protect specific Cloudkitty APIs, but it +is not suggested to copy and paste into a deployment unless you're planning on +providing a different policy for an operation that is not the default. + +The sample policy file can also be viewed in `file form <_static/policy.yaml.sample>`_. + +.. literalinclude:: _static/policy.yaml.sample diff --git a/etc/cloudkitty/policy.json b/etc/cloudkitty/policy.json deleted file mode 100644 index f2de2626..00000000 --- a/etc/cloudkitty/policy.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "context_is_admin": "role:admin", - "admin_or_owner": "is_admin:True or tenant:%(tenant_id)s", - "default": "", - - "info:list_services_info": "", - "info:get_service_info": "", - "info:get_config":"", - - "rating:list_modules": "role:admin", - "rating:get_module": "role:admin", - "rating:update_module": "role:admin", - "rating:quote": "", - - "report:list_tenants": "role:admin", - "report:get_summary": "rule:admin_or_owner", - "report:get_total": "rule:admin_or_owner", - - "collector:list_mappings": "role:admin", - "collector:get_mapping": "role:admin", - "collector:manage_mapping": "role:admin", - "collector:get_state": "role:admin", - "collector:update_state": "role:admin", - - "storage:list_data_frames": "", - - "rating:module_config": "role:admin" -} diff --git a/etc/oslo-policy-generator/cloudkitty.conf b/etc/oslo-policy-generator/cloudkitty.conf new file mode 100644 index 00000000..a98cd917 --- /dev/null +++ b/etc/oslo-policy-generator/cloudkitty.conf @@ -0,0 +1,3 @@ +[DEFAULT] +output_file = etc/cloudkitty/policy.yaml.sample +namespace = cloudkitty diff --git a/setup.cfg b/setup.cfg index 355a617c..66ae6e57 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,12 @@ console_scripts = wsgi_scripts = cloudkitty-api = cloudkitty.api.app:build_wsgi_app +oslo.policy.enforcer = + cloudkitty = cloudkitty.common.policy:get_enforcer + +oslo.policy.policies = + cloudkitty = cloudkitty.common.policies:list_rules + oslo.config.opts = cloudkitty.common.config = cloudkitty.common.config:list_opts diff --git a/tox.ini b/tox.ini index 044cbe5e..7ffee064 100644 --- a/tox.ini +++ b/tox.ini @@ -34,6 +34,9 @@ commands = commands = oslo-config-generator --config-file etc/oslo-config-generator/cloudkitty.conf +[testenv:genpolicy] +commands = oslopolicy-sample-generator --config-file=etc/oslo-policy-generator/cloudkitty.conf + [testenv:docs] commands = python setup.py build_sphinx