Using oslo.policy for monasca-log-api

Added policies and used policy enforcement engine
from monasca-common.

- Updated role_middleware to remove authorization into the routes.
- Updated unit tests and implemented some new tests.
- Added a new entry point for generating sample policy file by tox.

story: 2001233
task: 22086

Change-Id: I3d199fac244eca94fc434d19c78bc5a17e804c37
Signed-off-by: Amir Mofakhar <amofakhar@op5.com>
This commit is contained in:
Amir Mofakhar 2018-06-26 12:47:39 +02:00
parent 1562cdb243
commit 5729d7e7c8
32 changed files with 639 additions and 149 deletions

2
.gitignore vendored
View File

@ -16,7 +16,7 @@ MANIFEST
AUTHORS AUTHORS
ChangeLog ChangeLog
monasca-log-api.log monasca-log-api.log
etc/monasca/log-api.conf.sample etc/monasca/*.sample
*.swp *.swp
*.iml *.iml

View File

@ -5,3 +5,9 @@ To generate sample configuration execute
```sh ```sh
tox -e genconfig tox -e genconfig
``` ```
To generate the sample policies execute
```sh
tox -e genpolicy
```

View File

@ -5,3 +5,4 @@ format = ini
summarize = True summarize = True
namespace = monasca_log_api namespace = monasca_log_api
namespace = oslo.log namespace = oslo.log
namespace = oslo.policy

View File

@ -0,0 +1,4 @@
[DEFAULT]
output_file = etc/monasca/log-api.policy.yaml.sample
format = yaml
namespace = monasca_log_api

View File

@ -1 +1 @@
_static/*.conf.sample _static/*.sample

View File

@ -39,6 +39,7 @@ extensions = [
'oslo_config.sphinxconfiggen', 'oslo_config.sphinxconfiggen',
'oslo_config.sphinxext', 'oslo_config.sphinxext',
'openstackdocstheme', 'openstackdocstheme',
'oslo_policy.sphinxpolicygen'
] ]
# geeneral information about project # geeneral information about project
@ -54,6 +55,11 @@ author = u'OpenStack Foundation'
# sample config # sample config
config_generator_config_file = [ config_generator_config_file = [
('config-generator/monasca-log-api.conf', '_static/log-api') ('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. # Add any paths that contain templates here, relative to this directory.

View File

@ -17,7 +17,7 @@ configuration files.
This means that gunicorn reports the CLI options of This means that gunicorn reports the CLI options of
oslo as unknown, and vice versa. 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 <configuration-files>`. options, see :ref:`here <configuration-files>`.
Configuring Keystone Authorization Configuring Keystone Authorization
@ -82,7 +82,8 @@ The configuration for ``monitoring`` should either be provided in
Configuring RBAC 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: It provides a custom mechanism, however, that can be configured as follows:
* ``path`` - list of URIs that RBAC applies to * ``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 The configuration for ``roles_middleware`` should either be provided in
``log-api.conf`` or in a file in one of the configuration directories. ``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 Configuring Logging
------------------- -------------------
@ -137,3 +141,21 @@ based on your deployment:
* `oslo.log <https://docs.openstack.org/oslo.log/latest/index.html>`_ * `oslo.log <https://docs.openstack.org/oslo.log/latest/index.html>`_
* `Python HowTo <https://docs.python.org/2/howto/logging.html>`_ * `Python HowTo <https://docs.python.org/2/howto/logging.html>`_
* `Logging handlers <https://docs.python.org/2/library/logging.handlers.html>`_ * `Logging handlers <https://docs.python.org/2/library/logging.handlers.html>`_
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"

View File

@ -83,3 +83,17 @@ To enable ``oslo_middleware.debug:Debug`` for ``Log v3`` pipeline,
This particular filter might be useful for examining the This particular filter might be useful for examining the
WSGI environment during troubleshooting or local development. 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 <https://docs.openstack.org/oslo.policy/latest/admin/policy-yaml-file.html>`_
A sample of this configuration file is also available
:ref:`here <sample-configuration-policy>`

View File

@ -38,3 +38,14 @@ This sample configuration can also be viewed in `log-api-paste.ini
<https://github.com/openstack/monasca-log-api/blob/master/etc/monasca/log-api-paste.ini>`_. <https://github.com/openstack/monasca-log-api/blob/master/etc/monasca/log-api-paste.ini>`_.
.. literalinclude:: ../../../etc/monasca/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

View File

@ -61,7 +61,6 @@ class LogPublisher(object):
""" """
def __init__(self): def __init__(self):
self._topics = CONF.log_publisher.topics self._topics = CONF.log_publisher.topics
self.max_message_size = CONF.log_publisher.max_message_size self.max_message_size = CONF.log_publisher.max_message_size

View File

@ -14,9 +14,14 @@
import falcon 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.app.base import validation
from monasca_log_api import policies
policy.POLICIES = policies
_TENANT_ID_PARAM = 'tenant_id' _TENANT_ID_PARAM = 'tenant_id'
"""Name of the query-param pointing at project-id (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): def __init__(self, env, options=None):
super(Request, self).__init__(env, options) 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): def validate(self, content_types):
"""Performs common request validation """Performs common request validation
@ -99,5 +104,8 @@ class Request(falcon.Request):
""" """
return self.context.roles return self.context.roles
def can(self, action, target=None):
return self.context.can(action, target)
def __repr__(self): def __repr__(self):
return '%s, context=%s' % (self.path, self.context) return '%s, context=%s' % (self.path, self.context)

View File

@ -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)

View File

@ -244,3 +244,24 @@ def validate_log_message(log_object):
raise exceptions.HTTPUnprocessableEntity( raise exceptions.HTTPUnprocessableEntity(
'Log property should have message' '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)

View File

@ -15,6 +15,7 @@
import falcon import falcon
from monasca_common.rest import utils as rest_utils 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.app.controller.api import healthcheck_api
from monasca_log_api.healthcheck import kafka_check from monasca_log_api.healthcheck import kafka_check
@ -33,13 +34,14 @@ class HealthChecks(healthcheck_api.HealthChecksApi):
super(HealthChecks, self).__init__() super(HealthChecks, self).__init__()
def on_head(self, req, res): def on_head(self, req, res):
validate_authorization(req, ['log_api:healthcheck:head'])
res.status = self.HEALTHY_CODE_HEAD res.status = self.HEALTHY_CODE_HEAD
res.cache_control = self.CACHE_CONTROL res.cache_control = self.CACHE_CONTROL
def on_get(self, req, res): def on_get(self, req, res):
# at this point we know API is alive, so # at this point we know API is alive, so
# keep up good work and verify kafka status # keep up good work and verify kafka status
validate_authorization(req, ['log_api:healthcheck:get'])
kafka_result = self._kafka_check.healthcheck() kafka_result = self._kafka_check.healthcheck()
# in case it'd be unhealthy, # in case it'd be unhealthy,

View File

@ -18,6 +18,7 @@ import six
from monasca_log_api.app.base import log_publisher 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 headers
from monasca_log_api.app.controller.api import logs_api from monasca_log_api.app.controller.api import logs_api
from monasca_log_api.app.controller.v2.aid import service from monasca_log_api.app.controller.v2.aid import service
@ -41,6 +42,7 @@ class Logs(logs_api.LogsApi):
@falcon.deprecated(_DEPRECATED_INFO) @falcon.deprecated(_DEPRECATED_INFO)
def on_post(self, req, res): def on_post(self, req, res):
validate_authorization(req, ['log_api:logs:post'])
if CONF.monitoring.enable: if CONF.monitoring.enable:
with self._logs_processing_time.time(name=None): with self._logs_processing_time.time(name=None):
self.process_on_post_request(req, res) self.process_on_post_request(req, res)

View File

@ -50,6 +50,7 @@ class Logs(logs_api.LogsApi):
self._processor = bulk_processor.BulkProcessor() self._processor = bulk_processor.BulkProcessor()
def on_post(self, req, res): def on_post(self, req, res):
validation.validate_authorization(req, ['log_api:logs:post'])
if CONF.monitoring.enable: if CONF.monitoring.enable:
with self._logs_processing_time.time(name=None): with self._logs_processing_time.time(name=None):
self.process_on_post_request(req, res) self.process_on_post_request(req, res)

View File

@ -18,6 +18,7 @@ import six
from monasca_common.rest import utils as rest_utils 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 from monasca_log_api.app.controller.api import versions_api
_VERSIONS_TPL_DICT = { _VERSIONS_TPL_DICT = {
@ -69,6 +70,7 @@ class Versions(versions_api.VersionsAPI):
res.status = falcon.HTTP_400 res.status = falcon.HTTP_400
def on_get(self, req, res, version_id=None): def on_get(self, req, res, version_id=None):
validate_authorization(req, ['log_api:versions:get'])
result = { result = {
'links': _get_common_links(req), 'links': _get_common_links(req),
'elements': [] 'elements': []

View File

@ -29,7 +29,11 @@ role_m_opts = [
cfg.ListOpt(name='delegate_roles', cfg.ListOpt(name='delegate_roles',
default=['admin'], default=['admin'],
help=('Roles that are allowed to POST logs on ' 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') role_m_group = cfg.OptGroup(name='roles_middleware', title='roles_middleware')

View File

@ -15,6 +15,7 @@
import sys import sys
from oslo_log import log from oslo_log import log
from oslo_policy import opts as policy_opts
from monasca_log_api import conf from monasca_log_api import conf
from monasca_log_api import version from monasca_log_api import version
@ -56,5 +57,6 @@ def parse_args(argv=None):
version=version.version_str) version=version.version_str)
conf.register_opts() conf.register_opts()
policy_opts.set_defaults(CONF)
_CONF_LOADED = True _CONF_LOADED = True

View File

@ -98,47 +98,38 @@ class RoleMiddleware(om.ConfigurableMiddleware):
return None return None
is_authenticated = self._is_authenticated(req) 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') tenant_id = req.headers.get('X-Tenant-Id')
req.environ[_X_MONASCA_LOG_AGENT] = is_agent req.environ[_X_MONASCA_LOG_AGENT] = is_agent
LOG.debug('%s is authenticated=%s, authorized=%s, log_agent=%s', LOG.debug('%s is authenticated=%s, log_agent=%s',
tenant_id, is_authenticated, is_authorized, is_agent) tenant_id, is_authenticated, is_agent)
if is_authenticated and is_authorized: if is_authenticated:
LOG.debug('%s has been authenticated and authorized', tenant_id) LOG.debug('%s has been authenticated', tenant_id)
return # do return nothing to enter API internal return # do return nothing to enter API internal
# whoops explanation = u'Failed to authenticate request for %s' % tenant_id
if is_authorized: LOG.error(explanation)
explanation = u'Failed to authenticate request for %s' % tenant_id json_body = {u'title': u'Unauthorized', u'message': explanation}
else: return response.Response(status=401,
explanation = (u'Tenant %s is missing a required role to access ' json_body=json_body,
u'this service' % tenant_id) content_type='application/json')
if explanation is not None: def _is_agent(self, req):
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):
headers = req.headers headers = req.headers
roles = headers.get(_X_ROLES) roles = headers.get(_X_ROLES)
if not roles: if not roles:
LOG.warning('Couldn\'t locate %s header,or it was empty', _X_ROLES) LOG.warning('Couldn\'t locate %s header,or it was empty', _X_ROLES)
return False, False return False
else: else:
roles = _ensure_lower_roles(roles.split(',')) roles = _ensure_lower_roles(roles.split(','))
is_agent = len(_intersect(roles, self._agent_roles)) > 0 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): def _is_authenticated(self, req):
headers = req.headers headers = req.headers

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,7 @@
# coding=utf-8 # coding=utf-8
# Copyright 2015 kornicameister@gmail.com # Copyright 2015 kornicameister@gmail.com
# Copyright 2015-2017 FUJITSU LIMITED # Copyright 2015-2017 FUJITSU LIMITED
# Copyright 2018 OP5 AB
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # 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 # not use this file except in compliance with the License. You may obtain
@ -15,6 +16,7 @@
# under the License. # under the License.
import codecs import codecs
import os
import random import random
import string import string
@ -22,14 +24,19 @@ import falcon
from falcon import testing from falcon import testing
import fixtures import fixtures
import mock import mock
from monasca_common.policy import policy_engine as policy
from oslo_config import fixture as oo_cfg from oslo_config import fixture as oo_cfg
from oslo_context import fixture as oo_ctx from oslo_context import fixture as oo_ctx
from oslo_serialization import jsonutils
from oslotest import base as oslotest_base from oslotest import base as oslotest_base
import six import six
from monasca_log_api.app.base import request from monasca_log_api.app.base import request
from monasca_log_api import conf from monasca_log_api import conf
from monasca_log_api import config from monasca_log_api import config
from monasca_log_api import policies
policy.POLICIES = policies
class MockedAPI(falcon.API): 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') 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): class BaseTestCase(oslotest_base.BaseTestCase):
def setUp(self): def setUp(self):
@ -156,6 +197,7 @@ class BaseTestCase(oslotest_base.BaseTestCase):
self.useFixture(ConfigFixture()) self.useFixture(ConfigFixture())
self.useFixture(DisableStatsdFixture()) self.useFixture(DisableStatsdFixture())
self.useFixture(oo_ctx.ClearRequestContext()) self.useFixture(oo_ctx.ClearRequestContext())
self.useFixture(PolicyFixture())
@staticmethod @staticmethod
def conf_override(**kw): def conf_override(**kw):

View File

@ -51,7 +51,7 @@ class TestApiLogs(base.BaseApiTestCase):
'/log/single', '/log/single',
method='POST', method='POST',
headers={ headers={
headers.X_ROLES.name: 'some_role', headers.X_ROLES.name: ROLES,
headers.X_DIMENSIONS.name: 'a:1', headers.X_DIMENSIONS.name: 'a:1',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': '0' 'Content-Length': '0'
@ -75,7 +75,7 @@ class TestApiLogs(base.BaseApiTestCase):
'Content-Length': '0' '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.controller.v2.aid.service.LogCreator')
@mock.patch('monasca_log_api.app.base.log_publisher.LogPublisher') @mock.patch('monasca_log_api.app.base.log_publisher.LogPublisher')
@ -90,7 +90,7 @@ class TestApiLogs(base.BaseApiTestCase):
'/log/single', '/log/single',
method='POST', method='POST',
headers={ headers={
headers.X_ROLES.name: 'some_role', headers.X_ROLES.name: ROLES,
headers.X_DIMENSIONS.name: 'a:1', headers.X_DIMENSIONS.name: 'a:1',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': '0' 'Content-Length': '0'

View File

@ -237,11 +237,12 @@ class TestApiLogs(base.BaseApiTestCase):
method='POST', method='POST',
query_string='tenant_id=1', query_string='tenant_id=1',
headers={ headers={
headers.X_ROLES.name: ROLES,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': '0' '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.' @mock.patch('monasca_log_api.app.controller.v3.aid.bulk_processor.'
'BulkProcessor') 'BulkProcessor')
@ -257,7 +258,7 @@ class TestApiLogs(base.BaseApiTestCase):
'/logs', '/logs',
method='POST', method='POST',
headers={ headers={
headers.X_ROLES.name: 'some_role', headers.X_ROLES.name: ROLES,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': str(content_length) 'Content-Length': str(content_length)
}, },

View File

@ -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))

View File

@ -125,23 +125,7 @@ class RolesMiddlewareSideLogicTest(base.BaseTestCase):
self.assertFalse(instance._is_authenticated(req)) self.assertFalse(instance._is_authenticated(req))
def test_should_return_true_if_authorized_no_agent(self): def test_should_return_true_if_is_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):
roles = 'cmm-admin,cmm-user' roles = 'cmm-admin,cmm-user'
roles_array = roles.split(',') roles_array = roles.split(',')
@ -155,48 +139,9 @@ class RolesMiddlewareSideLogicTest(base.BaseTestCase):
req = mock.Mock() req = mock.Mock()
req.headers = {rm._X_ROLES: roles} 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_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): class RolesMiddlewareLogicTest(base.BaseTestCase):
@ -215,7 +160,7 @@ class RolesMiddlewareLogicTest(base.BaseTestCase):
# spying # spying
instance._is_authenticated = mock.Mock() instance._is_authenticated = mock.Mock()
instance._is_authorized = mock.Mock() instance._is_agent = mock.Mock()
req = mock.Mock() req = mock.Mock()
req.headers = {rm._X_ROLES: roles} req.headers = {rm._X_ROLES: roles}
@ -224,7 +169,7 @@ class RolesMiddlewareLogicTest(base.BaseTestCase):
instance.process_request(req=req) instance.process_request(req=req)
self.assertFalse(instance._is_authenticated.called) 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): def test_not_process_further_if_cannot_apply_method(self):
roles = 'cmm-admin,cmm-user' roles = 'cmm-admin,cmm-user'
@ -240,7 +185,7 @@ class RolesMiddlewareLogicTest(base.BaseTestCase):
# spying # spying
instance._is_authenticated = mock.Mock() instance._is_authenticated = mock.Mock()
instance._is_authorized = mock.Mock() instance._is_agent = mock.Mock()
req = mock.Mock() req = mock.Mock()
req.headers = {rm._X_ROLES: roles} req.headers = {rm._X_ROLES: roles}
@ -250,65 +195,16 @@ class RolesMiddlewareLogicTest(base.BaseTestCase):
instance.process_request(req=req) instance.process_request(req=req)
self.assertFalse(instance._is_authenticated.called) 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): def test_should_produce_json_response_if_not_authenticated(
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(
self): self):
instance = rm.RoleMiddleware(None) 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_agent = True
is_authenticated = False is_authenticated = False
instance._can_apply_middleware = mock.Mock(return_value=True) instance._can_apply_middleware = mock.Mock(return_value=True)
instance._is_authorized = mock.Mock(return_value=(is_authorized, instance._is_agent = mock.Mock(return_value=is_agent)
is_agent))
instance._is_authenticated = mock.Mock(return_value=is_authenticated) instance._is_authenticated = mock.Mock(return_value=is_authenticated)
req = mock.Mock() req = mock.Mock()

View File

@ -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.

View File

@ -41,6 +41,9 @@ wsgi_scripts =
oslo.config.opts = oslo.config.opts =
monasca_log_api = monasca_log_api.conf:list_opts monasca_log_api = monasca_log_api.conf:list_opts
oslo.policy.policies =
monasca_log_api = monasca_log_api.policies:list_rules
[build_sphinx] [build_sphinx]
all_files = 1 all_files = 1
build-dir = doc/build build-dir = doc/build

View File

@ -89,6 +89,11 @@ basepython = python3
description = Generates sample documentation file for monasca-log-api description = Generates sample documentation file for monasca-log-api
commands = oslo-config-generator --config-file=config-generator/monasca-log-api.conf 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] [testenv:docs]
basepython = python3 basepython = python3
description = Builds api-ref, api-guide, releasenotes and devdocs description = Builds api-ref, api-guide, releasenotes and devdocs