diff --git a/.gitignore b/.gitignore index 2cc5eef5..ca9f8ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ MANIFEST AUTHORS ChangeLog monasca-log-api.log -etc/monasca/log-api.conf.sample +etc/monasca/*.sample *.swp *.iml diff --git a/config-generator/README.md b/config-generator/README.md index 0985af25..477d8a36 100644 --- a/config-generator/README.md +++ b/config-generator/README.md @@ -5,3 +5,9 @@ To generate sample configuration execute ```sh tox -e genconfig ``` + +To generate the sample policies execute + +```sh +tox -e genpolicy +``` diff --git a/config-generator/monasca-log-api.conf b/config-generator/monasca-log-api.conf index b889fd96..3e0c1292 100644 --- a/config-generator/monasca-log-api.conf +++ b/config-generator/monasca-log-api.conf @@ -5,3 +5,4 @@ format = ini summarize = True namespace = monasca_log_api namespace = oslo.log +namespace = oslo.policy diff --git a/config-generator/policy.conf b/config-generator/policy.conf new file mode 100644 index 00000000..075c2b99 --- /dev/null +++ b/config-generator/policy.conf @@ -0,0 +1,4 @@ +[DEFAULT] +output_file = etc/monasca/log-api.policy.yaml.sample +format = yaml +namespace = monasca_log_api \ No newline at end of file diff --git a/doc/source/.gitignore b/doc/source/.gitignore index 22124f4a..53dafec3 100644 --- a/doc/source/.gitignore +++ b/doc/source/.gitignore @@ -1 +1 @@ -_static/*.conf.sample +_static/*.sample diff --git a/doc/source/conf.py b/doc/source/conf.py index 362e97c3..bb30547e 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -39,6 +39,7 @@ extensions = [ 'oslo_config.sphinxconfiggen', 'oslo_config.sphinxext', 'openstackdocstheme', + 'oslo_policy.sphinxpolicygen' ] # geeneral information about project @@ -54,6 +55,11 @@ author = u'OpenStack Foundation' # sample config config_generator_config_file = [ ('config-generator/monasca-log-api.conf', '_static/log-api') + +] +policy_generator_config_file = [ + ('config-generator/policy.conf', '_static/log-api') + ] # Add any paths that contain templates here, relative to this directory. diff --git a/doc/source/configuration/configuring.rst b/doc/source/configuration/configuring.rst index bf8b453f..50111972 100644 --- a/doc/source/configuration/configuring.rst +++ b/doc/source/configuration/configuring.rst @@ -17,7 +17,7 @@ configuration files. This means that gunicorn reports the CLI options of oslo as unknown, and vice versa. -There are 3 configuration files. For more details on the configuration +There are 4 configuration files. For more details on the configuration options, see :ref:`here `. Configuring Keystone Authorization @@ -82,7 +82,8 @@ The configuration for ``monitoring`` should either be provided in Configuring RBAC ---------------- -At the moment monasca-log-api does not feature RBAC with ``oslo.policies``. +At the moment monasca-log-api does not feature RBAC fully with +``oslo.policies``. It provides a custom mechanism, however, that can be configured as follows: * ``path`` - list of URIs that RBAC applies to @@ -95,6 +96,9 @@ It provides a custom mechanism, however, that can be configured as follows: The configuration for ``roles_middleware`` should either be provided in ``log-api.conf`` or in a file in one of the configuration directories. +The configuration for accessing the services by ``oslo.policies`` can be +provided in ``log-api.policy.yaml``. + Configuring Logging ------------------- @@ -137,3 +141,21 @@ based on your deployment: * `oslo.log `_ * `Python HowTo `_ * `Logging handlers `_ + +Configuring Policies +-------------------- + +The policies for accessing each service can be configured in the +``log-api.policy.yaml`` configuration file:: + + Policy Description + Method Path + "Policy string": "Roles" + +example:: + + Logs post rule + POST /logs + POST /log/single + "log_api:logs:post": "role:monasca-user" + diff --git a/doc/source/configuration/files.rst b/doc/source/configuration/files.rst index 7408daac..11ca1cb2 100644 --- a/doc/source/configuration/files.rst +++ b/doc/source/configuration/files.rst @@ -83,3 +83,17 @@ To enable ``oslo_middleware.debug:Debug`` for ``Log v3`` pipeline, This particular filter might be useful for examining the WSGI environment during troubleshooting or local development. +log-api.policy.yaml +------------------- + +This is the configuration file for policies to access the services. +the path of the file can be defined in ``log-api.conf``:: + + [oslo_policy] + policy_file = log-api.policy.yaml + +More information about policy file configuration can be found at +`oslo.policy `_ + +A sample of this configuration file is also available +:ref:`here ` diff --git a/doc/source/configuration/sample.rst b/doc/source/configuration/sample.rst index 144a19b0..667c9a8c 100644 --- a/doc/source/configuration/sample.rst +++ b/doc/source/configuration/sample.rst @@ -38,3 +38,14 @@ This sample configuration can also be viewed in `log-api-paste.ini `_. .. literalinclude:: ../../../etc/monasca/log-api-paste.ini + +.. _sample-configuration-policy: + +Sample Configuration For Policy +------------------------------- + +This sample configuration can also be viewed in `log-api-policy.yaml.sample +<../_static/log-api-policy.yaml.sample>`_. + +.. literalinclude:: ../_static/log-api.policy.yaml.sample + diff --git a/monasca_log_api/app/base/log_publisher.py b/monasca_log_api/app/base/log_publisher.py index 2b51039d..25827bb6 100644 --- a/monasca_log_api/app/base/log_publisher.py +++ b/monasca_log_api/app/base/log_publisher.py @@ -61,7 +61,6 @@ class LogPublisher(object): """ def __init__(self): - self._topics = CONF.log_publisher.topics self.max_message_size = CONF.log_publisher.max_message_size diff --git a/monasca_log_api/app/base/request.py b/monasca_log_api/app/base/request.py index 319caed6..11d6a97d 100644 --- a/monasca_log_api/app/base/request.py +++ b/monasca_log_api/app/base/request.py @@ -14,9 +14,14 @@ import falcon -from oslo_context import context +from monasca_common.policy import policy_engine as policy +from monasca_log_api.app.base import request_context from monasca_log_api.app.base import validation +from monasca_log_api import policies + +policy.POLICIES = policies + _TENANT_ID_PARAM = 'tenant_id' """Name of the query-param pointing at project-id (tenant-id)""" @@ -32,7 +37,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) def validate(self, content_types): """Performs common request validation @@ -99,5 +104,8 @@ class Request(falcon.Request): """ return self.context.roles + 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_log_api/app/base/request_context.py b/monasca_log_api/app/base/request_context.py new file mode 100644 index 00000000..5805c40d --- /dev/null +++ b/monasca_log_api/app/base/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_log_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_log_api/app/base/validation.py b/monasca_log_api/app/base/validation.py index 6ec033cd..260215ea 100644 --- a/monasca_log_api/app/base/validation.py +++ b/monasca_log_api/app/base/validation.py @@ -244,3 +244,24 @@ def validate_log_message(log_object): raise exceptions.HTTPUnprocessableEntity( 'Log property should have message' ) + + +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 which can access the service). + """ + challenge = 'Token' + for rule in authorized_rules_list: + try: + http_request.can(rule) + return + except Exception as ex: + LOG.debug(ex) + + raise falcon.HTTPUnauthorized('Forbidden', + 'The request does not have access to this service', + challenge) diff --git a/monasca_log_api/app/controller/healthchecks.py b/monasca_log_api/app/controller/healthchecks.py index a29ebf18..04cd1b46 100644 --- a/monasca_log_api/app/controller/healthchecks.py +++ b/monasca_log_api/app/controller/healthchecks.py @@ -15,6 +15,7 @@ import falcon from monasca_common.rest import utils as rest_utils +from monasca_log_api.app.base.validation import validate_authorization from monasca_log_api.app.controller.api import healthcheck_api from monasca_log_api.healthcheck import kafka_check @@ -33,13 +34,14 @@ class HealthChecks(healthcheck_api.HealthChecksApi): super(HealthChecks, self).__init__() def on_head(self, req, res): + validate_authorization(req, ['log_api:healthcheck:head']) res.status = self.HEALTHY_CODE_HEAD res.cache_control = self.CACHE_CONTROL def on_get(self, req, res): # at this point we know API is alive, so # keep up good work and verify kafka status - + validate_authorization(req, ['log_api:healthcheck:get']) kafka_result = self._kafka_check.healthcheck() # in case it'd be unhealthy, diff --git a/monasca_log_api/app/controller/v2/logs.py b/monasca_log_api/app/controller/v2/logs.py index fb198e48..90779e19 100644 --- a/monasca_log_api/app/controller/v2/logs.py +++ b/monasca_log_api/app/controller/v2/logs.py @@ -18,6 +18,7 @@ import six from monasca_log_api.app.base import log_publisher +from monasca_log_api.app.base.validation import validate_authorization from monasca_log_api.app.controller.api import headers from monasca_log_api.app.controller.api import logs_api from monasca_log_api.app.controller.v2.aid import service @@ -41,6 +42,7 @@ class Logs(logs_api.LogsApi): @falcon.deprecated(_DEPRECATED_INFO) def on_post(self, req, res): + validate_authorization(req, ['log_api:logs:post']) if CONF.monitoring.enable: with self._logs_processing_time.time(name=None): self.process_on_post_request(req, res) diff --git a/monasca_log_api/app/controller/v3/logs.py b/monasca_log_api/app/controller/v3/logs.py index 6236fcf8..12045f6b 100644 --- a/monasca_log_api/app/controller/v3/logs.py +++ b/monasca_log_api/app/controller/v3/logs.py @@ -50,6 +50,7 @@ class Logs(logs_api.LogsApi): self._processor = bulk_processor.BulkProcessor() def on_post(self, req, res): + validation.validate_authorization(req, ['log_api:logs:post']) if CONF.monitoring.enable: with self._logs_processing_time.time(name=None): self.process_on_post_request(req, res) diff --git a/monasca_log_api/app/controller/versions.py b/monasca_log_api/app/controller/versions.py index 7cd58332..cf3e3e59 100644 --- a/monasca_log_api/app/controller/versions.py +++ b/monasca_log_api/app/controller/versions.py @@ -18,6 +18,7 @@ import six from monasca_common.rest import utils as rest_utils +from monasca_log_api.app.base.validation import validate_authorization from monasca_log_api.app.controller.api import versions_api _VERSIONS_TPL_DICT = { @@ -69,6 +70,7 @@ class Versions(versions_api.VersionsAPI): res.status = falcon.HTTP_400 def on_get(self, req, res, version_id=None): + validate_authorization(req, ['log_api:versions:get']) result = { 'links': _get_common_links(req), 'elements': [] diff --git a/monasca_log_api/conf/role_middleware.py b/monasca_log_api/conf/role_middleware.py index 8cbc1c51..bb460f47 100644 --- a/monasca_log_api/conf/role_middleware.py +++ b/monasca_log_api/conf/role_middleware.py @@ -29,7 +29,11 @@ role_m_opts = [ cfg.ListOpt(name='delegate_roles', default=['admin'], help=('Roles that are allowed to POST logs on ' - 'behalf of another tenant (project)')) + 'behalf of another tenant (project)')), + cfg.ListOpt(name='check_roles', + default=['@'], + help=('Roles that are allowed to do check ' + 'version and health')) ] role_m_group = cfg.OptGroup(name='roles_middleware', title='roles_middleware') diff --git a/monasca_log_api/config.py b/monasca_log_api/config.py index 9bd9d656..42190b8c 100644 --- a/monasca_log_api/config.py +++ b/monasca_log_api/config.py @@ -15,6 +15,7 @@ import sys from oslo_log import log +from oslo_policy import opts as policy_opts from monasca_log_api import conf from monasca_log_api import version @@ -56,5 +57,6 @@ def parse_args(argv=None): version=version.version_str) conf.register_opts() + policy_opts.set_defaults(CONF) _CONF_LOADED = True diff --git a/monasca_log_api/middleware/role_middleware.py b/monasca_log_api/middleware/role_middleware.py index 40e7bee6..cac851a8 100644 --- a/monasca_log_api/middleware/role_middleware.py +++ b/monasca_log_api/middleware/role_middleware.py @@ -98,47 +98,38 @@ class RoleMiddleware(om.ConfigurableMiddleware): return None is_authenticated = self._is_authenticated(req) - is_authorized, is_agent = self._is_authorized(req) + is_agent = self._is_agent(req) tenant_id = req.headers.get('X-Tenant-Id') req.environ[_X_MONASCA_LOG_AGENT] = is_agent - LOG.debug('%s is authenticated=%s, authorized=%s, log_agent=%s', - tenant_id, is_authenticated, is_authorized, is_agent) + LOG.debug('%s is authenticated=%s, log_agent=%s', + tenant_id, is_authenticated, is_agent) - if is_authenticated and is_authorized: - LOG.debug('%s has been authenticated and authorized', tenant_id) + if is_authenticated: + LOG.debug('%s has been authenticated', tenant_id) return # do return nothing to enter API internal - # whoops - if is_authorized: - explanation = u'Failed to authenticate request for %s' % tenant_id - else: - explanation = (u'Tenant %s is missing a required role to access ' - u'this service' % tenant_id) + explanation = u'Failed to authenticate request for %s' % tenant_id + LOG.error(explanation) + json_body = {u'title': u'Unauthorized', u'message': explanation} + return response.Response(status=401, + json_body=json_body, + content_type='application/json') - if explanation is not None: - LOG.error(explanation) - json_body = {u'title': u'Unauthorized', u'message': explanation} - return response.Response(status=401, - json_body=json_body, - content_type='application/json') - - def _is_authorized(self, req): + def _is_agent(self, req): headers = req.headers roles = headers.get(_X_ROLES) if not roles: LOG.warning('Couldn\'t locate %s header,or it was empty', _X_ROLES) - return False, False + return False else: roles = _ensure_lower_roles(roles.split(',')) is_agent = len(_intersect(roles, self._agent_roles)) > 0 - is_authorized = (len(_intersect(roles, self._default_roles)) > 0 or - is_agent) - return is_authorized, is_agent + return is_agent def _is_authenticated(self, req): headers = req.headers diff --git a/monasca_log_api/policies/__init__.py b/monasca_log_api/policies/__init__.py new file mode 100644 index 00000000..d759849b --- /dev/null +++ b/monasca_log_api/policies/__init__.py @@ -0,0 +1,79 @@ +# 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_log_api.conf import role_middleware + +LOG = log.getLogger(__name__) +_BASE_MOD_PATH = 'monasca_log_api.policies.' +CONF = cfg.CONF + + +def roles_list_to_check_str(roles_list): + if roles_list: + converted_roles_list = ["role:" + role if role != '@' else role for role in roles_list] + return ' or '.join(converted_roles_list) + else: + return None + + +role_middleware.register_opts(CONF) + +DEFAULT_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.roles_middleware.default_roles) +AGENT_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.roles_middleware.agent_roles) +DELEGATE_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.roles_middleware.delegate_roles) +CHECK_AUTHORIZED_ROLES = roles_list_to_check_str(cfg.CONF.roles_middleware.check_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_log_api/policies/healthchecks.py b/monasca_log_api/policies/healthchecks.py new file mode 100644 index 00000000..148a35b5 --- /dev/null +++ b/monasca_log_api/policies/healthchecks.py @@ -0,0 +1,40 @@ +# 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_log_api.policies import CHECK_AUTHORIZED_ROLES + +rules = [ + policy.DocumentedRuleDefault( + name='log_api:healthcheck:head', + check_str=CHECK_AUTHORIZED_ROLES, + description='Healthcheck head rule', + operations=[ + {'path': '/', 'method': 'HEAD'} + ] + ), + policy.DocumentedRuleDefault( + name='log_api:healthcheck:get', + check_str=CHECK_AUTHORIZED_ROLES, + description='Healthcheck get rule', + operations=[ + {'path': '/', 'method': 'GET'} + ] + ), +] + + +def list_rules(): + return rules diff --git a/monasca_log_api/policies/logs.py b/monasca_log_api/policies/logs.py new file mode 100644 index 00000000..2e0e91ee --- /dev/null +++ b/monasca_log_api/policies/logs.py @@ -0,0 +1,38 @@ +# 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_log_api.policies import AGENT_AUTHORIZED_ROLES +from monasca_log_api.policies import DEFAULT_AUTHORIZED_ROLES +from monasca_log_api.policies import DELEGATE_AUTHORIZED_ROLES + + +rules = [ + policy.DocumentedRuleDefault( + name='log_api:logs:post', + check_str=' or '.join(filter(None, [AGENT_AUTHORIZED_ROLES, + DEFAULT_AUTHORIZED_ROLES, + DELEGATE_AUTHORIZED_ROLES])), + description='Logs post rule', + operations=[ + {'path': '/logs', 'method': 'POST'}, + {'path': '/log/single', 'method': 'POST'} + ] + ) +] + + +def list_rules(): + return rules diff --git a/monasca_log_api/policies/versions.py b/monasca_log_api/policies/versions.py new file mode 100644 index 00000000..60eb554d --- /dev/null +++ b/monasca_log_api/policies/versions.py @@ -0,0 +1,35 @@ +# 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_log_api.policies import CHECK_AUTHORIZED_ROLES + + +rules = [ + policy.DocumentedRuleDefault( + name='log_api:versions:get', + check_str=CHECK_AUTHORIZED_ROLES, + description='Versions get rule', + operations=[ + {'path': '/', 'method': 'GET'}, + {'path': '/version', 'method': 'GET'}, + {'path': '/version/{version_id}', 'method': 'GET'} + ] + ) +] + + +def list_rules(): + return rules diff --git a/monasca_log_api/tests/base.py b/monasca_log_api/tests/base.py index 6986c86e..ed1ac4fe 100644 --- a/monasca_log_api/tests/base.py +++ b/monasca_log_api/tests/base.py @@ -1,6 +1,7 @@ # coding=utf-8 # 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 @@ -15,6 +16,7 @@ # under the License. import codecs +import os import random import string @@ -22,14 +24,19 @@ import falcon from falcon import testing import fixtures import mock +from monasca_common.policy import policy_engine as policy 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 import six from monasca_log_api.app.base import request from monasca_log_api import conf from monasca_log_api import config +from monasca_log_api import policies + +policy.POLICIES = policies class MockedAPI(falcon.API): @@ -149,6 +156,40 @@ class ConfigFixture(oo_cfg.Config): self.conf.set_default('kafka_url', '127.0.0.1', 'log_publisher') +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 + + class BaseTestCase(oslotest_base.BaseTestCase): def setUp(self): @@ -156,6 +197,7 @@ class BaseTestCase(oslotest_base.BaseTestCase): self.useFixture(ConfigFixture()) self.useFixture(DisableStatsdFixture()) self.useFixture(oo_ctx.ClearRequestContext()) + self.useFixture(PolicyFixture()) @staticmethod def conf_override(**kw): diff --git a/monasca_log_api/tests/test_logs.py b/monasca_log_api/tests/test_logs.py index e999b04b..5114f4b2 100644 --- a/monasca_log_api/tests/test_logs.py +++ b/monasca_log_api/tests/test_logs.py @@ -51,7 +51,7 @@ class TestApiLogs(base.BaseApiTestCase): '/log/single', method='POST', headers={ - headers.X_ROLES.name: 'some_role', + headers.X_ROLES.name: ROLES, headers.X_DIMENSIONS.name: 'a:1', 'Content-Type': 'application/json', 'Content-Length': '0' @@ -75,7 +75,7 @@ class TestApiLogs(base.BaseApiTestCase): 'Content-Length': '0' } ) - self.assertEqual(falcon.HTTP_403, self.srmock.status) + self.assertEqual(falcon.HTTP_401, self.srmock.status) @mock.patch('monasca_log_api.app.controller.v2.aid.service.LogCreator') @mock.patch('monasca_log_api.app.base.log_publisher.LogPublisher') @@ -90,7 +90,7 @@ class TestApiLogs(base.BaseApiTestCase): '/log/single', method='POST', headers={ - headers.X_ROLES.name: 'some_role', + headers.X_ROLES.name: ROLES, headers.X_DIMENSIONS.name: 'a:1', 'Content-Type': 'application/json', 'Content-Length': '0' diff --git a/monasca_log_api/tests/test_logs_v3.py b/monasca_log_api/tests/test_logs_v3.py index 729e73e4..83be9213 100644 --- a/monasca_log_api/tests/test_logs_v3.py +++ b/monasca_log_api/tests/test_logs_v3.py @@ -237,11 +237,12 @@ class TestApiLogs(base.BaseApiTestCase): method='POST', query_string='tenant_id=1', headers={ + headers.X_ROLES.name: ROLES, 'Content-Type': 'application/json', 'Content-Length': '0' } ) - self.assertEqual(falcon.HTTP_403, self.srmock.status) + self.assertEqual(falcon.HTTP_400, self.srmock.status) @mock.patch('monasca_log_api.app.controller.v3.aid.bulk_processor.' 'BulkProcessor') @@ -257,7 +258,7 @@ class TestApiLogs(base.BaseApiTestCase): '/logs', method='POST', headers={ - headers.X_ROLES.name: 'some_role', + headers.X_ROLES.name: ROLES, 'Content-Type': 'application/json', 'Content-Length': str(content_length) }, diff --git a/monasca_log_api/tests/test_policy.py b/monasca_log_api/tests/test_policy.py new file mode 100644 index 00000000..37d5e4e1 --- /dev/null +++ b/monasca_log_api/tests/test_policy.py @@ -0,0 +1,214 @@ +# 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_log_api.app.base import request +from monasca_log_api.policies import roles_list_to_check_str +from monasca_log_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.default_roles = ['monasca-user', 'admin'] + + def test_healthchecks_policies_roles(self): + healthcheck_policies = { + 'log_api:healthcheck:head': ['any_role'], + 'log_api:healthcheck:get': ['any_role'] + } + + self._assert_rules(healthcheck_policies) + + def test_versions_policies_roles(self): + versions_policies = { + 'log_api:versions:get': ['any_role'] + } + + self._assert_rules(versions_policies) + + def test_logs_policies_roles(self): + + logs_policies = { + 'log_api:logs:post': self.default_roles + } + + self._assert_rules(logs_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') + self.assertIsNone(roles_list_to_check_str(None)) diff --git a/monasca_log_api/tests/test_role_middleware.py b/monasca_log_api/tests/test_role_middleware.py index 243cc78f..8602e07a 100644 --- a/monasca_log_api/tests/test_role_middleware.py +++ b/monasca_log_api/tests/test_role_middleware.py @@ -125,23 +125,7 @@ class RolesMiddlewareSideLogicTest(base.BaseTestCase): self.assertFalse(instance._is_authenticated(req)) - def test_should_return_true_if_authorized_no_agent(self): - roles = 'cmm-admin,cmm-user' - roles_array = roles.split(',') - - instance = rm.RoleMiddleware(None) - instance._default_roles = roles_array - instance._agent_roles = [] - - req = mock.Mock() - req.headers = {rm._X_ROLES: roles} - - is_authorized, is_agent = instance._is_authorized(req) - - self.assertFalse(is_agent) - self.assertTrue(is_authorized) - - def test_should_return_true_if_authorized_with_agent(self): + def test_should_return_true_if_is_agent(self): roles = 'cmm-admin,cmm-user' roles_array = roles.split(',') @@ -155,48 +139,9 @@ class RolesMiddlewareSideLogicTest(base.BaseTestCase): req = mock.Mock() req.headers = {rm._X_ROLES: roles} - is_authorized, is_agent = instance._is_authorized(req) + is_agent = instance._is_agent(req) self.assertTrue(is_agent) - self.assertTrue(is_authorized) - - def test_should_return_not_authorized_no_x_roles(self): - roles = 'cmm-admin,cmm-user' - roles_array = roles.split(',') - - default_roles = [roles_array[0]] - admin_roles = [roles_array[1]] - - instance = rm.RoleMiddleware(None) - instance._default_roles = default_roles - instance._agent_roles = admin_roles - - req = mock.Mock() - req.headers = {} - - is_authorized, is_agent = instance._is_authorized(req) - - self.assertFalse(is_agent) - self.assertFalse(is_authorized) - - def test_should_return_authorized_if_at_least_agent_true(self): - roles = 'cmm-admin,cmm-user' - roles_array = roles.split(',') - - default_roles = ['different_role'] - admin_roles = [roles_array[1]] - - instance = rm.RoleMiddleware(None) - instance._default_roles = default_roles - instance._agent_roles = admin_roles - - req = mock.Mock() - req.headers = {rm._X_ROLES: roles} - - is_authorized, is_agent = instance._is_authorized(req) - - self.assertTrue(is_agent) - self.assertTrue(is_authorized) class RolesMiddlewareLogicTest(base.BaseTestCase): @@ -215,7 +160,7 @@ class RolesMiddlewareLogicTest(base.BaseTestCase): # spying instance._is_authenticated = mock.Mock() - instance._is_authorized = mock.Mock() + instance._is_agent = mock.Mock() req = mock.Mock() req.headers = {rm._X_ROLES: roles} @@ -224,7 +169,7 @@ class RolesMiddlewareLogicTest(base.BaseTestCase): instance.process_request(req=req) self.assertFalse(instance._is_authenticated.called) - self.assertFalse(instance._is_authorized.called) + self.assertFalse(instance._is_agent.called) def test_not_process_further_if_cannot_apply_method(self): roles = 'cmm-admin,cmm-user' @@ -240,7 +185,7 @@ class RolesMiddlewareLogicTest(base.BaseTestCase): # spying instance._is_authenticated = mock.Mock() - instance._is_authorized = mock.Mock() + instance._is_agent = mock.Mock() req = mock.Mock() req.headers = {rm._X_ROLES: roles} @@ -250,65 +195,16 @@ class RolesMiddlewareLogicTest(base.BaseTestCase): instance.process_request(req=req) self.assertFalse(instance._is_authenticated.called) - self.assertFalse(instance._is_authorized.called) + self.assertFalse(instance._is_agent.called) - def test_should_return_None_if_authenticated_authorized(self): - instance = rm.RoleMiddleware(None) - is_authorized = True - is_agent = True - - instance._can_apply_middleware = mock.Mock(return_value=True) - instance._is_authorized = mock.Mock(return_value=(is_authorized, - is_agent)) - instance._is_authenticated = mock.Mock(return_value=True) - - req = mock.Mock() - req.environ = {} - - result = instance.process_request(req=req) - - self.assertIsNone(result) - - def test_should_produce_json_response_if_not_authorized_but_authenticated( + def test_should_produce_json_response_if_not_authenticated( self): instance = rm.RoleMiddleware(None) - is_authorized = False - is_agent = False - is_authenticated = True - - instance._can_apply_middleware = mock.Mock(return_value=True) - instance._is_authorized = mock.Mock(return_value=(is_authorized, - is_agent)) - instance._is_authenticated = mock.Mock(return_value=is_authenticated) - - req = mock.Mock() - req.environ = {} - req.headers = { - 'X-Tenant-Id': '11111111' - } - - result = instance.process_request(req=req) - - self.assertIsNotNone(result) - self.assertIsInstance(result, response.Response) - - status = result.status_code - json_body = result.json_body - message = json_body.get('message') - - self.assertIn('is missing a required role to access', message) - self.assertEqual(401, status) - - def test_should_produce_json_response_if_not_authenticated_but_authorized( - self): - instance = rm.RoleMiddleware(None) - is_authorized = True is_agent = True is_authenticated = False instance._can_apply_middleware = mock.Mock(return_value=True) - instance._is_authorized = mock.Mock(return_value=(is_authorized, - is_agent)) + instance._is_agent = mock.Mock(return_value=is_agent) instance._is_authenticated = mock.Mock(return_value=is_authenticated) req = mock.Mock() diff --git a/releasenotes/notes/oslo-policy-e142fa9243a8dcf6.yaml b/releasenotes/notes/oslo-policy-e142fa9243a8dcf6.yaml new file mode 100644 index 00000000..ab3d57bb --- /dev/null +++ b/releasenotes/notes/oslo-policy-e142fa9243a8dcf6.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Use of oslo mechanisms for defining and enforcing policy. + A command line entry point that allows the user to generate a sample policy file. diff --git a/setup.cfg b/setup.cfg index f85039eb..3771fbb2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,9 @@ wsgi_scripts = oslo.config.opts = monasca_log_api = monasca_log_api.conf:list_opts +oslo.policy.policies = + monasca_log_api = monasca_log_api.policies:list_rules + [build_sphinx] all_files = 1 build-dir = doc/build diff --git a/tox.ini b/tox.ini index d2d0ca62..d463794c 100644 --- a/tox.ini +++ b/tox.ini @@ -89,6 +89,11 @@ basepython = python3 description = Generates sample documentation file for monasca-log-api commands = oslo-config-generator --config-file=config-generator/monasca-log-api.conf +[testenv:genpolicy] +basepython = python3 +description = Generates sample policy.json file for monasca-log-api +commands = oslopolicy-sample-generator --config-file=config-generator/policy.conf + [testenv:docs] basepython = python3 description = Builds api-ref, api-guide, releasenotes and devdocs