diff --git a/.gitignore b/.gitignore index 962feb2b..ea1338c7 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ releasenotes/build # PyCharm IDE .idea/ + +# policy sample generation +etc/masakari/policy.yaml.sample + diff --git a/HACKING.rst b/HACKING.rst index 369e3609..b507140e 100644 --- a/HACKING.rst +++ b/HACKING.rst @@ -43,3 +43,5 @@ Masakari Specific Commandments - [M329] Deprecated library function os.popen() - [M331] LOG.warn is deprecated. Enforce use of LOG.warning. - [M332] Yield must always be followed by a space when yielding a value. +- [M333] Policy registration should be in the central location ``masakari/policies/`` +- [M334] Do not use the oslo_policy.policy.Enforcer.enforce() method. diff --git a/doc/source/_static/masakari.policy.json.sample b/doc/source/_static/masakari.policy.json.sample deleted file mode 100644 index 3ee64373..00000000 --- a/doc/source/_static/masakari.policy.json.sample +++ /dev/null @@ -1,10 +0,0 @@ -{ - "admin_api": "is_admin:True", - "context_is_admin": "role:admin", - "admin_or_owner": "is_admin:True or project_id:%(project_id)s", - "default": "rule:admin_api", - "os_masakari_api:extensions": "rule:admin_api", - "os_masakari_api:segments": "rule:admin_api", - "os_masakari_api:os-hosts": "rule:admin_api", - "os_masakari_api:notifications": "rule:admin_api" -} diff --git a/doc/source/_static/masakari.policy.yaml.sample b/doc/source/_static/masakari.policy.yaml.sample new file mode 100644 index 00000000..d93e7793 --- /dev/null +++ b/doc/source/_static/masakari.policy.yaml.sample @@ -0,0 +1,90 @@ +# Decides what is required for the 'is_admin:True' check to succeed. +#"context_is_admin": "role:admin" + +# Default rule for most non-Admin APIs. +#"admin_or_owner": "is_admin:True or project_id:%(project_id)s" + +# Default rule for most Admin APIs. +#"admin_api": "is_admin:True" + +# List available extensions. +# GET /extensions +#"os_masakari_api:extensions:index": "rule:admin_api" + +# Shows information for an extension. +# GET /extensions/{extensions_id} +#"os_masakari_api:extensions:detail": "rule:admin_api" + +# Extension Info API extensions to change the API. +#"os_masakari_api:extensions:discoverable": "rule:admin_api" + +# Lists IDs, names, type, reserved, on_maintenance for all hosts. +# GET /segments/{segment_id}/hosts +#"os_masakari_api:os-hosts:index": "rule:admin_api" + +# Shows details for a host. +# GET /segments/{segment_id}/hosts/{host_id} +#"os_masakari_api:os-hosts:detail": "rule:admin_api" + +# Creates a host under given segment. +# POST /segments/{segment_id}/hosts +#"os_masakari_api:os-hosts:create": "rule:admin_api" + +# Updates the editable attributes of an existing host. +# PUT /segments/{segment_id}/hosts/{host_id} +#"os_masakari_api:os-hosts:update": "rule:admin_api" + +# Deletes a host from given segment. +# DELETE /segments/{segment_id}/hosts/{host_id} +#"os_masakari_api:os-hosts:delete": "rule:admin_api" + +# Host API extensions to change the API. +#"os_masakari_api:os-hosts:discoverable": "rule:admin_api" + +# Lists IDs, notification types, host_name, generated_time, payload +# and status for all notifications. +# GET /notifications +#"os_masakari_api:notifications:index": "rule:admin_api" + +# Shows details for a notification. +# GET /notifications/{notification_id} +#"os_masakari_api:notifications:detail": "rule:admin_api" + +# Creates a notiification. +# POST /notifications +#"os_masakari_api:notifications:create": "rule:admin_api" + +# Notification API extensions to change the API. +#"os_masakari_api:notifications:discoverable": "rule:admin_api" + +# Lists IDs, names, description, recovery_method, service_type for all +# segments. +# GET /segments +#"os_masakari_api:segments:index": "rule:admin_api" + +# Shows details for a segment. +# GET /segments/{segment_id} +#"os_masakari_api:segments:detail": "rule:admin_api" + +# Creates a segment. +# POST /segments +#"os_masakari_api:segments:create": "rule:admin_api" + +# Updates the editable attributes of an existing host. +# PUT /segments/{segment_id} +#"os_masakari_api:segments:update": "rule:admin_api" + +# Deletes a segment. +# DELETE /segments/{segment_id} +#"os_masakari_api:segments:delete": "rule:admin_api" + +# Segment API extensions to change the API. +#"os_masakari_api:segments:discoverable": "rule:admin_api" + +# List all versions. +# GET / +#"os_masakari_api:versions:index": "@" + +# Version API extensions to change the API. +#"os_masakari_api:versions:discoverable": "@" + diff --git a/doc/source/sample_policy.rst b/doc/source/sample_policy.rst index dd409a79..2169088d 100644 --- a/doc/source/sample_policy.rst +++ b/doc/source/sample_policy.rst @@ -6,4 +6,4 @@ The following is a sample masakari policy file. Operator can configure policies as per his requirement. It is recommended that all api's of masakari should be allowed to admin user only. -.. literalinclude:: _static/masakari.policy.json.sample +.. literalinclude:: _static/masakari.policy.yaml.sample diff --git a/etc/masakari/masakari-policy-generator.conf b/etc/masakari/masakari-policy-generator.conf new file mode 100644 index 00000000..b191d50a --- /dev/null +++ b/etc/masakari/masakari-policy-generator.conf @@ -0,0 +1,3 @@ +[DEFAULT] +output_file = etc/masakari/policy.yaml.sample +namespace = masakari diff --git a/etc/masakari/policy.json b/etc/masakari/policy.json deleted file mode 100644 index 3ee64373..00000000 --- a/etc/masakari/policy.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "admin_api": "is_admin:True", - "context_is_admin": "role:admin", - "admin_or_owner": "is_admin:True or project_id:%(project_id)s", - "default": "rule:admin_api", - "os_masakari_api:extensions": "rule:admin_api", - "os_masakari_api:segments": "rule:admin_api", - "os_masakari_api:os-hosts": "rule:admin_api", - "os_masakari_api:notifications": "rule:admin_api" -} diff --git a/masakari/api/openstack/extensions.py b/masakari/api/openstack/extensions.py index 57ea6b16..f891f9a7 100644 --- a/masakari/api/openstack/extensions.py +++ b/masakari/api/openstack/extensions.py @@ -22,11 +22,9 @@ import six import webob.dec import webob.exc -import masakari.api.openstack from masakari.api.openstack import wsgi from masakari import exception from masakari.i18n import _ -import masakari.policy LOG = logging.getLogger(__name__) @@ -316,45 +314,6 @@ def load_standard_extensions(ext_mgr, logger, path, package, ext_list=None): dirnames[:] = subdirs -# This will be deprecated after policy cleanup finished -def core_authorizer(api_name, extension_name): - def authorize(context, target=None, action=None): - if target is None: - target = {'project_id': context.project_id, - 'user_id': context.user_id} - if action is None: - act = '%s:%s' % (api_name, extension_name) - else: - act = '%s:%s:%s' % (api_name, extension_name, action) - masakari.policy.enforce(context, act, target) - return authorize - - -def _soft_authorizer(hard_authorizer, api_name, extension_name): - hard_authorize = hard_authorizer(api_name, extension_name) - - def authorize(context, target=None, action=None): - try: - hard_authorize(context, target=target, action=action) - return True - except exception.Forbidden: - return False - return authorize - - -# This will be deprecated after policy cleanup finished -def soft_core_authorizer(api_name, extension_name): - return _soft_authorizer(core_authorizer, api_name, extension_name) - - -def os_masakari_authorizer(extension_name): - return core_authorizer('os_masakari_api', extension_name) - - -def os_masakari_soft_authorizer(extension_name): - return soft_core_authorizer('os_masakari_api', extension_name) - - @six.add_metaclass(abc.ABCMeta) class V1APIExtensionBase(object): """Abstract base class for all v1 API extensions. diff --git a/masakari/api/openstack/ha/extension_info.py b/masakari/api/openstack/ha/extension_info.py index c870ce7a..8126f87e 100644 --- a/masakari/api/openstack/ha/extension_info.py +++ b/masakari/api/openstack/ha/extension_info.py @@ -19,10 +19,11 @@ import webob.exc from masakari.api.openstack import extensions from masakari.api.openstack import wsgi from masakari import exception +from masakari.policies import base as base_policies +from masakari.policies import extension_info as extension_policies ALIAS = 'extensions' LOG = logging.getLogger(__name__) -authorize = extensions.os_masakari_authorizer(ALIAS) class FakeExtension(object): @@ -53,10 +54,10 @@ class ExtensionInfoController(wsgi.Controller): """Filter extensions list based on policy.""" discoverable_extensions = dict() - for alias, ext in self.extension_info.get_extensions().items(): - authorize = extensions.os_masakari_soft_authorizer(alias) - if authorize(context, action='discoverable'): + action = ':'.join([ + base_policies.MASAKARI_API, alias, 'discoverable']) + if context.can(action, fatal=False): discoverable_extensions[alias] = ext else: LOG.debug("Filter out extension %s from discover list", @@ -67,7 +68,7 @@ class ExtensionInfoController(wsgi.Controller): @extensions.expected_errors(()) def index(self, req): context = req.environ['masakari.context'] - authorize(context) + context.can(extension_policies.EXTENSIONS % 'index') discoverable_extensions = self._get_extensions(context) sorted_ext_list = sorted(discoverable_extensions.items()) @@ -80,7 +81,7 @@ class ExtensionInfoController(wsgi.Controller): @extensions.expected_errors(http_client.NOT_FOUND) def show(self, req, id): context = req.environ['masakari.context'] - authorize(context) + context.can(extension_policies.EXTENSIONS % 'detail') try: ext = self._get_extensions(context)[id] except KeyError: diff --git a/masakari/api/openstack/ha/hosts.py b/masakari/api/openstack/ha/hosts.py index c6778460..746cf990 100644 --- a/masakari/api/openstack/ha/hosts.py +++ b/masakari/api/openstack/ha/hosts.py @@ -29,9 +29,9 @@ from masakari import exception from masakari.ha import api as host_api from masakari.i18n import _ from masakari import objects +from masakari.policies import hosts as host_policies ALIAS = "os-hosts" -authorize = extensions.os_masakari_authorizer(ALIAS) class HostsController(wsgi.Controller): @@ -45,7 +45,7 @@ class HostsController(wsgi.Controller): def index(self, req, segment_id): """Returns a list a hosts.""" context = req.environ['masakari.context'] - authorize(context) + context.can(host_policies.HOSTS % 'index') try: filters = {} @@ -103,7 +103,7 @@ class HostsController(wsgi.Controller): def create(self, req, segment_id, body): """Creates a host.""" context = req.environ['masakari.context'] - authorize(context) + context.can(host_policies.HOSTS % 'create') host_data = body.get('host') try: host = self.api.create_host(context, segment_id, host_data) @@ -118,7 +118,7 @@ class HostsController(wsgi.Controller): def show(self, req, segment_id, id): """Shows the details of a host.""" context = req.environ['masakari.context'] - authorize(context) + context.can(host_policies.HOSTS % 'detail') try: host = self.api.get_host(context, segment_id, id) except exception.HostNotFound as e: @@ -133,7 +133,7 @@ class HostsController(wsgi.Controller): def update(self, req, segment_id, id, body): """Updates the existing host.""" context = req.environ['masakari.context'] - authorize(context) + context.can(host_policies.HOSTS % 'update') host_data = body.get('host') try: host = self.api.update_host(context, segment_id, id, host_data) @@ -152,7 +152,7 @@ class HostsController(wsgi.Controller): def delete(self, req, segment_id, id): """Removes a host by id.""" context = req.environ['masakari.context'] - authorize(context) + context.can(host_policies.HOSTS % 'delete') try: self.api.delete_host(context, segment_id, id) except exception.FailoverSegmentNotFound as e: diff --git a/masakari/api/openstack/ha/notifications.py b/masakari/api/openstack/ha/notifications.py index 5609e0d1..c8ab762e 100644 --- a/masakari/api/openstack/ha/notifications.py +++ b/masakari/api/openstack/ha/notifications.py @@ -25,9 +25,9 @@ from masakari.api import validation from masakari import exception from masakari.ha import api as notification_api from masakari.i18n import _ +from masakari.policies import notifications as notifications_policies ALIAS = 'notifications' -authorize = extensions.os_masakari_authorizer(ALIAS) class NotificationsController(wsgi.Controller): @@ -43,7 +43,7 @@ class NotificationsController(wsgi.Controller): def create(self, req, body): """Creates a new notification.""" context = req.environ['masakari.context'] - authorize(context) + context.can(notifications_policies.NOTIFICATIONS % 'create') notification_data = body['notification'] try: @@ -61,7 +61,7 @@ class NotificationsController(wsgi.Controller): def index(self, req): """Returns a summary list of notifications.""" context = req.environ['masakari.context'] - authorize(context) + context.can(notifications_policies.NOTIFICATIONS % 'index') try: limit, marker = common.get_limit_and_marker(req) sort_keys, sort_dirs = common.get_sort_params(req.params) @@ -95,7 +95,7 @@ class NotificationsController(wsgi.Controller): def show(self, req, id): """Return data about the given notification id.""" context = req.environ['masakari.context'] - authorize(context) + context.can(notifications_policies.NOTIFICATIONS % 'detail') try: notification = self.api.get_notification(context, id) diff --git a/masakari/api/openstack/ha/segments.py b/masakari/api/openstack/ha/segments.py index ddf8395e..014109f3 100644 --- a/masakari/api/openstack/ha/segments.py +++ b/masakari/api/openstack/ha/segments.py @@ -23,10 +23,10 @@ from masakari.api.openstack import wsgi from masakari.api import validation from masakari import exception from masakari.ha import api as segment_api +from masakari.policies import segments as segment_policies ALIAS = 'segments' -authorize = extensions.os_masakari_authorizer(ALIAS) class SegmentsController(wsgi.Controller): @@ -39,7 +39,7 @@ class SegmentsController(wsgi.Controller): def index(self, req): """Returns a summary list of failover segments.""" context = req.environ['masakari.context'] - authorize(context) + context.can(segment_policies.SEGMENTS % 'index') try: limit, marker = common.get_limit_and_marker(req) @@ -66,7 +66,7 @@ class SegmentsController(wsgi.Controller): def show(self, req, id): """Return data about the given segment id.""" context = req.environ['masakari.context'] - authorize(context) + context.can(segment_policies.SEGMENTS % 'detail') try: segment = self.api.get_segment(context, id) @@ -80,7 +80,7 @@ class SegmentsController(wsgi.Controller): def create(self, req, body): """Creates a new failover segment.""" context = req.environ['masakari.context'] - authorize(context) + context.can(segment_policies.SEGMENTS % 'create') segment_data = body['segment'] try: @@ -95,7 +95,7 @@ class SegmentsController(wsgi.Controller): def update(self, req, id, body): """Updates the existing segment.""" context = req.environ['masakari.context'] - authorize(context) + context.can(segment_policies.SEGMENTS % 'update') segment_data = body['segment'] try: @@ -113,7 +113,7 @@ class SegmentsController(wsgi.Controller): def delete(self, req, id): """Removes a segment by uuid.""" context = req.environ['masakari.context'] - authorize(context) + context.can(segment_policies.SEGMENTS % 'delete') try: self.api.delete_segment(context, id) diff --git a/masakari/context.py b/masakari/context.py index 499cab98..ac33702f 100644 --- a/masakari/context.py +++ b/masakari/context.py @@ -27,6 +27,7 @@ from oslo_log import log as logging from oslo_utils import timeutils import six +from masakari import exception from masakari.i18n import _ from masakari import policy from masakari import utils @@ -202,6 +203,39 @@ class RequestContext(context.RequestContext): return context + def can(self, action, target=None, fatal=True): + """Verifies that the given action is valid on the target in this context. + + :param action: string representing the action to be checked. + :param target: dictionary representing the object of the action + for object creation this should be a dictionary representing the + location of the object e.g. ``{'project_id': context.project_id}``. + If None, then this default target will be considered: + {'project_id': self.project_id, 'user_id': self.user_id} + :param fatal: if False, will return False when an exception.Forbidden + occurs. + + :raises masakari.exception.Forbidden: if verification fails and fatal + is True. + + :return: returns a non-False value (not necessarily "True") if + authorized and False if not authorized and fatal is False. + """ + if target is None: + target = {'project_id': self.project_id, + 'user_id': self.user_id} + try: + return policy.authorize(self, action, target) + except exception.Forbidden: + if fatal: + raise + return False + + def to_policy_values(self): + policy = super(RequestContext, self).to_policy_values() + policy['is_admin'] = self.is_admin + return policy + def __str__(self): return "" % self.to_dict() diff --git a/masakari/hacking/checks.py b/masakari/hacking/checks.py index 580cf9f3..b300a3b7 100644 --- a/masakari/hacking/checks.py +++ b/masakari/hacking/checks.py @@ -35,6 +35,8 @@ UNDERSCORE_IMPORT_FILES = [] session_check = re.compile(r"\w*def [a-zA-Z0-9].*[(].*session.*[)]") cfg_re = re.compile(r".*\scfg\.") cfg_opt_re = re.compile(r".*[\s\[]cfg\.[a-zA-Z]*Opt\(") +rule_default_re = re.compile(r".*RuleDefault\(") +policy_enforce_re = re.compile(r".*_ENFORCER\.enforce\(") asse_trueinst_re = re.compile( r"(.)*assertTrue\(isinstance\((\w|\.|\'|\"|\[|\])+, " "(\w|\.|\'|\"|\[|\])+\)\)") @@ -412,6 +414,38 @@ def yield_followed_by_space(logical_line): "M332: Yield keyword should be followed by a space.") +def check_policy_registration_in_central_place(logical_line, filename): + msg = ('M333: Policy registration should be in the central location ' + '"/masakari/policies/*".') + # This is where registration should happen + if "masakari/policies/" in filename: + return + # A couple of policy tests register rules + if "masakari/tests/unit/test_policy.py" in filename: + return + + if rule_default_re.match(logical_line): + yield (0, msg) + + +def check_policy_enforce(logical_line, filename): + """Look for uses of masakari.policy._ENFORCER.enforce() + + Now that policy defaults are registered in code the _ENFORCER.authorize + method should be used. That ensures that only registered policies are used. + Uses of _ENFORCER.enforce could allow unregistered policies to be used, so + this check looks for uses of that method. + + M333 + """ + + msg = ('M334: masakari.policy._ENFORCER.enforce() should not be used. ' + 'Use the authorize() method instead.') + + if policy_enforce_re.match(logical_line): + yield (0, msg) + + def factory(register): register(no_db_session_in_public_api) register(use_timeutils_utcnow) @@ -438,3 +472,5 @@ def factory(register): register(no_os_popen) register(no_log_warn) register(yield_followed_by_space) + register(check_policy_registration_in_central_place) + register(check_policy_enforce) diff --git a/masakari/policies/__init__.py b/masakari/policies/__init__.py new file mode 100644 index 00000000..29d68861 --- /dev/null +++ b/masakari/policies/__init__.py @@ -0,0 +1,35 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import itertools + +from masakari.policies import base +from masakari.policies import extension_info +from masakari.policies import hosts +from masakari.policies import notifications +from masakari.policies import segments +from masakari.policies import versions + + +def list_rules(): + return itertools.chain( + base.list_rules(), + extension_info.list_rules(), + hosts.list_rules(), + notifications.list_rules(), + segments.list_rules(), + versions.list_rules() + ) diff --git a/masakari/policies/base.py b/masakari/policies/base.py new file mode 100644 index 00000000..087da9ac --- /dev/null +++ b/masakari/policies/base.py @@ -0,0 +1,41 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_policy import policy + +MASAKARI_API = 'os_masakari_api' + +RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' +RULE_ADMIN_API = 'rule:admin_api' +RULE_ANY = '@' + +rules = [ + policy.RuleDefault( + "context_is_admin", + "role:admin", + "Decides what is required for the 'is_admin:True' check to succeed."), + policy.RuleDefault( + "admin_or_owner", + "is_admin:True or project_id:%(project_id)s", + "Default rule for most non-Admin APIs."), + policy.RuleDefault( + "admin_api", + "is_admin:True", + "Default rule for most Admin APIs.") +] + + +def list_rules(): + return rules diff --git a/masakari/policies/extension_info.py b/masakari/policies/extension_info.py new file mode 100644 index 00000000..c3b59b13 --- /dev/null +++ b/masakari/policies/extension_info.py @@ -0,0 +1,53 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_policy import policy + +from masakari.policies import base + +EXTENSIONS = 'os_masakari_api:extensions:%s' + +rules = [ + policy.DocumentedRuleDefault( + name=EXTENSIONS % 'index', + check_str=base.RULE_ADMIN_API, + description="List available extensions.", + operations=[ + { + 'method': 'GET', + 'path': '/extensions' + } + ]), + policy.DocumentedRuleDefault( + name=EXTENSIONS % 'detail', + check_str=base.RULE_ADMIN_API, + description="Shows information for an extension.", + operations=[ + { + 'method': 'GET', + 'path': '/extensions/{extensions_id}' + } + ]), + policy.RuleDefault( + name=EXTENSIONS % 'discoverable', + check_str=base.RULE_ADMIN_API, + description="Extension Info API extensions to change the API.", + ), +] + + +def list_rules(): + return rules diff --git a/masakari/policies/hosts.py b/masakari/policies/hosts.py new file mode 100644 index 00000000..7729286f --- /dev/null +++ b/masakari/policies/hosts.py @@ -0,0 +1,85 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_policy import policy + +from masakari.policies import base + + +HOSTS = 'os_masakari_api:os-hosts:%s' + +rules = [ + policy.DocumentedRuleDefault( + name=HOSTS % 'index', + check_str=base.RULE_ADMIN_API, + description="Lists IDs, names, type, reserved, on_maintenance for all" + " hosts.", + operations=[ + { + 'method': 'GET', + 'path': '/segments/{segment_id}/hosts' + } + ]), + policy.DocumentedRuleDefault( + name=HOSTS % 'detail', + check_str=base.RULE_ADMIN_API, + description="Shows details for a host.", + operations=[ + { + 'method': 'GET', + 'path': '/segments/{segment_id}/hosts/{host_id}' + } + ]), + policy.DocumentedRuleDefault( + name=HOSTS % 'create', + check_str=base.RULE_ADMIN_API, + description="Creates a host under given segment.", + operations=[ + { + 'method': 'POST', + 'path': '/segments/{segment_id}/hosts' + } + ]), + policy.DocumentedRuleDefault( + name=HOSTS % 'update', + check_str=base.RULE_ADMIN_API, + description="Updates the editable attributes of an existing host.", + operations=[ + { + 'method': 'PUT', + 'path': '/segments/{segment_id}/hosts/{host_id}' + } + ]), + policy.DocumentedRuleDefault( + name=HOSTS % 'delete', + check_str=base.RULE_ADMIN_API, + description="Deletes a host from given segment.", + operations=[ + { + 'method': 'DELETE', + 'path': '/segments/{segment_id}/hosts/{host_id}' + } + ]), + policy.RuleDefault( + name=HOSTS % 'discoverable', + check_str=base.RULE_ADMIN_API, + description="Host API extensions to change the API.", + ), +] + + +def list_rules(): + return rules diff --git a/masakari/policies/notifications.py b/masakari/policies/notifications.py new file mode 100644 index 00000000..9b071181 --- /dev/null +++ b/masakari/policies/notifications.py @@ -0,0 +1,65 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_policy import policy + +from masakari.policies import base + + +NOTIFICATIONS = 'os_masakari_api:notifications:%s' + +rules = [ + policy.DocumentedRuleDefault( + name=NOTIFICATIONS % 'index', + check_str=base.RULE_ADMIN_API, + description="Lists IDs, notification types, host_name, generated_time," + " payload and status for all notifications.", + operations=[ + { + 'method': 'GET', + 'path': '/notifications' + } + ]), + policy.DocumentedRuleDefault( + name=NOTIFICATIONS % 'detail', + check_str=base.RULE_ADMIN_API, + description="Shows details for a notification.", + operations=[ + { + 'method': 'GET', + 'path': '/notifications/{notification_id}' + } + ]), + policy.DocumentedRuleDefault( + name=NOTIFICATIONS % 'create', + check_str=base.RULE_ADMIN_API, + description="Creates a notiification.", + operations=[ + { + 'method': 'POST', + 'path': '/notifications' + } + ]), + policy.RuleDefault( + name=NOTIFICATIONS % 'discoverable', + check_str=base.RULE_ADMIN_API, + description="Notification API extensions to change the API.", + ), +] + + +def list_rules(): + return rules diff --git a/masakari/policies/segments.py b/masakari/policies/segments.py new file mode 100644 index 00000000..a91da634 --- /dev/null +++ b/masakari/policies/segments.py @@ -0,0 +1,85 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_policy import policy + +from masakari.policies import base + + +SEGMENTS = 'os_masakari_api:segments:%s' + +rules = [ + policy.DocumentedRuleDefault( + name=SEGMENTS % 'index', + check_str=base.RULE_ADMIN_API, + description="Lists IDs, names, description, recovery_method, " + "service_type for all segments.", + operations=[ + { + 'method': 'GET', + 'path': '/segments' + } + ]), + policy.DocumentedRuleDefault( + name=SEGMENTS % 'detail', + check_str=base.RULE_ADMIN_API, + description="Shows details for a segment.", + operations=[ + { + 'method': 'GET', + 'path': '/segments/{segment_id}' + } + ]), + policy.DocumentedRuleDefault( + name=SEGMENTS % 'create', + check_str=base.RULE_ADMIN_API, + description="Creates a segment.", + operations=[ + { + 'method': 'POST', + 'path': '/segments' + } + ]), + policy.DocumentedRuleDefault( + name=SEGMENTS % 'update', + check_str=base.RULE_ADMIN_API, + description="Updates the editable attributes of an existing host.", + operations=[ + { + 'method': 'PUT', + 'path': '/segments/{segment_id}' + } + ]), + policy.DocumentedRuleDefault( + name=SEGMENTS % 'delete', + check_str=base.RULE_ADMIN_API, + description="Deletes a segment.", + operations=[ + { + 'method': 'DELETE', + 'path': '/segments/{segment_id}' + } + ]), + policy.RuleDefault( + name=SEGMENTS % 'discoverable', + check_str=base.RULE_ADMIN_API, + description="Segment API extensions to change the API.", + ), +] + + +def list_rules(): + return rules diff --git a/masakari/policies/versions.py b/masakari/policies/versions.py new file mode 100644 index 00000000..63440fae --- /dev/null +++ b/masakari/policies/versions.py @@ -0,0 +1,44 @@ +# Copyright (C) 2018 NTT DATA +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from oslo_policy import policy + +from masakari.policies import base + + +VERSIONS = 'os_masakari_api:versions:%s' + +rules = [ + policy.DocumentedRuleDefault( + name=VERSIONS % 'index', + check_str=base.RULE_ANY, + description="List all versions.", + operations=[ + { + 'method': 'GET', + 'path': '/' + } + ]), + policy.RuleDefault( + name=VERSIONS % 'discoverable', + check_str=base.RULE_ANY, + description="Version API extensions to change the API.", + ), +] + + +def list_rules(): + return rules diff --git a/masakari/policy.py b/masakari/policy.py index d2a8254c..0b9d485d 100644 --- a/masakari/policy.py +++ b/masakari/policy.py @@ -15,18 +15,27 @@ """Policy Engine For Masakari.""" +import copy import logging +import re +import sys from oslo_config import cfg from oslo_policy import policy from oslo_utils import excutils from masakari import exception +# from masakari.i18n import _LE, _LW +from masakari import policies CONF = cfg.CONF LOG = logging.getLogger(__name__) _ENFORCER = None +# saved_file_rules and used to compare with new rules to determine the +# rules whether were updated. +saved_file_rules = [] +KEY_EXPR = re.compile(r'%\((\w+)\)s') def reset(): @@ -48,12 +57,44 @@ def init(policy_file=None, rules=None, default_rule=None, use_conf=True): :param use_conf: Whether to load rules from config file. """ global _ENFORCER + global saved_file_rules if not _ENFORCER: _ENFORCER = policy.Enforcer(CONF, policy_file=policy_file, rules=rules, default_rule=default_rule, use_conf=use_conf) + register_rules(_ENFORCER) + _ENFORCER.load_rules() + + # Only the rules which are loaded from file may be changed. + current_file_rules = _ENFORCER.file_rules + current_file_rules = _serialize_rules(current_file_rules) + + # Checks whether the rules are updated in the runtime + if saved_file_rules != current_file_rules: + _warning_for_deprecated_user_based_rules(current_file_rules) + saved_file_rules = copy.deepcopy(current_file_rules) + + +def _serialize_rules(rules): + """Serialize all the Rule object as string which is used to compare the + rules list. + """ + result = [(rule_name, str(rule)) + for rule_name, rule in rules.items()] + return sorted(result, key=lambda rule: rule[0]) + + +def _warning_for_deprecated_user_based_rules(rules): + """Warning user based policy enforcement used in the rule but the rule + doesn't support it. + """ + for rule in rules: + if 'user_id' in KEY_EXPR.findall(rule[1]): + LOG.debug(("The user_id attribute isn't supported in the rule%s'. " + "All the user_id based policy enforcement will be " + "removed in the future."), rule[0]) def set_rules(rules, overwrite=True, use_conf=False): @@ -69,34 +110,46 @@ def set_rules(rules, overwrite=True, use_conf=False): _ENFORCER.set_rules(rules, overwrite, use_conf) -def enforce(context, action, target, do_raise=True, exc=None): +def authorize(context, action, target, do_raise=True, exc=None): """Verifies that the action is valid on the target in this context. :param context: masakari context :param action: string representing the action to be checked this should be colon separated for clarity. + i.e. ``os_masakari_api:segments``, + ``os_masakari_api:os-hosts``, + ``os_masakari_api:notifications``, + ``os_masakari_api:extensions`` :param target: dictionary representing the object of the action for object creation this should be a dictionary representing the location of the object e.g. ``{'project_id': context.project_id}`` :param do_raise: if True (the default), raises PolicyNotAuthorized; if False, returns False + :param exc: Class of the exception to raise if the check fails. + Any remaining arguments passed to :meth:`authorize` (both + positional and keyword arguments) will be passed to + the exception class. If not specified, + :class:`PolicyNotAuthorized` will be used. :raises masakari.exception.PolicyNotAuthorized: if verification fails - and do_raise is True. + and do_raise is True. Or if 'exc' is specified it will raise an + exception of that type. :return: returns a non-False value (not necessarily "True") if authorized, and the exact value False if not authorized and do_raise is False. """ init() - credentials = context.to_dict() + credentials = context.to_policy_values() if not exc: exc = exception.PolicyNotAuthorized try: - result = _ENFORCER.enforce(action, target, credentials, - do_raise=do_raise, exc=exc, action=action) + result = _ENFORCER.authorize(action, target, credentials, + do_raise=do_raise, exc=exc, action=action) + except policy.PolicyNotRegistered: + with excutils.save_and_reraise_exception(): + LOG.debug('Policy not registered') except Exception: - credentials.pop('auth_token', None) with excutils.save_and_reraise_exception(): LOG.debug('Policy check for %(action)s failed with credentials ' '%(credentials)s', @@ -111,9 +164,9 @@ def check_is_admin(context): init() # the target is user-self - credentials = context.to_dict() + credentials = context.to_policy_values() target = credentials - return _ENFORCER.enforce('context_is_admin', target, credentials) + return _ENFORCER.authorize('context_is_admin', target, credentials) @policy.register('is_admin') @@ -136,3 +189,27 @@ class IsAdminCheck(policy.Check): def get_rules(): if _ENFORCER: return _ENFORCER.rules + + +def register_rules(enforcer): + enforcer.register_defaults(policies.list_rules()) + + +def get_enforcer(): + # This method is for use by oslopolicy CLI scripts. Those scripts need the + # 'output-file' and 'namespace' options, but having those in sys.argv means + # loading the Masakari config options will fail as those are not expected + # to be present. So we pass in an arg list with those stripped out. + conf_args = [] + # Start at 1 because cfg.CONF expects the equivalent of sys.argv[1:] + i = 1 + while i < len(sys.argv): + if sys.argv[i].strip('-') in ['namespace', 'output-file']: + i += 2 + continue + conf_args.append(sys.argv[i]) + i += 1 + + cfg.CONF(conf_args, project='masakari') + init() + return _ENFORCER diff --git a/masakari/tests/unit/api/openstack/ha/test_extension_info.py b/masakari/tests/unit/api/openstack/ha/test_extension_info.py index 74daa5a8..6bd00488 100644 --- a/masakari/tests/unit/api/openstack/ha/test_extension_info.py +++ b/masakari/tests/unit/api/openstack/ha/test_extension_info.py @@ -64,14 +64,14 @@ class ExtensionInfoTest(test.NoDBTestCase): self.assertEqual(e['links'], []) self.assertEqual(6, len(e)) - @mock.patch.object(policy, 'enforce', mock.Mock(return_value=True)) + @mock.patch.object(policy, 'authorize', mock.Mock(return_value=True)) def test_extension_info_list(self): req = fakes.HTTPRequest.blank('/extensions') res_dict = self.controller.index(req) self.assertGreaterEqual(len(res_dict['extensions']), 3) self._filter_extensions(res_dict) - @mock.patch.object(policy, 'enforce', mock.Mock(return_value=True)) + @mock.patch.object(policy, 'authorize', mock.Mock(return_value=True)) def test_extension_info_show(self): req = fakes.HTTPRequest.blank('/extensions/ext1-alias') res_dict = self.controller.show(req, 'ext1-alias') @@ -86,7 +86,7 @@ class ExtensionInfoTest(test.NoDBTestCase): self.assertEqual(res_dict['extension']['links'], []) self.assertEqual(6, len(res_dict['extension'])) - @mock.patch.object(policy, 'enforce') + @mock.patch.object(policy, 'authorize') def test_extension_info_list_not_all_discoverable(self, mock_authorize): mock_authorize.side_effect = fake_policy_authorize_selective req = fakes.HTTPRequest.blank('/extensions') diff --git a/masakari/tests/unit/api/openstack/ha/test_hosts.py b/masakari/tests/unit/api/openstack/ha/test_hosts.py index 82d6af2a..eb0553e1 100644 --- a/masakari/tests/unit/api/openstack/ha/test_hosts.py +++ b/masakari/tests/unit/api/openstack/ha/test_hosts.py @@ -480,25 +480,27 @@ class HostTestCasePolicyNotAuthorized(test.NoDBTestCase): self.req = fakes.HTTPRequest.blank( '/v1/segments/%s/hosts' % uuidsentinel.fake_segment1) self.context = self.req.environ['masakari.context'] - self.rule_name = "os_masakari_api:os-hosts" - self.policy.set_rules({self.rule_name: "project:non_fake"}) def setUp(self): super(HostTestCasePolicyNotAuthorized, self).setUp() self._set_up() - def _check_rule(self, exc): + def _check_rule(self, exc, rule_name): self.assertEqual( - "Policy doesn't allow %s to be performed." % self.rule_name, + "Policy doesn't allow %s to be performed." % rule_name, exc.format_message()) def test_index_no_admin(self): + rule_name = "os_masakari_api:os-hosts:index" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.index, self.req, uuidsentinel.fake_segment1) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_create_no_admin(self): + rule_name = "os_masakari_api:os-hosts:create" + self.policy.set_rules({rule_name: "project:non_fake"}) body = { "host": { "name": "host-1", "type": "fake", "reserved": False, @@ -510,16 +512,20 @@ class HostTestCasePolicyNotAuthorized(test.NoDBTestCase): self.controller.create, self.req, uuidsentinel.fake_segment1, body=body) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_show_no_admin(self): + rule_name = "os_masakari_api:os-hosts:detail" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.show, self.req, uuidsentinel.fake_segment1, uuidsentinel.fake_host_1) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_update_no_admin(self): + rule_name = "os_masakari_api:os-hosts:update" + self.policy.set_rules({rule_name: "project:non_fake"}) body = { "host": { "name": "host-1", "type": "fake", "reserved": False, @@ -531,11 +537,13 @@ class HostTestCasePolicyNotAuthorized(test.NoDBTestCase): self.controller.update, self.req, uuidsentinel.fake_segment1, uuidsentinel.fake_host_1, body=body) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_delete_no_admin(self): + rule_name = "os_masakari_api:os-hosts:delete" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.delete, self.req, uuidsentinel.fake_segment1, uuidsentinel.fake_host_1) - self._check_rule(exc) + self._check_rule(exc, rule_name) diff --git a/masakari/tests/unit/api/openstack/ha/test_notifications.py b/masakari/tests/unit/api/openstack/ha/test_notifications.py index 263b480a..0c4d0d3d 100644 --- a/masakari/tests/unit/api/openstack/ha/test_notifications.py +++ b/masakari/tests/unit/api/openstack/ha/test_notifications.py @@ -338,15 +338,15 @@ class NotificationCasePolicyNotAuthorized(test.NoDBTestCase): self.controller = notifications.NotificationsController() self.req = fakes.HTTPRequest.blank('/v1/notifications') self.context = self.req.environ['masakari.context'] - self.rule_name = "os_masakari_api:notifications" - self.policy.set_rules({self.rule_name: "project:non_fake"}) - def _check_rule(self, exc): + def _check_rule(self, exc, rule_name): self.assertEqual( - "Policy doesn't allow %s to be performed." % self.rule_name, + "Policy doesn't allow %s to be performed." % rule_name, exc.format_message()) def test_create_no_admin(self): + rule_name = "os_masakari_api:notifications:create" + self.policy.set_rules({rule_name: "project:non_fake"}) body = { "notification": {"hostname": "fake_host", "payload": {"event": "STOPPED", @@ -357,16 +357,20 @@ class NotificationCasePolicyNotAuthorized(test.NoDBTestCase): exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.create, self.req, body=body) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_show_no_admin(self): + rule_name = "os_masakari_api:notifications:detail" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.show, self.req, uuidsentinel.fake_notification) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_index_no_admin(self): + rule_name = "os_masakari_api:notifications:index" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.index, self.req) - self._check_rule(exc) + self._check_rule(exc, rule_name) diff --git a/masakari/tests/unit/api/openstack/ha/test_segments.py b/masakari/tests/unit/api/openstack/ha/test_segments.py index 76fad9f4..6af8acf7 100644 --- a/masakari/tests/unit/api/openstack/ha/test_segments.py +++ b/masakari/tests/unit/api/openstack/ha/test_segments.py @@ -322,21 +322,23 @@ class FailoverSegmentTestCasePolicyNotAuthorized(test.NoDBTestCase): self.controller = segments.SegmentsController() self.req = fakes.HTTPRequest.blank('/v1/segments') self.context = self.req.environ['masakari.context'] - self.rule_name = "os_masakari_api:segments" - self.policy.set_rules({self.rule_name: "project:non_fake"}) - def _check_rule(self, exc): + def _check_rule(self, exc, rule_name): self.assertEqual( - "Policy doesn't allow %s to be performed." % self.rule_name, + "Policy doesn't allow %s to be performed." % rule_name, exc.format_message()) def test_index_no_admin(self): + rule_name = "os_masakari_api:segments:index" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.index, self.req) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_create_no_admin(self): + rule_name = "os_masakari_api:segments:create" + self.policy.set_rules({rule_name: "project:non_fake"}) body = { "segment": { "name": "segment1", @@ -348,15 +350,19 @@ class FailoverSegmentTestCasePolicyNotAuthorized(test.NoDBTestCase): exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.create, self.req, body=body) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_show_no_admin(self): + rule_name = "os_masakari_api:segments:detail" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.show, self.req, uuidsentinel.fake_segment) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_update_no_admin(self): + rule_name = "os_masakari_api:segments:update" + self.policy.set_rules({rule_name: "project:non_fake"}) body = { "segment": { "name": "segment1", @@ -368,10 +374,12 @@ class FailoverSegmentTestCasePolicyNotAuthorized(test.NoDBTestCase): exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.update, self.req, uuidsentinel.fake_segment, body=body) - self._check_rule(exc) + self._check_rule(exc, rule_name) def test_delete_no_admin(self): + rule_name = "os_masakari_api:segments:delete" + self.policy.set_rules({rule_name: "project:non_fake"}) exc = self.assertRaises(exception.PolicyNotAuthorized, self.controller.delete, self.req, uuidsentinel.fake_segment) - self._check_rule(exc) + self._check_rule(exc, rule_name) diff --git a/masakari/tests/unit/fake_policy.py b/masakari/tests/unit/fake_policy.py index 13ccdcf4..6ab218b3 100644 --- a/masakari/tests/unit/fake_policy.py +++ b/masakari/tests/unit/fake_policy.py @@ -17,9 +17,20 @@ policy_data = """ { "context_is_admin": "role:admin or role:administrator", - "os_masakari_api:extensions": "", - "os_masakari_api:segments": "", - "os_masakari_api:os-hosts": "", - "os_masakari_api:notifications": "" + "os_masakari_api:extensions:index": "", + "os_masakari_api:extensions:detail": "", + "os_masakari_api:segments:index": "", + "os_masakari_api:segments:detail": "", + "os_masakari_api:segments:create": "", + "os_masakari_api:segments:update": "", + "os_masakari_api:segments:delete": "", + "os_masakari_api:os-hosts:index": "", + "os_masakari_api:os-hosts:detail": "", + "os_masakari_api:os-hosts:create": "", + "os_masakari_api:os-hosts:update": "", + "os_masakari_api:os-hosts:delete": "", + "os_masakari_api:notifications:index": "", + "os_masakari_api:notifications:detail": "", + "os_masakari_api:notifications:create": "" } """ diff --git a/masakari/tests/unit/policy_fixture.py b/masakari/tests/unit/policy_fixture.py index 3310a7e8..5f8aa21c 100644 --- a/masakari/tests/unit/policy_fixture.py +++ b/masakari/tests/unit/policy_fixture.py @@ -20,6 +20,7 @@ from oslo_serialization import jsonutils import masakari.conf from masakari.conf import paths +from masakari import policies import masakari.policy from masakari.tests.unit import fake_policy @@ -52,9 +53,10 @@ class RealPolicyFixture(fixtures.Fixture): masakari.policy.init() self.addCleanup(masakari.policy.reset) - def set_rules(self, rules): + def set_rules(self, rules, overwrite=True): policy = masakari.policy._ENFORCER - policy.set_rules(oslo_policy.Rules.from_dict(rules)) + policy.set_rules(oslo_policy.Rules.from_dict(rules), + overwrite=overwrite) class PolicyFixture(RealPolicyFixture): @@ -91,13 +93,10 @@ class RoleBasedPolicyFixture(RealPolicyFixture): self.role = role def _prepare_policy(self): - with open(CONF.oslo_policy.policy_file) as fp: - policy = fp.read() - policy = jsonutils.loads(policy) - - # Convert all actions to require specified role - for action, rule in policy.items(): - policy[action] = 'role:%s' % self.role + # Convert all actions to require the specified role + policy = {} + for rule in policies.list_rules(): + policy[rule.name] = 'role:%s' % self.role self.policy_dir = self.useFixture(fixtures.TempDir()) self.policy_file = os.path.join(self.policy_dir.path, 'policy.json') diff --git a/masakari/tests/unit/test_hacking.py b/masakari/tests/unit/test_hacking.py index 1dfb7f35..9dda272f 100644 --- a/masakari/tests/unit/test_hacking.py +++ b/masakari/tests/unit/test_hacking.py @@ -457,3 +457,39 @@ class HackingTestCase(test.NoDBTestCase): yieldx_func(a, b) """ self._assert_has_no_errors(code, checks.yield_followed_by_space) + + def test_check_policy_registration_in_central_place(self): + errors = [(3, 0, "M333")] + code = """ + from masakari import policy + + policy.RuleDefault('context_is_admin', 'role:admin') + """ + # registration in the proper place + self._assert_has_no_errors( + code, checks.check_policy_registration_in_central_place, + filename="masakari/policies/base.py") + # option at a location which is not in scope right now + self._assert_has_errors( + code, checks.check_policy_registration_in_central_place, + filename="masakari/api/openstack/ha/non_existent.py", + expected_errors=errors) + + def test_check_policy_enforce(self): + errors = [(3, 0, "M334")] + code = """ + from masakari import policy + + policy._ENFORCER.enforce('context_is_admin', target, credentials) + """ + self._assert_has_errors(code, checks.check_policy_enforce, + expected_errors=errors) + + def test_check_policy_enforce_does_not_catch_other_enforce(self): + # Simulate a different enforce method defined in masakari + code = """ + from masakari import foo + + foo.enforce() + """ + self._assert_has_no_errors(code, checks.check_policy_enforce) diff --git a/masakari/tests/unit/test_policy.py b/masakari/tests/unit/test_policy.py index fadfd9cf..71b41194 100644 --- a/masakari/tests/unit/test_policy.py +++ b/masakari/tests/unit/test_policy.py @@ -56,11 +56,11 @@ class PolicyFileTestCase(test.NoDBTestCase): action = "example:test" with open(tmpfilename, "w") as policyfile: policyfile.write('{"example:test": ""}') - policy.enforce(self.context, action, self.target) + policy.authorize(self.context, action, self.target) with open(tmpfilename, "w") as policyfile: policyfile.write('{"example:test": "!"}') policy._ENFORCER.load_rules(True) - self.assertRaises(exception.PolicyNotAuthorized, policy.enforce, + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, self.context, action, self.target) @@ -90,39 +90,70 @@ class PolicyTestCase(test.NoDBTestCase): self.context = context.RequestContext('fake', 'fake', roles=['member']) self.target = {} - def test_enforce_bad_action_throws(self): - action = "example:denied" - self.assertRaises(exception.PolicyNotAuthorized, policy.enforce, + def test_authorize_nonexistent_action_throws(self): + action = "example:noexist" + self.assertRaises(oslo_policy.PolicyNotRegistered, policy.authorize, self.context, action, self.target) - def test_enforce_bad_action_noraise(self): + def test_authorize_bad_action_throws(self): action = "example:denied" - result = policy.enforce(self.context, action, self.target, False) + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_authorize_bad_action_noraise(self): + action = "example:denied" + result = policy.authorize(self.context, action, self.target, False) self.assertFalse(result) - def test_enforce_good_action(self): + def test_authorize_good_action(self): action = "example:allowed" - result = policy.enforce(self.context, action, self.target) + result = policy.authorize(self.context, action, self.target) self.assertTrue(result) @requests_mock.mock() - def test_enforce_http_true(self, req_mock): + def test_authorize_http_true(self, req_mock): req_mock.post('http://www.example.com/', text='True') action = "example:get_http" target = {} - result = policy.enforce(self.context, action, target) + result = policy.authorize(self.context, action, target) self.assertTrue(result) @requests_mock.mock() - def test_enforce_http_false(self, req_mock): + def test_authorize_http_false(self, req_mock): req_mock.post('http://www.example.com/', text='False') action = "example:get_http" target = {} - self.assertRaises(exception.PolicyNotAuthorized, policy.enforce, + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, self.context, action, target) + def test_templatized_authorization(self): + target_mine = {'project_id': 'fake'} + target_not_mine = {'project_id': 'another'} + action = "example:my_file" + policy.authorize(self.context, action, target_mine) + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, target_not_mine) + + def test_early_AND_authorization(self): + action = "example:early_and_fail" + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_early_OR_authorization(self): + action = "example:early_or_success" + policy.authorize(self.context, action, self.target) + + def test_ignore_case_role_check(self): + lowercase_action = "example:lowercase_admin" + uppercase_action = "example:uppercase_admin" + admin_context = context.RequestContext('admin', + 'fake', + roles=['AdMiN']) + policy.authorize(admin_context, lowercase_action, self.target) + policy.authorize(admin_context, uppercase_action, self.target) + class IsAdminCheckTestCase(test.NoDBTestCase): def setUp(self): @@ -160,6 +191,23 @@ class IsAdminCheckTestCase(test.NoDBTestCase): policy._ENFORCER), True) +class AdminRolePolicyTestCase(test.NoDBTestCase): + def setUp(self): + super(AdminRolePolicyTestCase, self).setUp() + self.policy = self.useFixture(policy_fixture.RoleBasedPolicyFixture()) + self.context = context.RequestContext('fake', 'fake', roles=['member']) + self.actions = policy.get_rules().keys() + self.target = {} + + def test_authorize_admin_actions_with_nonadmin_context_throws(self): + """Check if non-admin context passed to admin actions throws + Policy not authorized exception + """ + for action in self.actions: + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + class RealRolePolicyTestCase(test.NoDBTestCase): def setUp(self): super(RealRolePolicyTestCase, self).setUp() @@ -172,10 +220,21 @@ class RealRolePolicyTestCase(test.NoDBTestCase): self.fake_policy = jsonutils.loads(fake_policy.policy_data) self.admin_only_rules = ( - "os_masakari_api:extensions", - "os_masakari_api:os-hosts", - "os_masakari_api:segments", - "os_masakari_api:notifications" + "os_masakari_api:extensions:index", + "os_masakari_api:extensions:detail", + "os_masakari_api:os-hosts:index", + "os_masakari_api:os-hosts:detail", + "os_masakari_api:os-hosts:create", + "os_masakari_api:os-hosts:update", + "os_masakari_api:os-hosts:delete", + "os_masakari_api:segments:index", + "os_masakari_api:segments:detail", + "os_masakari_api:segments:create", + "os_masakari_api:segments:update", + "os_masakari_api:segments:delete", + "os_masakari_api:notifications:index", + "os_masakari_api:notifications:detail", + "os_masakari_api:notifications:create" ) def test_all_rules_in_sample_file(self): @@ -187,7 +246,7 @@ class RealRolePolicyTestCase(test.NoDBTestCase): def test_admin_only_rules(self): for rule in self.admin_only_rules: - self.assertRaises(exception.PolicyNotAuthorized, policy.enforce, + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, self.non_admin_context, rule, {'project_id': 'fake', 'user_id': 'fake'}) - policy.enforce(self.admin_context, rule, self.target) + policy.authorize(self.admin_context, rule, self.target) diff --git a/releasenotes/notes/policy-in-code-8740d51624055044.yaml b/releasenotes/notes/policy-in-code-8740d51624055044.yaml new file mode 100644 index 00000000..9a25476b --- /dev/null +++ b/releasenotes/notes/policy-in-code-8740d51624055044.yaml @@ -0,0 +1,22 @@ +--- +features: + - | + Masakari now support policy in code, which means if operators doesn't need to + modify any of the default policy rules, they do not need a policy file. + Operators can modify/generate a ``policy.yaml.sample`` file which will override + specific policy rules from their defaults. + + Masakari is now configured to work with two oslo.policy CLI scripts that + have been added: + + - The first of these can be called like + ``oslopolicy-list-redundant --namespace masakari`` and will output a list of + policy rules in policy.[json|yaml] that match the project defaults. These + rules can be removed from the policy file as they have no effect there. + - The second script can be called like + ``oslopolicy-policy-generator --namespace masakari --output-file policy-merged.yaml`` + and will populate the policy-merged.yaml file with the effective policy. + This is the merged results of project defaults and config file overrides. + + NOTE: Default `policy.json` file is now removed as Masakari now uses default + policies. A policy file is only needed if overriding one of the defaults. diff --git a/setup.cfg b/setup.cfg index de29af74..8685c146 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,16 @@ oslo.config.opts = oslo.config.opts.defaults = masakari.api = masakari.common.config:set_middleware_defaults +oslo.policy.enforcer = + masakari = masakari.policy:get_enforcer + +oslo.policy.policies = + # The sample policies will be ordered by entry point and then by list + # returned from that entry point. If more control is desired split out each + # list_rules method into a separate entry point rather than using the + # aggregate method. + masakari = masakari.policies:list_rules + console_scripts = masakari-api = masakari.cmd.api:main masakari-engine = masakari.cmd.engine:main diff --git a/tox.ini b/tox.ini index 7121c52a..70211ba0 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,9 @@ commands = basepython = python3 commands = oslo-config-generator --config-file=etc/masakari/masakari-config-generator.conf +[testenv:genpolicy] +commands = oslopolicy-sample-generator --config-file=etc/masakari/masakari-policy-generator.conf + [testenv:pep8] basepython = python3 commands = flake8 {posargs}