From 3ca4b057dd115b8b05a29b641c147822996e1efd Mon Sep 17 00:00:00 2001 From: Amir Mofakhar Date: Thu, 22 Feb 2018 12:42:37 +0100 Subject: [PATCH] Using oslo.policy for monasca-api Added policies and used policy enforcement engine from monasca-common. - Replaced security with oslo.policy - Updated unit tests and implemented some new tests - Added a new entry point for generating sample policy file by tox story: 2001233 task: 6355 Change-Id: I4aa444fe6ec883160c03c201145c77994b6615f9 Signed-off-by: Amir Mofakhar --- config-generator/README.rst | 4 + config-generator/api-config.conf | 1 + config-generator/policy.conf | 4 + monasca_api/api/core/request.py | 13 +- monasca_api/api/core/request_context.py | 36 +++ monasca_api/conf/security.py | 4 + monasca_api/config.py | 3 + monasca_api/healthchecks.py | 2 + monasca_api/policies/__init__.py | 78 ++++++ monasca_api/policies/alarms.py | 158 +++++++++++ monasca_api/policies/delegate.py | 31 +++ monasca_api/policies/healthcheck.py | 32 +++ monasca_api/policies/metrics.py | 62 +++++ monasca_api/policies/notifications.py | 96 +++++++ monasca_api/policies/versions.py | 33 +++ monasca_api/tests/base.py | 44 +++ monasca_api/tests/test_alarms.py | 1 - monasca_api/tests/test_helpers.py | 98 +++++++ monasca_api/tests/test_policy.py | 254 ++++++++++++++++++ monasca_api/tests/test_request.py | 3 + monasca_api/tests/test_validation.py | 52 +--- monasca_api/v2/reference/alarm_definitions.py | 26 +- monasca_api/v2/reference/alarms.py | 37 +-- monasca_api/v2/reference/helpers.py | 57 ++-- monasca_api/v2/reference/metrics.py | 80 ++---- monasca_api/v2/reference/notifications.py | 28 +- monasca_api/v2/reference/notificationstype.py | 3 +- monasca_api/v2/reference/version_2_0.py | 3 + monasca_api/v2/reference/versions.py | 3 + .../notes/oslo-policy-aebaebd218b9d2ff.yaml | 5 + requirements.txt | 1 + setup.cfg | 3 + tox.ini | 4 + 33 files changed, 1058 insertions(+), 201 deletions(-) create mode 100644 config-generator/policy.conf create mode 100644 monasca_api/api/core/request_context.py create mode 100644 monasca_api/policies/__init__.py create mode 100644 monasca_api/policies/alarms.py create mode 100644 monasca_api/policies/delegate.py create mode 100644 monasca_api/policies/healthcheck.py create mode 100644 monasca_api/policies/metrics.py create mode 100644 monasca_api/policies/notifications.py create mode 100644 monasca_api/policies/versions.py create mode 100644 monasca_api/tests/test_helpers.py create mode 100644 monasca_api/tests/test_policy.py create mode 100644 releasenotes/notes/oslo-policy-aebaebd218b9d2ff.yaml diff --git a/config-generator/README.rst b/config-generator/README.rst index ac3ebc9c2..bfc9d49a9 100644 --- a/config-generator/README.rst +++ b/config-generator/README.rst @@ -5,3 +5,7 @@ config-generator To generate sample configuration file execute:: tox -e genconfig + +To generate the sample policies execute:: + + tox -e genpolicy \ No newline at end of file diff --git a/config-generator/api-config.conf b/config-generator/api-config.conf index 8c1fbaddb..52228f4a6 100644 --- a/config-generator/api-config.conf +++ b/config-generator/api-config.conf @@ -6,3 +6,4 @@ summarize = True namespace = monasca_api namespace = oslo.log namespace = oslo.db +namespace = oslo.policy diff --git a/config-generator/policy.conf b/config-generator/policy.conf new file mode 100644 index 000000000..b20c7c75f --- /dev/null +++ b/config-generator/policy.conf @@ -0,0 +1,4 @@ +[DEFAULT] +output_file = etc/api-policy.yaml.sample +format = yaml +namespace = monasca_api \ No newline at end of file diff --git a/monasca_api/api/core/request.py b/monasca_api/api/core/request.py index b8ff35746..94a569e32 100644 --- a/monasca_api/api/core/request.py +++ b/monasca_api/api/core/request.py @@ -1,4 +1,5 @@ # Copyright 2016 FUJITSU LIMITED +# Copyright 2018 OP5 AB # # 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 @@ -14,11 +15,16 @@ import falcon -from oslo_context import context +from monasca_common.policy import policy_engine as policy +from monasca_api.api.core import request_context from monasca_api.common.repositories import constants +from monasca_api import policies from monasca_api.v2.common import exceptions + +policy.POLICIES = policies + _TENANT_ID_PARAM = 'tenant_id' """Name of the query-param pointing at project-id (tenant-id)""" @@ -33,7 +39,7 @@ class Request(falcon.Request): def __init__(self, env, options=None): super(Request, self).__init__(env, options) - self.context = context.RequestContext.from_environ(self.env) + self.context = request_context.RequestContext.from_environ(self.env) @property def project_id(self): @@ -105,5 +111,8 @@ class Request(falcon.Request): else: return constants.PAGE_LIMIT + def can(self, action, target=None): + return self.context.can(action, target) + def __repr__(self): return '%s, context=%s' % (self.path, self.context) diff --git a/monasca_api/api/core/request_context.py b/monasca_api/api/core/request_context.py new file mode 100644 index 000000000..901efae4f --- /dev/null +++ b/monasca_api/api/core/request_context.py @@ -0,0 +1,36 @@ +# Copyright 2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB +# +# 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 monasca_common.policy import policy_engine as policy +from oslo_context import context + +from monasca_api import policies + +policy.POLICIES = policies + + +class RequestContext(context.RequestContext): + """RequestContext. + + RequestContext is customized version of + :py:class:oslo_context.context.RequestContext. + """ + + def can(self, action, target=None): + if target is None: + target = {'project_id': self.project_id, + 'user_id': self.user_id} + + return policy.authorize(self, action=action, target=target) diff --git a/monasca_api/conf/security.py b/monasca_api/conf/security.py index b80e80009..d5b150f4b 100644 --- a/monasca_api/conf/security.py +++ b/monasca_api/conf/security.py @@ -17,6 +17,10 @@ from oslo_config import cfg security_opts = [ + cfg.ListOpt('healthcheck_roles', default=['@'], + help='Roles that are allowed to check the health'), + cfg.ListOpt('versions_roles', default=['@'], + help='Roles that are allowed to check the versions'), cfg.ListOpt('default_authorized_roles', default=['monasca-user'], help=''' Roles that are allowed full access to the API diff --git a/monasca_api/config.py b/monasca_api/config.py index 3870164dd..f3833c3af 100644 --- a/monasca_api/config.py +++ b/monasca_api/config.py @@ -1,4 +1,5 @@ # Copyright 2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB # # 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 @@ -16,6 +17,7 @@ import sys from oslo_config import cfg from oslo_log import log +from oslo_policy import opts as policy_opts from monasca_api import conf from monasca_api import version @@ -57,6 +59,7 @@ def parse_args(argv=None, config_file=None): product_name='monasca-api', version=version.version_str) conf.register_opts() + policy_opts.set_defaults(CONF) _CONF_LOADED = True diff --git a/monasca_api/healthchecks.py b/monasca_api/healthchecks.py index 9ef6a6c60..689adbbc3 100644 --- a/monasca_api/healthchecks.py +++ b/monasca_api/healthchecks.py @@ -1,4 +1,5 @@ # Copyright 2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB # # 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 @@ -39,6 +40,7 @@ class HealthChecks(healthcheck_api.HealthCheckApi): res.cache_control = self.CACHE_CONTROL def on_get(self, req, res): + helpers.validate_authorization(req, ['api:healthcheck']) kafka_result = self._kafka_check.health_check() alarms_db_result = self._alarm_db_check.health_check() metrics_db_result = self._metrics_db_check.health_check() diff --git a/monasca_api/policies/__init__.py b/monasca_api/policies/__init__.py new file mode 100644 index 000000000..efeec4c00 --- /dev/null +++ b/monasca_api/policies/__init__.py @@ -0,0 +1,78 @@ +# Copyright 2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB +# +# 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 +import pkgutil + + +from oslo_config import cfg +from oslo_log import log +from oslo_utils import importutils + +from monasca_api.conf import security + +LOG = log.getLogger(__name__) +_BASE_MOD_PATH = 'monasca_api.policies.' +CONF = cfg.CONF + + +def roles_list_to_check_str(roles_list): + converted_roles_list = ["role:" + role if role != '@' else role for role in roles_list] + return ' or '.join(converted_roles_list) + + +security.register_opts(CONF) + +HEALTHCHECK_ROLES = roles_list_to_check_str(cfg.CONF.security.healthcheck_roles) +VERSIONS_ROLES = roles_list_to_check_str(cfg.CONF.security.versions_roles) +DEFAULT_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.security.default_authorized_roles) +READ_ONLY_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.security.read_only_authorized_roles) +AGENT_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.security.agent_authorized_roles) +DELEGATE_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.security.delegate_authorized_roles) + + +def load_policy_modules(): + """Load all modules that contain policies. + + Method iterates over modules of :py:mod:`monasca_events_api.policies` + and imports only those that contain following methods: + + - list_rules + + """ + for modname in _list_module_names(): + mod = importutils.import_module(_BASE_MOD_PATH + modname) + if hasattr(mod, 'list_rules'): + yield mod + + +def _list_module_names(): + package_path = os.path.dirname(os.path.abspath(__file__)) + for _, modname, ispkg in pkgutil.iter_modules(path=[package_path]): + if not (modname == "opts" and ispkg): + yield modname + + +def list_rules(): + """List all policy modules rules. + + Goes through all policy modules and yields their rules + + """ + all_rules = [] + for mod in load_policy_modules(): + rules = mod.list_rules() + all_rules.extend(rules) + return all_rules diff --git a/monasca_api/policies/alarms.py b/monasca_api/policies/alarms.py new file mode 100644 index 000000000..7ce8ca073 --- /dev/null +++ b/monasca_api/policies/alarms.py @@ -0,0 +1,158 @@ +# Copyright 2018 OP5 AB +# +# 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 monasca_api.policies import DEFAULT_AUTHORIZED_ROLES +from monasca_api.policies import READ_ONLY_AUTHORIZED_ROLES + +rules = [ + policy.DocumentedRuleDefault( + name='api:alarms:definition:post', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Post alarm definition role', + operations=[ + { + 'path': '/v2.0/alarm-definitions/', + 'method': 'POST' + } + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:definition:get', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get alarm definition role', + operations=[ + { + 'path': '/v2.0/alarm-definitions/{alarm_definition_id}', + 'method': 'GET' + }, + { + 'path': '/v2.0/alarm-definitions', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:definition:put', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Put alarm definition role', + operations=[ + { + 'path': '/v2.0/alarm-definitions/{alarm_definition_id}', + 'method': 'PUT' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:definition:patch', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Patch alarm definition role', + operations=[ + { + 'path': '/v2.0/alarm-definitions/{alarm_definition_id}', + 'method': 'PATCH' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:definition:delete', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Delete alarm definition role', + operations=[ + { + 'path': '/v2.0/alarm-definitions/{alarm_definition_id}', + 'method': 'DELETE' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:put', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Put alarm role', + operations=[ + { + 'path': '/v2.0/alarms/{alarm_id}', + 'method': 'PUT' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:patch', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Patch alarm role', + operations=[ + { + 'path': '/v2.0/alarms/{alarm_id}', + 'method': 'PATCH' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:delete', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Delete alarm role', + operations=[ + { + 'path': '/v2.0/alarms/{alarm_id}', + 'method': 'DELETE' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:get', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get alarm role', + operations=[ + { + 'path': '/v2.0/alarms/', + 'method': 'GET' + }, + { + 'path': '/v2.0/alarms/{alarm_id}', + 'method': 'GET' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:count', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Count alarm role', + operations=[ + { + 'path': '/v2.0/alarms/count/', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name='api:alarms:state_history', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Alarm state history role', + operations=[ + { + 'path': '/v2.0/alarms/state-history', + 'method': 'GET' + }, + { + 'path': '/v2.0/alarms/{alarm_id}/state-history', + 'method': 'GET' + } + ] + ) +] + + +def list_rules(): + return rules diff --git a/monasca_api/policies/delegate.py b/monasca_api/policies/delegate.py new file mode 100644 index 000000000..d5429588b --- /dev/null +++ b/monasca_api/policies/delegate.py @@ -0,0 +1,31 @@ +# Copyright 2018 OP5 AB +# +# 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 monasca_api.policies import DELEGATE_AUTHORIZED_ROLES + +rules = [ + policy.RuleDefault( + name='api:delegate', + check_str=DELEGATE_AUTHORIZED_ROLES, + description='The rules that allowes to access the API on' + ' behalf of another tenant role', + + ) +] + + +def list_rules(): + return rules diff --git a/monasca_api/policies/healthcheck.py b/monasca_api/policies/healthcheck.py new file mode 100644 index 000000000..c1dd9a142 --- /dev/null +++ b/monasca_api/policies/healthcheck.py @@ -0,0 +1,32 @@ +# Copyright 2018 OP5 AB +# +# 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 monasca_api.policies import HEALTHCHECK_ROLES + +rules = [ + policy.DocumentedRuleDefault( + name='api:healthcheck', + check_str=HEALTHCHECK_ROLES, + description='Healthcheck role', + operations=[ + {'path': '/healthcheck', 'method': 'GET'} + ] + ), +] + + +def list_rules(): + return rules diff --git a/monasca_api/policies/metrics.py b/monasca_api/policies/metrics.py new file mode 100644 index 000000000..2fa7810e1 --- /dev/null +++ b/monasca_api/policies/metrics.py @@ -0,0 +1,62 @@ +# Copyright 2018 OP5 AB +# +# 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 monasca_api.policies import AGENT_AUTHORIZED_ROLES +from monasca_api.policies import DEFAULT_AUTHORIZED_ROLES +from monasca_api.policies import READ_ONLY_AUTHORIZED_ROLES + + +rules = [ + policy.DocumentedRuleDefault( + name='api:metrics:get', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get metrics role', + operations=[ + {'path': '/v2.0/metrics', 'method': 'GET'}, + {'path': '/v2.0/metrics/measurements', 'method': 'GET'}, + {'path': '/v2.0/metrics/statistics', 'method': 'GET'}, + {'path': '/v2.0/metrics/names', 'method': 'GET'} + ] + ), + policy.DocumentedRuleDefault( + name='api:metrics:post', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + AGENT_AUTHORIZED_ROLES, + description='Post metrics role', + operations=[ + {'path': '/v2.0/metrics', 'method': 'POST'} + ] + ), + policy.DocumentedRuleDefault( + name='api:metrics:dimension:values', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get metrics dimension values role', + operations=[ + {'path': '/v2.0/metrics/dimensions/names/values', 'method': 'GET'} + ] + ), + policy.DocumentedRuleDefault( + name='api:metrics:dimension:names', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get metrics dimension names role', + operations=[ + {'path': '/v2.0/metrics/dimensions/names', 'method': 'GET'} + ] + ), +] + + +def list_rules(): + return rules diff --git a/monasca_api/policies/notifications.py b/monasca_api/policies/notifications.py new file mode 100644 index 000000000..63347bcea --- /dev/null +++ b/monasca_api/policies/notifications.py @@ -0,0 +1,96 @@ +# Copyright 2018 OP5 AB +# +# 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 monasca_api.policies import DEFAULT_AUTHORIZED_ROLES +from monasca_api.policies import READ_ONLY_AUTHORIZED_ROLES + + +rules = [ + policy.DocumentedRuleDefault( + name='api:notifications:put', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Put notifications role', + operations=[ + { + 'path': '/v2.0/notification-methods/{notification_method_id}', + 'method': 'PUT' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:notifications:patch', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Patch notifications role', + operations=[ + { + 'path': '/v2.0/notification-methods/{notification_method_id}', + 'method': 'PATCH' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:notifications:delete', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Delete notifications role', + operations=[ + { + 'path': '/v2.0/notification-methods/{notification_method_id}', + 'method': 'DELETE' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:notifications:get', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get notifications role', + operations=[ + { + 'path': '/v2.0/notification-methods', + 'method': 'GET' + }, + { + 'path': '/v2.0/notification-methods/{notification_method_id}', + 'method': 'GET' + }, + ] + ), + policy.DocumentedRuleDefault( + name='api:notifications:post', + check_str=DEFAULT_AUTHORIZED_ROLES, + description='Post notifications role', + operations=[ + { + 'path': '/v2.0/notification-methods', + 'method': 'POST' + } + ] + ), + policy.DocumentedRuleDefault( + name='api:notifications:type', + check_str=DEFAULT_AUTHORIZED_ROLES + ' or ' + READ_ONLY_AUTHORIZED_ROLES, + description='Get notifications type role', + operations=[ + { + 'path': '/v2.0/notification-methods/types', + 'method': 'GET' + } + ] + ) +] + + +def list_rules(): + return rules diff --git a/monasca_api/policies/versions.py b/monasca_api/policies/versions.py new file mode 100644 index 000000000..c6c0cbc97 --- /dev/null +++ b/monasca_api/policies/versions.py @@ -0,0 +1,33 @@ +# Copyright 2018 OP5 AB +# +# 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 monasca_api.policies import VERSIONS_ROLES + +rules = [ + policy.DocumentedRuleDefault( + name='api:versions', + check_str=VERSIONS_ROLES, + description='Get versions role', + operations=[ + {'path': '/', 'method': 'GET'}, + {'path': '/v2.0', 'method': 'GET'} + ] + ), +] + + +def list_rules(): + return rules diff --git a/monasca_api/tests/base.py b/monasca_api/tests/base.py index 077b181ec..aa1342bd6 100644 --- a/monasca_api/tests/base.py +++ b/monasca_api/tests/base.py @@ -1,5 +1,6 @@ # Copyright 2015 kornicameister@gmail.com # Copyright 2015-2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB # # 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 @@ -12,17 +13,25 @@ # 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 import falcon from falcon import testing +import fixtures +from monasca_common.policy import policy_engine as policy from oslo_config import cfg from oslo_config import fixture as oo_cfg from oslo_context import fixture as oo_ctx +from oslo_serialization import jsonutils from oslotest import base as oslotest_base from monasca_api.api.core import request from monasca_api import conf from monasca_api import config +from monasca_api import policies + + +policy.POLICIES = policies class MockedAPI(falcon.API): @@ -70,6 +79,7 @@ class BaseTestCase(oslotest_base.BaseTestCase): super(BaseTestCase, self).setUp() self.useFixture(ConfigFixture()) self.useFixture(oo_ctx.ClearRequestContext()) + self.useFixture(PolicyFixture()) @staticmethod def conf_override(**kw): @@ -95,3 +105,37 @@ class BaseApiTestCase(BaseTestCase, testing.TestBase): *args, **kwargs ) + + +class PolicyFixture(fixtures.Fixture): + """Override the policy with a completely new policy file. + + This overrides the policy with a completely fake and synthetic + policy file. + + """ + + def setUp(self): + super(PolicyFixture, self).setUp() + self._prepare_policy() + policy.reset() + policy.init() + + def _prepare_policy(self): + policy_dir = self.useFixture(fixtures.TempDir()) + policy_file = os.path.join(policy_dir.path, 'policy.yaml') + # load the fake_policy data and add the missing default rules. + policy_rules = jsonutils.loads('{}') + self.add_missing_default_rules(policy_rules) + with open(policy_file, 'w') as f: + jsonutils.dump(policy_rules, f) + + BaseTestCase.conf_override(policy_file=policy_file, + group='oslo_policy') + BaseTestCase.conf_override(policy_dirs=[], group='oslo_policy') + + @staticmethod + def add_missing_default_rules(rules): + for rule in policies.list_rules(): + if rule.name not in rules: + rules[rule.name] = rule.check_str diff --git a/monasca_api/tests/test_alarms.py b/monasca_api/tests/test_alarms.py index 698a5c8ba..e58fb2420 100644 --- a/monasca_api/tests/test_alarms.py +++ b/monasca_api/tests/test_alarms.py @@ -192,7 +192,6 @@ class TestAlarmsStateHistory(AlarmTestBase): 'X-Roles': CONF.security.default_authorized_roles[0], 'X-Tenant-Id': TENANT_ID, }) - self.assertEqual(self.srmock.status, falcon.HTTP_200) self.assertThat(response, RESTResponseEquals(expected_elements)) diff --git a/monasca_api/tests/test_helpers.py b/monasca_api/tests/test_helpers.py new file mode 100644 index 000000000..0973ce649 --- /dev/null +++ b/monasca_api/tests/test_helpers.py @@ -0,0 +1,98 @@ +# Copyright 2018 OP5 AB +# +# 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 falcon import testing + +from monasca_common.policy import policy_engine as policy +from oslo_policy import policy as os_policy + +from monasca_api.api.core import request +from monasca_api.tests import base +import monasca_api.v2.reference.helpers as helpers + + +class TestGetXTenantOrTenantId(base.BaseApiTestCase): + def setUp(self): + super(TestGetXTenantOrTenantId, self).setUp() + rules = [ + os_policy.RuleDefault("example:allowed", "@"), + os_policy.RuleDefault("example:denied", "!"), + os_policy.RuleDefault("example:authorized", + "role:role_1 or role:role_2") + ] + policy.reset() + policy.init() + policy._ENFORCER.register_defaults(rules) + + def test_return_tenant_id_on_authorized_roles(self): + + for role in ['role_1', 'role_2']: + req_context = self._get_request_context(role) + self.assertEqual( + 'fake_tenant_id', + helpers.get_x_tenant_or_tenant_id( + req_context, ['example:authorized'] + ) + ) + + def test_return_tenant_id_on_allowed_rules(self): + req_context = self._get_request_context() + self.assertEqual( + 'fake_tenant_id', + helpers.get_x_tenant_or_tenant_id( + req_context, + ['example:allowed'] + ) + ) + + def test_return_project_id_on_unauthorized_role(self): + req_context = self._get_request_context() + self.assertEqual('fake_project_id', + helpers.get_x_tenant_or_tenant_id( + req_context, + ['example:authorized'])) + + def test_return_project_id_on_denied_rules(self): + req_context = self._get_request_context() + self.assertEqual( + 'fake_project_id', + helpers.get_x_tenant_or_tenant_id( + req_context, + ['example:denied'] + ) + ) + + def test_return_project_id_on_unavailable_tenant_id(self): + req_context = self._get_request_context() + req_context.query_string = '' + self.assertEqual( + 'fake_project_id', + helpers.get_x_tenant_or_tenant_id( + req_context, + ['example:allowed'] + ) + ) + + @staticmethod + def _get_request_context(role='fake_role'): + return request.Request( + testing.create_environ( + path="/", + query_string="tenant_id=fake_tenant_id", + headers={ + "X_PROJECT_ID": "fake_project_id", + "X_ROLES": role + } + ) + ) diff --git a/monasca_api/tests/test_policy.py b/monasca_api/tests/test_policy.py new file mode 100644 index 000000000..6ffca43e4 --- /dev/null +++ b/monasca_api/tests/test_policy.py @@ -0,0 +1,254 @@ +# Copyright 2016-2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB +# +# 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 falcon import testing + +from monasca_common.policy import policy_engine as policy +from oslo_context import context +from oslo_policy import policy as os_policy + +from monasca_api.api.core import request +from monasca_api.policies import roles_list_to_check_str +from monasca_api.tests import base + + +class TestPolicyFileCase(base.BaseTestCase): + def setUp(self): + super(TestPolicyFileCase, self).setUp() + self.context = context.RequestContext(user='fake', + tenant='fake', + roles=['fake']) + self.target = {'tenant_id': 'fake'} + + def test_modified_policy_reloads(self): + tmp_file = \ + self.create_tempfiles(files=[('policies', '{}')], ext='.yaml')[0] + base.BaseTestCase.conf_override(policy_file=tmp_file, + group='oslo_policy') + + policy.reset() + policy.init() + action = 'example:test' + rule = os_policy.RuleDefault(action, '') + policy._ENFORCER.register_defaults([rule]) + + with open(tmp_file, 'w') as policy_file: + policy_file.write('{"example:test": ""}') + policy.authorize(self.context, action, self.target) + + with open(tmp_file, 'w') as policy_file: + policy_file.write('{"example:test": "!"}') + policy._ENFORCER.load_rules(True) + self.assertRaises(os_policy.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + +class TestPolicyCase(base.BaseTestCase): + def setUp(self): + super(TestPolicyCase, self).setUp() + rules = [ + os_policy.RuleDefault("true", "@"), + os_policy.RuleDefault("example:allowed", "@"), + os_policy.RuleDefault("example:denied", "!"), + os_policy.RuleDefault("example:lowercase_monasca_user", + "role:monasca_user or role:sysadmin"), + os_policy.RuleDefault("example:uppercase_monasca_user", + "role:MONASCA_USER or role:sysadmin"), + ] + policy.reset() + policy.init() + policy._ENFORCER.register_defaults(rules) + + def test_authorize_nonexist_action_throws(self): + action = "example:noexist" + ctx = request.Request( + testing.create_environ( + path="/", + headers={ + "X_USER_ID": "fake", + "X_PROJECT_ID": "fake", + "X_ROLES": "member" + } + ) + ) + self.assertRaises(os_policy.PolicyNotRegistered, policy.authorize, + ctx.context, action, {}) + + def test_authorize_bad_action_throws(self): + action = "example:denied" + ctx = request.Request( + testing.create_environ( + path="/", + headers={ + "X_USER_ID": "fake", + "X_PROJECT_ID": "fake", + "X_ROLES": "member" + } + ) + ) + self.assertRaises(os_policy.PolicyNotAuthorized, policy.authorize, + ctx.context, action, {}) + + def test_authorize_bad_action_no_exception(self): + action = "example:denied" + ctx = request.Request( + testing.create_environ( + path="/", + headers={ + "X_USER_ID": "fake", + "X_PROJECT_ID": "fake", + "X_ROLES": "member" + } + ) + ) + result = policy.authorize(ctx.context, action, {}, False) + self.assertFalse(result) + + def test_authorize_good_action(self): + action = "example:allowed" + ctx = request.Request( + testing.create_environ( + path="/", + headers={ + "X_USER_ID": "fake", + "X_PROJECT_ID": "fake", + "X_ROLES": "member" + } + ) + ) + result = policy.authorize(ctx.context, action, {}, False) + self.assertTrue(result) + + def test_ignore_case_role_check(self): + lowercase_action = "example:lowercase_monasca_user" + uppercase_action = "example:uppercase_monasca_user" + + monasca_user_context = request.Request( + testing.create_environ( + path="/", + headers={ + "X_USER_ID": "monasca_user", + "X_PROJECT_ID": "fake", + "X_ROLES": "MONASCA_user" + } + ) + ) + self.assertTrue(policy.authorize(monasca_user_context.context, + lowercase_action, + {})) + self.assertTrue(policy.authorize(monasca_user_context.context, + uppercase_action, + {})) + + +class RegisteredPoliciesTestCase(base.BaseTestCase): + def __init__(self, *args, **kwds): + super(RegisteredPoliciesTestCase, self).__init__(*args, **kwds) + self.agent_roles = ['agent'] + self.readonly_roles = ['monasca-read-only-user'] + self.default_roles = ['monasca-user'] + self.delegate_roles = ['admin'] + + def test_alarms_policies_roles(self): + alarms_policies = { + 'api:alarms:definition:post': self.default_roles, + 'api:alarms:definition:get': + self.default_roles + self.readonly_roles, + 'api:alarms:definition:put': self.default_roles, + 'api:alarms:definition:patch': self.default_roles, + 'api:alarms:definition:delete': self.default_roles, + 'api:alarms:put': self.default_roles, + 'api:alarms:patch': self.default_roles, + 'api:alarms:delete': self.default_roles, + 'api:alarms:get': self.default_roles + self.readonly_roles, + 'api:alarms:count': self.default_roles + self.readonly_roles, + 'api:alarms:state_history': self.default_roles + self.readonly_roles + } + + self._assert_rules(alarms_policies) + + def test_metrics_policies_roles(self): + metrics_policies = { + 'api:metrics:get': self.default_roles + self.readonly_roles, + 'api:metrics:post': self.agent_roles + self.default_roles, + 'api:metrics:dimension:values': + self.default_roles + self.readonly_roles, + 'api:metrics:dimension:names': + self.default_roles + self.readonly_roles + + } + self._assert_rules(metrics_policies) + + def test_notifications_policies_roles(self): + notifications_policies = { + 'api:notifications:put': self.default_roles, + 'api:notifications:patch': self.default_roles, + 'api:notifications:delete': self.default_roles, + 'api:notifications:get': self.default_roles + self.readonly_roles, + 'api:notifications:post': self.default_roles, + 'api:notifications:type': self.default_roles + self.readonly_roles, + + } + self._assert_rules(notifications_policies) + + def test_versions_policies_roles(self): + versions_policies = { + 'api:versions': ['any_rule!'] + + } + self._assert_rules(versions_policies) + + def test_healthcheck_policies_roles(self): + healthcheck_policies = { + 'api:healthcheck': ['any_rule!'] + } + self._assert_rules(healthcheck_policies) + + def test_delegate_policies_roles(self): + delegate_policies = { + 'api:delegate': self.delegate_roles + } + self._assert_rules(delegate_policies) + + def _assert_rules(self, policies_list): + for policy_name in policies_list: + registered_rule = policy.get_rules()[policy_name] + if hasattr(registered_rule, 'rules'): + self.assertEqual(len(registered_rule.rules), + len(policies_list[policy_name])) + for role in policies_list[policy_name]: + ctx = self._get_request_context(role) + self.assertTrue(policy.authorize(ctx.context, + policy_name, + {}) + ) + + @staticmethod + def _get_request_context(role): + return request.Request( + testing.create_environ( + path='/', + headers={'X_ROLES': role} + ) + ) + + +class PolicyUtilsTestCase(base.BaseTestCase): + def test_roles_list_to_check_str(self): + self.assertEqual(roles_list_to_check_str(['test_role']), 'role:test_role') + self.assertEqual(roles_list_to_check_str(['role1', 'role2', 'role3']), + 'role:role1 or role:role2 or role:role3') + self.assertEqual(roles_list_to_check_str(['@']), '@') + self.assertEqual(roles_list_to_check_str(['role1', '@', 'role2']), + 'role:role1 or @ or role:role2') diff --git a/monasca_api/tests/test_request.py b/monasca_api/tests/test_request.py index b16f92050..f8bff4aca 100644 --- a/monasca_api/tests/test_request.py +++ b/monasca_api/tests/test_request.py @@ -1,4 +1,5 @@ # Copyright 2016-2017 FUJITSU LIMITED +# Copyright 2018 OP5 AB # # 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 @@ -20,6 +21,8 @@ from monasca_api.v2.common import exceptions class TestRequest(base.BaseApiTestCase): + def setUp(self): + super(TestRequest, self).setUp() def test_use_context_from_request(self): req = request.Request( diff --git a/monasca_api/tests/test_validation.py b/monasca_api/tests/test_validation.py index f33233112..bef80e07e 100644 --- a/monasca_api/tests/test_validation.py +++ b/monasca_api/tests/test_validation.py @@ -26,6 +26,11 @@ import monasca_api.v2.common.validation as validation import monasca_api.v2.reference.helpers as helpers +def mock_req_can(authorised_rule): + if authorised_rule != 'authorized': + raise Exception + + class TestStateValidation(base.BaseTestCase): VALID_STATES = "OK", "ALARM", "UNDETERMINED" @@ -71,49 +76,20 @@ class TestSeverityValidation(base.BaseTestCase): '|'.join([self.VALID_SEVERITIES[0], 'BOGUS'])) -class TestRoleValidation(base.BaseTestCase): - - def test_role_valid(self): - req_roles = 'role0', 'rOlE1' - authorized_roles = ['RolE1', 'Role2'] - +class TestRuleValidation(base.BaseApiTestCase): + def test_rule_valid(self): req = mock.Mock() - req.roles = req_roles - - helpers.validate_authorization(req, authorized_roles) - - def test_role_invalid(self): - req_roles = 'role2', 'role3' - authorized_roles = ['role0', 'role1'] + req.can = mock_req_can + test_rules = ['Rule1', 'authorized'] + helpers.validate_authorization(req, test_rules) + def test_rule_invalid(self): req = mock.Mock() - req.roles = req_roles - + req.can = mock_req_can + test_rules = ['rule1', 'rule2'] self.assertRaises( falcon.HTTPUnauthorized, - helpers.validate_authorization, req, authorized_roles) - - def test_empty_role_header(self): - req_roles = [] - authorized_roles = ['Role1', 'Role2'] - - req = mock.Mock() - req.roles = req_roles - - self.assertRaises( - falcon.HTTPUnauthorized, - helpers.validate_authorization, req, authorized_roles) - - def test_no_role_header(self): - req_roles = None - authorized_roles = ['Role1', 'Role2'] - - req = mock.Mock() - req.roles = req_roles - - self.assertRaises( - falcon.HTTPUnauthorized, - helpers.validate_authorization, req, authorized_roles) + helpers.validate_authorization, req, test_rules) class TestTimestampsValidation(base.BaseTestCase): diff --git a/monasca_api/v2/reference/alarm_definitions.py b/monasca_api/v2/reference/alarm_definitions.py index 56b52b256..8421b699b 100644 --- a/monasca_api/v2/reference/alarm_definitions.py +++ b/monasca_api/v2/reference/alarm_definitions.py @@ -1,4 +1,5 @@ # (C) Copyright 2014-2017 Hewlett Packard Enterprise Development LP +# Copyright 2018 OP5 AB # # 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 @@ -44,11 +45,6 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, try: super(AlarmDefinitions, self).__init__() self._region = cfg.CONF.region - self._default_authorized_roles = ( - cfg.CONF.security.default_authorized_roles) - self._get_alarmdefs_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._alarm_definitions_repo = simport.load( cfg.CONF.repositories.alarm_definitions_driver)() @@ -58,7 +54,7 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, @resource.resource_try_catch_block def on_post(self, req, res): - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:definition:post']) alarm_definition = helpers.from_json(req) @@ -87,8 +83,8 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, @resource.resource_try_catch_block def on_get(self, req, res, alarm_definition_id=None): + helpers.validate_authorization(req, ['api:alarms:definition:get']) if alarm_definition_id is None: - helpers.validate_authorization(req, self._get_alarmdefs_authorized_roles) name = helpers.get_query_name(req) dimensions = helpers.get_query_dimensions(req) severity = helpers.get_query_param(req, "severity", default_val=None) @@ -118,20 +114,16 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, req.uri, sort_by, offset, req.limit) - res.body = helpers.to_json(result) - res.status = falcon.HTTP_200 - else: - helpers.validate_authorization(req, self._get_alarmdefs_authorized_roles) - result = self._alarm_definition_show(req.project_id, alarm_definition_id) helpers.add_links_to_resource(result, re.sub('/' + alarm_definition_id, '', req.uri)) - res.body = helpers.to_json(result) - res.status = falcon.HTTP_200 + + res.body = helpers.to_json(result) + res.status = falcon.HTTP_200 @resource.resource_try_catch_block def on_put(self, req, res, alarm_definition_id=None): @@ -139,7 +131,7 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, if not alarm_definition_id: raise HTTPBadRequestError('Bad Request', 'Alarm definition ID not provided') - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:definition:put']) alarm_definition = helpers.from_json(req) @@ -181,7 +173,7 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, if not alarm_definition_id: raise HTTPBadRequestError('Bad Request', 'Alarm definition ID not provided') - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:definition:patch']) alarm_definition = helpers.from_json(req) @@ -230,7 +222,7 @@ class AlarmDefinitions(alarm_definitions_api_v2.AlarmDefinitionsV2API, if not alarm_definition_id: raise HTTPBadRequestError('Bad Request', 'Alarm definition ID not provided') - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:definition:delete']) self._alarm_definition_delete(req.project_id, alarm_definition_id) res.status = falcon.HTTP_204 diff --git a/monasca_api/v2/reference/alarms.py b/monasca_api/v2/reference/alarms.py index f207aa027..2e4ab54eb 100644 --- a/monasca_api/v2/reference/alarms.py +++ b/monasca_api/v2/reference/alarms.py @@ -1,4 +1,5 @@ # Copyright 2014-2017 Hewlett Packard Enterprise Development LP +# Copyright 2018 OP5 AB # # 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 @@ -38,11 +39,6 @@ class Alarms(alarms_api_v2.AlarmsV2API, try: super(Alarms, self).__init__() self._region = cfg.CONF.region - self._default_authorized_roles = ( - cfg.CONF.security.default_authorized_roles) - self._get_alarms_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._alarms_repo = simport.load( cfg.CONF.repositories.alarms_driver)() @@ -53,7 +49,7 @@ class Alarms(alarms_api_v2.AlarmsV2API, @resource.resource_try_catch_block def on_put(self, req, res, alarm_id): - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:put']) alarm = helpers.from_json(req) schema_alarm.validate(alarm) @@ -80,7 +76,7 @@ class Alarms(alarms_api_v2.AlarmsV2API, @resource.resource_try_catch_block def on_patch(self, req, res, alarm_id): - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:patch']) alarm = helpers.from_json(req) schema_alarm.validate(alarm) @@ -106,7 +102,7 @@ class Alarms(alarms_api_v2.AlarmsV2API, @resource.resource_try_catch_block def on_delete(self, req, res, alarm_id): - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:delete']) self._alarm_delete(req.project_id, alarm_id) @@ -114,7 +110,7 @@ class Alarms(alarms_api_v2.AlarmsV2API, @resource.resource_try_catch_block def on_get(self, req, res, alarm_id=None): - helpers.validate_authorization(req, self._get_alarms_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:get']) if alarm_id is None: query_parms = falcon.uri.parse_query_string(req.query_string) @@ -359,9 +355,6 @@ class AlarmsCount(alarms_api_v2.AlarmsCountV2API, alarming.Alarming): try: super(AlarmsCount, self).__init__() self._region = cfg.CONF.region - self._get_alarms_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._alarms_repo = simport.load( cfg.CONF.repositories.alarms_driver)() @@ -371,7 +364,7 @@ class AlarmsCount(alarms_api_v2.AlarmsCountV2API, alarming.Alarming): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_alarms_authorized_roles) + helpers.validate_authorization(req, ['api:alarms:count']) query_parms = falcon.uri.parse_query_string(req.query_string) if 'state' in query_parms: @@ -464,9 +457,6 @@ class AlarmsStateHistory(alarms_api_v2.AlarmsStateHistoryV2API, try: super(AlarmsStateHistory, self).__init__() self._region = cfg.CONF.region - self._get_alarms_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._alarms_repo = simport.load( cfg.CONF.repositories.alarms_driver)() self._metrics_repo = simport.load( @@ -478,12 +468,13 @@ class AlarmsStateHistory(alarms_api_v2.AlarmsStateHistoryV2API, @resource.resource_try_catch_block def on_get(self, req, res, alarm_id=None): + helpers.validate_authorization(req, ['api:alarms:state_history']) + offset = helpers.get_query_param(req, 'offset') if alarm_id is None: - helpers.validate_authorization(req, self._get_alarms_authorized_roles) start_timestamp = helpers.get_query_starttime_timestamp(req, False) end_timestamp = helpers.get_query_endtime_timestamp(req, False) - offset = helpers.get_query_param(req, 'offset') + dimensions = helpers.get_query_dimensions(req) helpers.validate_query_dimensions(dimensions) @@ -491,19 +482,13 @@ class AlarmsStateHistory(alarms_api_v2.AlarmsStateHistoryV2API, end_timestamp, dimensions, req.uri, offset, req.limit) - res.body = helpers.to_json(result) - res.status = falcon.HTTP_200 - else: - helpers.validate_authorization(req, self._get_alarms_authorized_roles) - offset = helpers.get_query_param(req, 'offset') - result = self._alarm_history(req.project_id, alarm_id, req.uri, offset, req.limit) - res.body = helpers.to_json(result) - res.status = falcon.HTTP_200 + res.body = helpers.to_json(result) + res.status = falcon.HTTP_200 def _alarm_history_list(self, tenant_id, start_timestamp, end_timestamp, dimensions, req_uri, offset, diff --git a/monasca_api/v2/reference/helpers.py b/monasca_api/v2/reference/helpers.py index 82861d48a..ec79e4c38 100644 --- a/monasca_api/v2/reference/helpers.py +++ b/monasca_api/v2/reference/helpers.py @@ -1,6 +1,7 @@ # Copyright 2015 Cray Inc. All Rights Reserved. # (C) Copyright 2014,2016-2017 Hewlett Packard Enterprise Development LP # (C) Copyright 2017 SUSE LLC +# Copyright 2018 OP5 AB # # 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 @@ -68,53 +69,41 @@ def validate_json_content_type(req): 'application/json') -def validate_authorization(req, authorized_roles): - """Validates whether one or more X-ROLES in the HTTP header is authorized. +def validate_authorization(http_request, authorized_rules_list): + """Validates whether is authorized according to provided policy rules list. If authorization fails, 401 is thrown with appropriate description. Additionally response specifies 'WWW-Authenticate' header with 'Token' value challenging the client to use different token (the one with - different set of roles). - - :param req: HTTP request object. Must contain "X-ROLES" in the HTTP - request header. - :param authorized_roles: List of authorized roles to check against. - - :raises falcon.HTTPUnauthorized + different set of roles which can access the service). """ - roles = req.roles + challenge = 'Token' - if not roles: - raise falcon.HTTPUnauthorized('Forbidden', - 'Tenant does not have any roles', - challenge) - roles = roles.split(',') if isinstance(roles, six.string_types) else roles - authorized_roles_lower = [r.lower() for r in authorized_roles] - for role in roles: - role = role.lower() - if role in authorized_roles_lower: + for rule in authorized_rules_list: + try: + http_request.can(rule) return + except Exception as ex: + LOG.debug(ex) + raise falcon.HTTPUnauthorized('Forbidden', - 'Tenant ID is missing a required role to ' - 'access this service', + 'The request does not have access to this service', challenge) -def get_x_tenant_or_tenant_id(req, delegate_authorized_roles): - """Evaluates whether the tenant ID or cross tenant ID should be returned. +def get_x_tenant_or_tenant_id(http_request, delegate_authorized_rules_list): + params = falcon.uri.parse_query_string(http_request.query_string) + if 'tenant_id' in params: + tenant_id = params['tenant_id'] - :param req: HTTP request object. - :param delegate_authorized_roles: List of authorized roles that have - delegate privileges. + for rule in delegate_authorized_rules_list: + try: + http_request.can(rule) + return tenant_id + except Exception as ex: + LOG.debug(ex) - :returns: Returns the cross tenant or tenant ID. - """ - if any(x in set(delegate_authorized_roles) for x in req.roles): - params = falcon.uri.parse_query_string(req.query_string) - if 'tenant_id' in params: - tenant_id = params['tenant_id'] - return tenant_id - return req.project_id + return http_request.project_id def get_query_param(req, param_name, required=False, default_val=None): diff --git a/monasca_api/v2/reference/metrics.py b/monasca_api/v2/reference/metrics.py index 2b0075d9e..c25f875b5 100644 --- a/monasca_api/v2/reference/metrics.py +++ b/monasca_api/v2/reference/metrics.py @@ -1,4 +1,5 @@ # (C) Copyright 2014-2017 Hewlett Packard Enterprise Development LP +# Copyright 2018 OP5 AB # # 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 @@ -52,14 +53,6 @@ class Metrics(metrics_api_v2.MetricsV2API): try: super(Metrics, self).__init__() self._region = cfg.CONF.region - self._delegate_authorized_roles = ( - cfg.CONF.security.delegate_authorized_roles) - self._get_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) - self._post_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.agent_authorized_roles) self._message_queue = simport.load(cfg.CONF.messaging.driver)( 'metrics') self._metrics_repo = simport.load( @@ -94,8 +87,7 @@ class Metrics(metrics_api_v2.MetricsV2API): @resource.resource_try_catch_block def on_post(self, req, res): helpers.validate_json_content_type(req) - helpers.validate_authorization(req, - self._post_metrics_authorized_roles) + helpers.validate_authorization(req, ['api:metrics:post']) metrics = helpers.from_json(req) try: metric_validation.validate(metrics) @@ -103,9 +95,7 @@ class Metrics(metrics_api_v2.MetricsV2API): LOG.exception(ex) raise HTTPUnprocessableEntityError("Unprocessable Entity", str(ex)) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) transformed_metrics = metrics_message.transform( metrics, tenant_id, self._region) self._send_metrics(transformed_metrics) @@ -113,10 +103,8 @@ class Metrics(metrics_api_v2.MetricsV2API): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_metrics_authorized_roles) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + helpers.validate_authorization(req, ['api:metrics:get']) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) name = helpers.get_query_name(req) helpers.validate_query_name(name) dimensions = helpers.get_query_dimensions(req) @@ -138,14 +126,6 @@ class MetricsMeasurements(metrics_api_v2.MetricsMeasurementsV2API): try: super(MetricsMeasurements, self).__init__() self._region = cfg.CONF.region - self._delegate_authorized_roles = ( - cfg.CONF.security.delegate_authorized_roles) - self._get_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) - self._post_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.agent_authorized_roles) self._metrics_repo = simport.load( cfg.CONF.repositories.metrics_driver)() @@ -156,10 +136,8 @@ class MetricsMeasurements(metrics_api_v2.MetricsMeasurementsV2API): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_metrics_authorized_roles) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + helpers.validate_authorization(req, ['api:metrics:get']) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) name = helpers.get_query_name(req, True) helpers.validate_query_name(name) dimensions = helpers.get_query_dimensions(req) @@ -203,11 +181,6 @@ class MetricsStatistics(metrics_api_v2.MetricsStatisticsV2API): try: super(MetricsStatistics, self).__init__() self._region = cfg.CONF.region - self._delegate_authorized_roles = ( - cfg.CONF.security.delegate_authorized_roles) - self._get_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._metrics_repo = simport.load( cfg.CONF.repositories.metrics_driver)() @@ -218,10 +191,8 @@ class MetricsStatistics(metrics_api_v2.MetricsStatisticsV2API): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_metrics_authorized_roles) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + helpers.validate_authorization(req, ['api:metrics:get']) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) name = helpers.get_query_name(req, True) helpers.validate_query_name(name) dimensions = helpers.get_query_dimensions(req) @@ -268,11 +239,6 @@ class MetricsNames(metrics_api_v2.MetricsNamesV2API): try: super(MetricsNames, self).__init__() self._region = cfg.CONF.region - self._delegate_authorized_roles = ( - cfg.CONF.security.delegate_authorized_roles) - self._get_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._metrics_repo = simport.load( cfg.CONF.repositories.metrics_driver)() @@ -283,10 +249,8 @@ class MetricsNames(metrics_api_v2.MetricsNamesV2API): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_metrics_authorized_roles) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + helpers.validate_authorization(req, ['api:metrics:get']) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) dimensions = helpers.get_query_dimensions(req) helpers.validate_query_dimensions(dimensions) offset = helpers.get_query_param(req, 'offset') @@ -310,11 +274,6 @@ class DimensionValues(metrics_api_v2.DimensionValuesV2API): try: super(DimensionValues, self).__init__() self._region = cfg.CONF.region - self._delegate_authorized_roles = ( - cfg.CONF.security.delegate_authorized_roles) - self._get_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._metrics_repo = simport.load( cfg.CONF.repositories.metrics_driver)() @@ -324,10 +283,8 @@ class DimensionValues(metrics_api_v2.DimensionValuesV2API): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_metrics_authorized_roles) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + helpers.validate_authorization(req, ['api:metrics:dimension:values']) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) metric_name = helpers.get_query_param(req, 'metric_name') dimension_name = helpers.get_query_param(req, 'dimension_name', required=True) @@ -353,11 +310,6 @@ class DimensionNames(metrics_api_v2.DimensionNamesV2API): try: super(DimensionNames, self).__init__() self._region = cfg.CONF.region - self._delegate_authorized_roles = ( - cfg.CONF.security.delegate_authorized_roles) - self._get_metrics_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._metrics_repo = simport.load( cfg.CONF.repositories.metrics_driver)() @@ -368,10 +320,8 @@ class DimensionNames(metrics_api_v2.DimensionNamesV2API): @resource.resource_try_catch_block def on_get(self, req, res): - helpers.validate_authorization(req, self._get_metrics_authorized_roles) - tenant_id = ( - helpers.get_x_tenant_or_tenant_id(req, - self._delegate_authorized_roles)) + helpers.validate_authorization(req, ['api:metrics:dimension:names']) + tenant_id = helpers.get_x_tenant_or_tenant_id(req, ['api:delegate']) metric_name = helpers.get_query_param(req, 'metric_name') offset = helpers.get_query_param(req, 'offset') result = self._dimension_names(tenant_id, req.uri, metric_name, diff --git a/monasca_api/v2/reference/notifications.py b/monasca_api/v2/reference/notifications.py index 50f949a53..86ab18021 100644 --- a/monasca_api/v2/reference/notifications.py +++ b/monasca_api/v2/reference/notifications.py @@ -1,4 +1,5 @@ # (C) Copyright 2014-2017 Hewlett Packard Enterprise Development LP +# Copyright 2018 OP5 AB # # 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 @@ -37,11 +38,6 @@ class Notifications(notifications_api_v2.NotificationsV2API): super(Notifications, self).__init__() self._region = cfg.CONF.region - self._default_authorized_roles = ( - cfg.CONF.security.default_authorized_roles) - self._get_notifications_authorized_roles = ( - cfg.CONF.security.default_authorized_roles + - cfg.CONF.security.read_only_authorized_roles) self._notifications_repo = simport.load( cfg.CONF.repositories.notifications_driver)() self._notification_method_type_repo = simport.load( @@ -205,7 +201,7 @@ class Notifications(notifications_api_v2.NotificationsV2API): @resource.resource_try_catch_block def on_post(self, req, res): helpers.validate_json_content_type(req) - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:notifications:post']) notification = helpers.from_json(req) self._parse_and_validate_notification(notification) result = self._create_notification(req.project_id, notification, req.uri) @@ -214,9 +210,8 @@ class Notifications(notifications_api_v2.NotificationsV2API): @resource.resource_try_catch_block def on_get(self, req, res, notification_method_id=None): + helpers.validate_authorization(req, ['api:notifications:get']) if notification_method_id is None: - helpers.validate_authorization(req, - self._get_notifications_authorized_roles) sort_by = helpers.get_query_param(req, 'sort_by', default_val=None) if sort_by is not None: if isinstance(sort_by, six.string_types): @@ -238,27 +233,26 @@ class Notifications(notifications_api_v2.NotificationsV2API): result = self._list_notifications(req.project_id, req.uri, sort_by, offset, req.limit) - res.body = helpers.to_json(result) - res.status = falcon.HTTP_200 + else: - helpers.validate_authorization(req, - self._get_notifications_authorized_roles) + result = self._list_notification(req.project_id, notification_method_id, req.uri) - res.body = helpers.to_json(result) - res.status = falcon.HTTP_200 + + res.body = helpers.to_json(result) + res.status = falcon.HTTP_200 @resource.resource_try_catch_block def on_delete(self, req, res, notification_method_id): - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:notifications:delete']) self._delete_notification(req.project_id, notification_method_id) res.status = falcon.HTTP_204 @resource.resource_try_catch_block def on_put(self, req, res, notification_method_id): helpers.validate_json_content_type(req) - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:notifications:put']) notification = helpers.from_json(req) self._parse_and_validate_notification(notification, require_all=True) result = self._update_notification(notification_method_id, req.project_id, @@ -269,7 +263,7 @@ class Notifications(notifications_api_v2.NotificationsV2API): @resource.resource_try_catch_block def on_patch(self, req, res, notification_method_id): helpers.validate_json_content_type(req) - helpers.validate_authorization(req, self._default_authorized_roles) + helpers.validate_authorization(req, ['api:notifications:patch']) notification = helpers.from_json(req) self._patch_get_notification(req.project_id, notification_method_id, notification) self._parse_and_validate_notification(notification, require_all=True) diff --git a/monasca_api/v2/reference/notificationstype.py b/monasca_api/v2/reference/notificationstype.py index 55bf7d278..cf59f1217 100644 --- a/monasca_api/v2/reference/notificationstype.py +++ b/monasca_api/v2/reference/notificationstype.py @@ -1,4 +1,5 @@ # (C) Copyright 2016-2017 Hewlett Packard Enterprise Development LP +# Copyright 2018 OP5 AB # # 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 @@ -34,7 +35,7 @@ class NotificationsType(notificationstype_api_v2.NotificationsTypeV2API): @resource.resource_try_catch_block def on_get(self, req, res): - + helpers.validate_authorization(req, ['api:notifications:type']) # This is to provide consistency. Pagination is not really supported here as there # are not that many rows result = self._list_notifications(req.uri, req.limit) diff --git a/monasca_api/v2/reference/version_2_0.py b/monasca_api/v2/reference/version_2_0.py index e4473caec..d26c3925b 100644 --- a/monasca_api/v2/reference/version_2_0.py +++ b/monasca_api/v2/reference/version_2_0.py @@ -1,4 +1,5 @@ # Copyright 2016 Hewlett Packard Enterprise Development Company, L.P. +# Copyright 2018 OP5 AB # # 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 @@ -20,6 +21,8 @@ class Version2(object): super(Version2, self).__init__() def on_get(self, req, res): + helpers.validate_authorization(req, + ['api:versions']) result = { 'id': 'v2.0', 'links': [{ diff --git a/monasca_api/v2/reference/versions.py b/monasca_api/v2/reference/versions.py index c43f1138a..49149922e 100644 --- a/monasca_api/v2/reference/versions.py +++ b/monasca_api/v2/reference/versions.py @@ -1,4 +1,5 @@ # Copyright 2014 Hewlett-Packard +# Copyright 2018 OP5 AB # # 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 @@ -38,6 +39,8 @@ class Versions(versions_api.VersionsAPI): def on_get(self, req, res, version_id=None): req_uri = req.uri.decode('utf8') if six.PY2 else req.uri + helpers.validate_authorization(req, + ['api:versions']) result = { 'links': [{ 'rel': 'self', diff --git a/releasenotes/notes/oslo-policy-aebaebd218b9d2ff.yaml b/releasenotes/notes/oslo-policy-aebaebd218b9d2ff.yaml new file mode 100644 index 000000000..bc5e79e8d --- /dev/null +++ b/releasenotes/notes/oslo-policy-aebaebd218b9d2ff.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Use of oslo mechanisms for defining and enforcing policy. + A command line entry point that allow the user to generate a sample policy file. diff --git a/requirements.txt b/requirements.txt index da59cbb8d..c6a3a7273 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ oslo.config>=5.2.0 # Apache-2.0 oslo.context>=2.19.2 # Apache-2.0 oslo.log>=3.36.0 # Apache-2.0 oslo.middleware>=3.31.0 # Apache-2.0 +oslo.policy>=1.30.0 # Apache-2.0 oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0 oslo.utils>=3.33.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 61a1bc53f..044282377 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,9 @@ console_scripts = oslo.config.opts = monasca_api = monasca_api.conf:list_opts +oslo.policy.policies = + monasca_api = monasca_api.policies:list_rules + [build_sphinx] all_files = 1 build-dir = doc/build diff --git a/tox.ini b/tox.ini index 3d1c6f0c7..8991687dd 100644 --- a/tox.ini +++ b/tox.ini @@ -117,6 +117,10 @@ commands = description = Generates sample configuration file for monasca-api commands = oslo-config-generator --config-file=config-generator/api-config.conf +[testenv:genpolicy] +description = Generates sample policy.json file for monasca-api +commands = oslopolicy-sample-generator --config-file=config-generator/policy.conf + [testenv:venv] commands = {posargs}