diff --git a/.gitignore b/.gitignore index 17e49b7b974a..f254e0545979 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ nova/vcsversion.py tools/conf/nova.conf* doc/source/_static/nova.conf.sample doc/source/_static/nova.policy.yaml.sample +doc/source/_static/placement.policy.yaml.sample # Files created by releasenotes build releasenotes/build diff --git a/doc/source/conf.py b/doc/source/conf.py index b163e8d729a8..96557403fe20 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -57,8 +57,10 @@ bug_tag = '' config_generator_config_file = '../../etc/nova/nova-config-generator.conf' sample_config_basename = '_static/nova' -policy_generator_config_file = '../../etc/nova/nova-policy-generator.conf' -sample_policy_basename = '_static/nova' +policy_generator_config_file = [ + ('../../etc/nova/nova-policy-generator.conf', '_static/nova'), + ('../../etc/nova/placement-policy-generator.conf', '_static/placement') +] actdiag_html_image_format = 'SVG' actdiag_antialias = True diff --git a/doc/source/configuration/index.rst b/doc/source/configuration/index.rst index 66081dcbf18c..7ebb6e72fd95 100644 --- a/doc/source/configuration/index.rst +++ b/doc/source/configuration/index.rst @@ -20,8 +20,8 @@ Configuration * :doc:`Sample Config File `: A sample config file with inline documentation. -Policy ------- +Nova Policy +----------- Nova, like most OpenStack projects, uses a policy language to restrict permissions on REST API actions. @@ -29,8 +29,20 @@ permissions on REST API actions. * :doc:`Policy Reference `: A complete reference of all policy points in nova and what they impact. -* :doc:`Sample Policy File `: A sample policy - file with inline documentation. +* :doc:`Sample Policy File `: A sample nova + policy file with inline documentation. + +Placement Policy +---------------- + +Placement, like most OpenStack projects, uses a policy language to restrict +permissions on REST API actions. + +* :doc:`Policy Reference `: A complete + reference of all policy points in placement and what they impact. + +* :doc:`Sample Policy File `: A sample + placement policy file with inline documentation. .. # NOTE(mriedem): This is the section where we hide things that we don't @@ -43,3 +55,5 @@ permissions on REST API actions. sample-config policy sample-policy + placement-policy + sample-placement-policy diff --git a/doc/source/configuration/placement-policy.rst b/doc/source/configuration/placement-policy.rst new file mode 100644 index 000000000000..67b6cf6d557f --- /dev/null +++ b/doc/source/configuration/placement-policy.rst @@ -0,0 +1,10 @@ +================== +Placement Policies +================== + +The following is an overview of all available policies in Placement. +For a sample configuration file, refer to +:doc:`/configuration/sample-placement-policy`. + +.. show-policy:: + :config-file: etc/nova/placement-policy-generator.conf diff --git a/doc/source/configuration/policy.rst b/doc/source/configuration/policy.rst index 8fea1406e5c0..66b4c7982d3c 100644 --- a/doc/source/configuration/policy.rst +++ b/doc/source/configuration/policy.rst @@ -1,6 +1,6 @@ -======== -Policies -======== +============= +Nova Policies +============= The following is an overview of all available policies in Nova. For a sample configuration file, refer to :doc:`/configuration/sample-policy`. diff --git a/doc/source/configuration/sample-placement-policy.rst b/doc/source/configuration/sample-placement-policy.rst new file mode 100644 index 000000000000..12e21c52a489 --- /dev/null +++ b/doc/source/configuration/sample-placement-policy.rst @@ -0,0 +1,16 @@ +============================ +Sample Placement Policy File +============================ + +The following is a sample placement policy file for adaptation and use. + +The sample policy can also be viewed in :download:`file form +`. + +.. important:: + + The sample policy file is auto-generated from placement when this + documentation is built. You must ensure your version of placement matches + the version of this documentation. + +.. literalinclude:: /_static/placement.policy.yaml.sample diff --git a/doc/source/configuration/sample-policy.rst b/doc/source/configuration/sample-policy.rst index 0e4b699af792..1dc10e4749a3 100644 --- a/doc/source/configuration/sample-policy.rst +++ b/doc/source/configuration/sample-policy.rst @@ -1,6 +1,6 @@ -================== -Sample Policy File -================== +======================= +Sample Nova Policy File +======================= The following is a sample nova policy file for adaptation and use. diff --git a/etc/nova/README-policy.yaml.txt b/etc/nova/README-policy.yaml.txt index b4a233bc32f9..7599f8071278 100644 --- a/etc/nova/README-policy.yaml.txt +++ b/etc/nova/README-policy.yaml.txt @@ -1,8 +1,24 @@ -To generate the sample policy.yaml file, run the following command from the top -level of the nova directory: +Nova +==== + +To generate the sample nova policy.yaml file, run the following command from +the top level of the nova directory: tox -egenpolicy -For a pre-generated example of the latest policy.yaml, see: +For a pre-generated example of the latest nova policy.yaml, see: https://docs.openstack.org/nova/latest/configuration/sample-policy.html + + +Placement +========= + +To generate the sample placement policy.yaml file, run the following command +from the top level of the nova directory: + + tox -e genplacementpolicy + +For a pre-generated example of the latest placement policy.yaml, see: + + https://docs.openstack.org/nova/latest/configuration/sample-placement-policy.html diff --git a/etc/nova/placement-policy-generator.conf b/etc/nova/placement-policy-generator.conf new file mode 100644 index 000000000000..a2e0697d0004 --- /dev/null +++ b/etc/nova/placement-policy-generator.conf @@ -0,0 +1,5 @@ +[DEFAULT] +# TODO: When placement is split out of the nova repo, this can change to +# etc/placement/policy.yaml.sample. +output_file = etc/nova/placement-policy.yaml.sample +namespace = placement diff --git a/lower-constraints.txt b/lower-constraints.txt index 99b46bfaae04..97a98fe814df 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -83,7 +83,7 @@ oslo.i18n==3.15.3 oslo.log==3.36.0 oslo.messaging==5.29.0 oslo.middleware==3.31.0 -oslo.policy==1.30.0 +oslo.policy==1.35.0 oslo.privsep==1.23.0 oslo.reports==1.18.0 oslo.rootwrap==5.8.0 diff --git a/nova/api/openstack/placement/auth.py b/nova/api/openstack/placement/auth.py index 0a072b5fb6d5..ff2551e26faa 100644 --- a/nova/api/openstack/placement/auth.py +++ b/nova/api/openstack/placement/auth.py @@ -12,13 +12,12 @@ from keystonemiddleware import auth_token -from oslo_context import context -from oslo_db.sqlalchemy import enginefacade from oslo_log import log as logging from oslo_middleware import request_id import webob.dec import webob.exc +from nova.api.openstack.placement import context LOG = logging.getLogger(__name__) @@ -57,11 +56,6 @@ class NoAuthMiddleware(Middleware): return self.application -@enginefacade.transaction_context_provider -class RequestContext(context.RequestContext): - pass - - class PlacementKeystoneContext(Middleware): """Make a request context from keystone headers.""" @@ -69,7 +63,7 @@ class PlacementKeystoneContext(Middleware): def __call__(self, req): req_id = req.environ.get(request_id.ENV_REQUEST_ID) - ctx = RequestContext.from_environ( + ctx = context.RequestContext.from_environ( req.environ, request_id=req_id) if ctx.user_id is None and req.environ['PATH_INFO'] != '/': diff --git a/nova/api/openstack/placement/context.py b/nova/api/openstack/placement/context.py new file mode 100644 index 000000000000..51b1340269f6 --- /dev/null +++ b/nova/api/openstack/placement/context.py @@ -0,0 +1,52 @@ +# 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_context import context +from oslo_db.sqlalchemy import enginefacade + +from nova.api.openstack.placement import exception +from nova.api.openstack.placement import policy + + +@enginefacade.transaction_context_provider +class RequestContext(context.RequestContext): + + 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: As much information about the object being operated on + as possible. The target argument should be a dict instance or an + instance of a class that fully supports the Mapping abstract base + class and deep copying. 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.PolicyNotAuthorized occurs. + :raises nova.exception.PolicyNotAuthorized: 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.PolicyNotAuthorized: + if fatal: + raise + return False diff --git a/nova/api/openstack/placement/exception.py b/nova/api/openstack/placement/exception.py index ae17d5470f67..231c6e7a37ae 100644 --- a/nova/api/openstack/placement/exception.py +++ b/nova/api/openstack/placement/exception.py @@ -127,6 +127,10 @@ class ObjectActionError(_BaseException): msg_fmt = _('Object action %(action)s failed because: %(reason)s') +class PolicyNotAuthorized(_BaseException): + msg_fmt = _("Policy does not allow %(action)s to be performed.") + + class ResourceClassCannotDeleteStandard(_BaseException): msg_fmt = _("Cannot delete standard resource class %(resource_class)s.") diff --git a/nova/api/openstack/placement/handler.py b/nova/api/openstack/placement/handler.py index 18536bb133c4..1e36a9e0ec5d 100644 --- a/nova/api/openstack/placement/handler.py +++ b/nova/api/openstack/placement/handler.py @@ -23,10 +23,13 @@ Routes.Mapper, including automatic handlers to respond with a method. """ +import re + import routes import webob from oslo_log import log as logging +from oslo_utils import excutils from nova.api.openstack.placement import exception from nova.api.openstack.placement.handlers import aggregate @@ -38,7 +41,6 @@ from nova.api.openstack.placement.handlers import resource_provider from nova.api.openstack.placement.handlers import root from nova.api.openstack.placement.handlers import trait from nova.api.openstack.placement.handlers import usage -from nova.api.openstack.placement import policy from nova.api.openstack.placement import util from nova.i18n import _ @@ -129,6 +131,19 @@ ROUTE_DECLARATIONS = { }, } +# This is a temporary list (of regexes) of the route handlers that will do +# their own granular policy check. Once all handlers are doing their own +# policy checks we can remove this along with the generic policy check in +# PlacementHandler. All entries are checked against re.match() so must +# match the start of the path. +PER_ROUTE_POLICY = [ + # The root is special in that it does not require auth. + '/$', + # /resource_providers + # /resource_providers/{uuid} + '/resource_providers(/[A-Za-z0-9-]+)?$' +] + def dispatch(environ, start_response, mapper): """Find a matching route for the current request. @@ -192,17 +207,29 @@ class PlacementHandler(object): # NOTE(cdent): Local config currently unused. self._map = make_map(ROUTE_DECLARATIONS) + @staticmethod + def _is_granular_policy_check(path): + for policy in PER_ROUTE_POLICY: + if re.match(policy, path): + return True + return False + def __call__(self, environ, start_response): - # All requests but '/' require admin. - if environ['PATH_INFO'] != '/': + # Any routes that do not yet have a granular policy check default + # to admin-only. + if not self._is_granular_policy_check(environ['PATH_INFO']): context = environ['placement.context'] - # TODO(cdent): Using is_admin everywhere (except /) is - # insufficiently flexible for future use case but is - # convenient for initial exploration. - if not policy.placement_authorize(context, 'placement'): - raise webob.exc.HTTPForbidden( - _('admin required'), - json_formatter=util.json_error_formatter) + try: + if not context.can('placement', fatal=False): + raise webob.exc.HTTPForbidden( + _('admin required'), + json_formatter=util.json_error_formatter) + except Exception: + # This is here mostly for help in debugging problems with + # busted test setup. + with excutils.save_and_reraise_exception(): + LOG.exception('policy check failed for path: %s', + environ['PATH_INFO']) # Check that an incoming request with a content-length header # that is an integer > 0 and not empty, also has a content-type # header that is not empty. If not raise a 400. @@ -223,6 +250,10 @@ class PlacementHandler(object): except exception.NotFound as exc: raise webob.exc.HTTPNotFound( exc, json_formatter=util.json_error_formatter) + except exception.PolicyNotAuthorized as exc: + raise webob.exc.HTTPForbidden( + exc.format_message(), + json_formatter=util.json_error_formatter) # Remaining uncaught exceptions will rise first to the Microversion # middleware, where any WebOb generated exceptions will be caught and # transformed into legit HTTP error responses (with microversion diff --git a/nova/api/openstack/placement/handlers/resource_provider.py b/nova/api/openstack/placement/handlers/resource_provider.py index c80e91fd82eb..86a83812e6ce 100644 --- a/nova/api/openstack/placement/handlers/resource_provider.py +++ b/nova/api/openstack/placement/handlers/resource_provider.py @@ -21,6 +21,7 @@ import webob from nova.api.openstack.placement import exception from nova.api.openstack.placement import microversion from nova.api.openstack.placement.objects import resource_provider as rp_obj +from nova.api.openstack.placement.policies import resource_provider as policies from nova.api.openstack.placement.schemas import resource_provider as rp_schema from nova.api.openstack.placement import util from nova.api.openstack.placement import wsgi_wrapper @@ -78,6 +79,7 @@ def create_resource_provider(req): header pointing to the newly created resource provider. """ context = req.environ['placement.context'] + context.can(policies.CREATE) schema = rp_schema.POST_RESOURCE_PROVIDER_SCHEMA want_version = req.environ[microversion.MICROVERSION_ENVIRON] if want_version.matches((1, 14)): @@ -126,6 +128,7 @@ def delete_resource_provider(req): """ uuid = util.wsgi_path_item(req.environ, 'uuid') context = req.environ['placement.context'] + context.can(policies.DELETE) # The containing application will catch a not found here. try: resource_provider = rp_obj.ResourceProvider.get_by_uuid( @@ -153,9 +156,10 @@ def get_resource_provider(req): """ want_version = req.environ[microversion.MICROVERSION_ENVIRON] uuid = util.wsgi_path_item(req.environ, 'uuid') - # The containing application will catch a not found here. context = req.environ['placement.context'] + context.can(policies.SHOW) + # The containing application will catch a not found here. resource_provider = rp_obj.ResourceProvider.get_by_uuid( context, uuid) @@ -179,6 +183,7 @@ def list_resource_providers(req): a collection of resource providers. """ context = req.environ['placement.context'] + context.can(policies.LIST) want_version = req.environ[microversion.MICROVERSION_ENVIRON] schema = rp_schema.GET_RPS_SCHEMA_1_0 @@ -244,6 +249,7 @@ def update_resource_provider(req): """ uuid = util.wsgi_path_item(req.environ, 'uuid') context = req.environ['placement.context'] + context.can(policies.UPDATE) want_version = req.environ[microversion.MICROVERSION_ENVIRON] # The containing application will catch a not found here. diff --git a/nova/api/openstack/placement/policies/__init__.py b/nova/api/openstack/placement/policies/__init__.py new file mode 100644 index 000000000000..575f1d8642e8 --- /dev/null +++ b/nova/api/openstack/placement/policies/__init__.py @@ -0,0 +1,23 @@ +# 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 nova.api.openstack.placement.policies import base +from nova.api.openstack.placement.policies import resource_provider + + +def list_rules(): + return itertools.chain( + base.list_rules(), + resource_provider.list_rules(), + ) diff --git a/nova/api/openstack/placement/policies/base.py b/nova/api/openstack/placement/policies/base.py new file mode 100644 index 000000000000..f196994d8001 --- /dev/null +++ b/nova/api/openstack/placement/policies/base.py @@ -0,0 +1,41 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_policy import policy + +RULE_ADMIN_API = 'rule:admin_api' + +rules = [ + # "placement" is the default rule (action) used for all routes that do + # not yet have granular policy rules. It is used in + # PlacementHandler.__call__ and can be dropped once all routes have + # granular policy handling. + policy.RuleDefault( + "placement", + "role:admin", + description="This rule is used for all routes that do not yet " + "have granular policy rules. It will be replaced " + "with rule:admin_api.", + deprecated_for_removal=True, + deprecated_reason="This was a catch-all rule hard-coded into " + "the placement service and has been superseded by " + "granular policy rules per operation.", + deprecated_since="18.0.0"), + policy.RuleDefault( + "admin_api", + "role:admin", + description="Default rule for most placement APIs."), +] + + +def list_rules(): + return rules diff --git a/nova/api/openstack/placement/policies/resource_provider.py b/nova/api/openstack/placement/policies/resource_provider.py new file mode 100644 index 000000000000..027985c20442 --- /dev/null +++ b/nova/api/openstack/placement/policies/resource_provider.py @@ -0,0 +1,81 @@ +# 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 nova.api.openstack.placement.policies import base + + +PREFIX = 'placement:resource_providers:%s' +LIST = PREFIX % 'list' +CREATE = PREFIX % 'create' +SHOW = PREFIX % 'show' +UPDATE = PREFIX % 'update' +DELETE = PREFIX % 'delete' + +rules = [ + policy.DocumentedRuleDefault( + LIST, + base.RULE_ADMIN_API, + "List resource providers.", + [ + { + 'method': 'GET', + 'path': '/resource_providers' + } + ]), + policy.DocumentedRuleDefault( + CREATE, + base.RULE_ADMIN_API, + "Create resource provider.", + [ + { + 'method': 'POST', + 'path': '/resource_providers' + } + ]), + policy.DocumentedRuleDefault( + SHOW, + base.RULE_ADMIN_API, + "Show resource provider.", + [ + { + 'method': 'GET', + 'path': '/resource_providers/{uuid}' + } + ]), + policy.DocumentedRuleDefault( + UPDATE, + base.RULE_ADMIN_API, + "Update resource provider.", + [ + { + 'method': 'PUT', + 'path': '/resource_providers/{uuid}' + } + ]), + policy.DocumentedRuleDefault( + DELETE, + base.RULE_ADMIN_API, + "Delete resource provider.", + [ + { + 'method': 'DELETE', + 'path': '/resource_providers/{uuid}' + } + ]), +] + + +def list_rules(): + return rules diff --git a/nova/api/openstack/placement/policy.py b/nova/api/openstack/placement/policy.py index fd856635b68b..a280b484c4ce 100644 --- a/nova/api/openstack/placement/policy.py +++ b/nova/api/openstack/placement/policy.py @@ -14,7 +14,10 @@ from oslo_config import cfg from oslo_log import log as logging from oslo_policy import policy -from oslo_serialization import jsonutils +from oslo_utils import excutils + +from nova.api.openstack.placement import exception +from nova.api.openstack.placement import policies CONF = cfg.CONF @@ -22,54 +25,68 @@ LOG = logging.getLogger(__name__) _ENFORCER_PLACEMENT = None -def placement_init(): - """Init an Enforcer class for placement policy. +def reset(): + """Used to reset the global _ENFORCER_PLACEMENT between test runs.""" + global _ENFORCER_PLACEMENT + if _ENFORCER_PLACEMENT: + _ENFORCER_PLACEMENT.clear() + _ENFORCER_PLACEMENT = None - This method uses a different list of policies than other parts of Nova. - This is done to facilitate a split out of the placement service later. - """ + +def init(): + """Init an Enforcer class. Sets the _ENFORCER_PLACEMENT global.""" global _ENFORCER_PLACEMENT if not _ENFORCER_PLACEMENT: - # TODO(cdent): Using is_admin everywhere (except /) is - # insufficiently flexible for future use case but is - # convenient for initial exploration. We will need to - # determine how to manage authorization/policy and - # implement that, probably per handler. - rules = policy.Rules.load(jsonutils.dumps({'placement': 'role:admin'})) - # Enforcer is initialized so that the above rule is loaded in and no - # policy file is read. - # TODO(alaski): Register a default rule rather than loading it in like - # this. That requires that a policy file is specified to be read. When - # this is split out such that a placement policy file makes sense then - # change to rule registration. - _ENFORCER_PLACEMENT = policy.Enforcer(CONF, rules=rules, - use_conf=False) + # NOTE(mriedem): We have to explicitly pass in the + # [placement]/policy_file path because otherwise oslo_policy defaults + # to read the policy file from config option [oslo_policy]/policy_file + # which is used by nova. In other words, to have separate policy files + # for placement and nova, we have to use separate policy_file options. + _ENFORCER_PLACEMENT = policy.Enforcer( + CONF, policy_file=CONF.placement.policy_file) + _ENFORCER_PLACEMENT.register_defaults(policies.list_rules()) + _ENFORCER_PLACEMENT.load_rules() -def placement_authorize(context, action, target=None): +def get_enforcer(): + # This method is used by oslopolicy CLI scripts in order to generate policy + # files from overrides on disk and defaults in code. We can just pass an + # empty list and let oslo do the config lifting for us. + cfg.CONF([], project='nova') + init() + return _ENFORCER_PLACEMENT + + +def authorize(context, action, target, do_raise=True): """Verifies that the action is valid on the target in this context. - :param context: RequestContext object - :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}`` - - :return: returns a non-False value (not necessarily "True") if - authorized, and the exact value False if not authorized. + :param context: instance of + nova.api.openstack.placement.context.RequestContext + :param action: string representing the action to be checked + this should be colon separated for clarity, i.e. + ``placement:resource_providers:list`` + :param target: dictionary representing the object of the action; + for object creation this should be a dictionary representing the + owner of the object e.g. ``{'project_id': context.project_id}``. + :param do_raise: if True (the default), raises PolicyNotAuthorized; + if False, returns False + :raises nova.api.openstack.placement.exception.PolicyNotAuthorized: if + verification fails and do_raise is True. + :returns: non-False value (not necessarily "True") if authorized, and the + exact value False if not authorized and do_raise is False. """ - placement_init() - if target is None: - target = {'project_id': context.project_id, - 'user_id': context.user_id} + init() credentials = context.to_policy_values() - # TODO(alaski): Change this to use authorize() when rules are registered. - # noqa the following line because a hacking check disallows using enforce. - result = _ENFORCER_PLACEMENT.enforce(action, target, credentials, - do_raise=False, exc=None, - action=action) - if result is False: - LOG.debug('Policy check for %(action)s failed with credentials ' - '%(credentials)s', - {'action': action, 'credentials': credentials}) - return result + try: + # NOTE(mriedem): The "action" kwarg is for the PolicyNotAuthorized exc. + return _ENFORCER_PLACEMENT.authorize( + action, target, credentials, do_raise=do_raise, + exc=exception.PolicyNotAuthorized, action=action) + except policy.PolicyNotRegistered: + with excutils.save_and_reraise_exception(): + LOG.exception('Policy not registered') + except Exception: + with excutils.save_and_reraise_exception(): + LOG.debug('Policy check for %(action)s failed with credentials ' + '%(credentials)s', + {'action': action, 'credentials': credentials}) diff --git a/nova/conf/placement.py b/nova/conf/placement.py index 9dd545915585..fd68dc13d193 100644 --- a/nova/conf/placement.py +++ b/nova/conf/placement.py @@ -35,6 +35,15 @@ being equal, two requests for allocation candidates will return the same results in the same order; but no guarantees are made as to how that order is determined. """), + # TODO(mriedem): When placement is split out of nova, this should be + # deprecated since then [oslo_policy]/policy_file can be used. + cfg.StrOpt( + 'policy_file', + # This default matches what is in + # etc/nova/placement-policy-generator.conf + default='placement-policy.yaml', + help='The file that defines placement policies. This can be an ' + 'absolute path or relative to the configuration file.'), ] diff --git a/nova/hacking/checks.py b/nova/hacking/checks.py index eea0a775e636..3780f897614c 100644 --- a/nova/hacking/checks.py +++ b/nova/hacking/checks.py @@ -621,13 +621,16 @@ def check_config_option_in_central_place(logical_line, filename): def check_policy_registration_in_central_place(logical_line, filename): - msg = ('N350: Policy registration should be in the central location ' - '"/nova/policies/*".') + msg = ('N350: Policy registration should be in the central location(s) ' + '"/nova/policies/*" or "nova/api/openstack/placement/policies/*".') # This is where registration should happen - if "nova/policies/" in filename: + if ("nova/policies/" in filename or + "nova/api/openstack/placement/policies/" in filename): return # A couple of policy tests register rules - if "nova/tests/unit/test_policy.py" in filename: + if ("nova/tests/unit/test_policy.py" in filename or + "nova/tests/unit/api/openstack/placement/test_policy.py" in + filename): return if rule_default_re.match(logical_line): diff --git a/nova/test.py b/nova/test.py index 26dcbfdc583f..d43782b7a91b 100644 --- a/nova/test.py +++ b/nova/test.py @@ -322,6 +322,8 @@ class TestCase(testtools.TestCase): self.addCleanup(self._clear_attrs) self.useFixture(fixtures.EnvironmentVariable('http_proxy')) self.policy = self.useFixture(policy_fixture.PolicyFixture()) + self.placement_policy = self.useFixture( + policy_fixture.PlacementPolicyFixture()) self.useFixture(nova_fixtures.PoisonFunctions()) diff --git a/nova/tests/functional/api/openstack/placement/fixtures.py b/nova/tests/functional/api/openstack/placement/fixtures.py index 31c898d1149b..c84f8f6b86ec 100644 --- a/nova/tests/functional/api/openstack/placement/fixtures.py +++ b/nova/tests/functional/api/openstack/placement/fixtures.py @@ -19,10 +19,12 @@ from oslo_utils import uuidutils from nova.api.openstack.placement import deploy from nova.api.openstack.placement import exception from nova.api.openstack.placement.objects import resource_provider as rp_obj +from nova.api.openstack.placement import policies from nova import conf from nova import config from nova import context from nova.tests import fixtures +from nova.tests.unit import policy_fixture from nova.tests import uuidsentinel as uuids @@ -514,3 +516,28 @@ class GranularFixture(APIFixture): _add_inventory(shr_net, 'SRIOV_NET_VF', 16) _add_inventory(shr_net, 'CUSTOM_NET_MBPS', 40000) _set_traits(shr_net, 'MISC_SHARES_VIA_AGGREGATE') + + +class OpenPolicyFixture(APIFixture): + """An APIFixture that changes all policy rules to allow non-admins.""" + + def start_fixture(self): + super(OpenPolicyFixture, self).start_fixture() + self.placement_policy_fixture = policy_fixture.PlacementPolicyFixture() + self.placement_policy_fixture.setUp() + # Get all of the registered rules and set them to '@' to allow any + # user to have access. The nova policy "admin_or_owner" concept does + # not really apply to most of placement resources since they do not + # have a user_id/project_id attribute. + rules = {} + for rule in policies.list_rules(): + name = rule.name + # Ignore "base" rules for role:admin. + if name in ['placement', 'admin_api']: + continue + rules[name] = '@' + self.placement_policy_fixture.set_rules(rules) + + def stop_fixture(self): + super(OpenPolicyFixture, self).stop_fixture() + self.placement_policy_fixture.cleanUp() diff --git a/nova/tests/functional/api/openstack/placement/gabbits/resource-provider-policy.yaml b/nova/tests/functional/api/openstack/placement/gabbits/resource-provider-policy.yaml new file mode 100644 index 000000000000..ef663f1b481c --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/gabbits/resource-provider-policy.yaml @@ -0,0 +1,48 @@ +# This tests the individual CRUD operations on /resource_providers +# using a non-admin user with an open policy configuration. The +# response validation is intentionally minimal. +fixtures: + - OpenPolicyFixture + +defaults: + request_headers: + x-auth-token: user + accept: application/json + content-type: application/json + openstack-api-version: placement latest + +tests: + +- name: list resource providers + GET: /resource_providers + response_json_paths: + $.resource_providers: [] + +- name: create resource provider + POST: /resource_providers + request_headers: + content-type: application/json + data: + name: $ENVIRON['RP_NAME'] + uuid: $ENVIRON['RP_UUID'] + status: 200 + response_json_paths: + $.uuid: $ENVIRON['RP_UUID'] + +- name: show resource provider + GET: /resource_providers/$ENVIRON['RP_UUID'] + response_json_paths: + $.uuid: $ENVIRON['RP_UUID'] + +- name: update resource provider + PUT: /resource_providers/$ENVIRON['RP_UUID'] + data: + name: new name + status: 200 + response_json_paths: + $.name: new name + $.uuid: $ENVIRON['RP_UUID'] + +- name: delete resource provider + DELETE: /resource_providers/$ENVIRON['RP_UUID'] + status: 204 diff --git a/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml b/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml index b39ce632014c..e86ba80059fd 100644 --- a/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml +++ b/nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml @@ -31,14 +31,13 @@ tests: response_json_paths: $.errors[0].title: Forbidden -- name: non admin forbidden non json - GET: /resource_providers +- name: route not found non json + GET: /moo request_headers: - x-auth-token: user accept: text/plain - status: 403 + status: 404 response_strings: - - admin required + - The resource could not be found - name: post new resource provider - old microversion POST: /resource_providers diff --git a/nova/tests/unit/api/openstack/placement/test_context.py b/nova/tests/unit/api/openstack/placement/test_context.py new file mode 100644 index 000000000000..18101615919f --- /dev/null +++ b/nova/tests/unit/api/openstack/placement/test_context.py @@ -0,0 +1,68 @@ +# 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 mock +import testtools + +from nova.api.openstack.placement import context +from nova.api.openstack.placement import exception + + +class TestPlacementRequestContext(testtools.TestCase): + """Test cases for PlacementRequestContext.""" + + def setUp(self): + super(TestPlacementRequestContext, self).setUp() + self.ctxt = context.RequestContext(user_id='fake', project_id='fake') + self.default_target = {'user_id': self.ctxt.user_id, + 'project_id': self.ctxt.project_id} + + @mock.patch('nova.api.openstack.placement.policy.authorize', + return_value=True) + def test_can_target_none_fatal_true_accept(self, mock_authorize): + self.assertTrue(self.ctxt.can('placement:resource_providers:list')) + mock_authorize.assert_called_once_with( + self.ctxt, 'placement:resource_providers:list', + self.default_target) + + @mock.patch('nova.api.openstack.placement.policy.authorize', + side_effect=exception.PolicyNotAuthorized( + action='placement:resource_providers:list')) + def test_can_target_none_fatal_true_reject(self, mock_authorize): + self.assertRaises(exception.PolicyNotAuthorized, + self.ctxt.can, 'placement:resource_providers:list') + mock_authorize.assert_called_once_with( + self.ctxt, 'placement:resource_providers:list', + self.default_target) + + @mock.patch('nova.api.openstack.placement.policy.authorize', + side_effect=exception.PolicyNotAuthorized( + action='placement:resource_providers:list')) + def test_can_target_none_fatal_false_reject(self, mock_authorize): + self.assertFalse(self.ctxt.can('placement:resource_providers:list', + fatal=False)) + mock_authorize.assert_called_once_with( + self.ctxt, 'placement:resource_providers:list', + self.default_target) + + @mock.patch('nova.api.openstack.placement.policy.authorize', + return_value=True) + def test_can_target_none_fatal_true_accept_custom_target( + self, mock_authorize): + class MyObj(object): + user_id = project_id = 'fake2' + + target = MyObj() + self.assertTrue(self.ctxt.can('placement:resource_providers:list', + target=target)) + mock_authorize.assert_called_once_with( + self.ctxt, 'placement:resource_providers:list', target) diff --git a/nova/tests/unit/api/openstack/placement/test_policy.py b/nova/tests/unit/api/openstack/placement/test_policy.py new file mode 100644 index 000000000000..ad0bdf637a6a --- /dev/null +++ b/nova/tests/unit/api/openstack/placement/test_policy.py @@ -0,0 +1,80 @@ +# 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 + +from oslo_policy import policy as oslo_policy +import testtools + +from nova.api.openstack.placement import context +from nova.api.openstack.placement import exception +from nova.api.openstack.placement import policy +from nova.tests.unit import conf_fixture +from nova.tests.unit import policy_fixture +from nova import utils + + +class PlacementPolicyTestCase(testtools.TestCase): + """Tests interactions with placement policy. + + These tests do not rely on the base nova.test.TestCase to avoid + interference from the PlacementPolicyFixture which is not used in all + test cases. + """ + def setUp(self): + super(PlacementPolicyTestCase, self).setUp() + self.conf = self.useFixture(conf_fixture.ConfFixture()).conf + self.ctxt = context.RequestContext(user_id='fake', project_id='fake') + self.target = {'user_id': 'fake', 'project_id': 'fake'} + + def test_modified_policy_reloads(self): + """Creates a temporary placement-policy.yaml file and tests + authorizations against a fake rule between updates to the physical + policy file. + """ + with utils.tempdir() as tmpdir: + tmpfilename = os.path.join(tmpdir, 'placement-policy.yaml') + + self.conf.set_default( + 'policy_file', tmpfilename, group='placement') + + action = 'placement:test' + # Expect PolicyNotRegistered since defaults are not yet loaded. + self.assertRaises(oslo_policy.PolicyNotRegistered, + policy.authorize, self.ctxt, action, self.target) + + # Load the default action and rule (defaults to "any"). + enforcer = policy.get_enforcer() + rule = oslo_policy.RuleDefault(action, '') + enforcer.register_default(rule) + + # Now auth should work because the action is registered and anyone + # can perform the action. + policy.authorize(self.ctxt, action, self.target) + + # Now update the policy file and reload it to disable the action + # from all users. + with open(tmpfilename, "w") as policyfile: + policyfile.write('"%s": "!"' % action) + enforcer.load_rules(force_reload=True) + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.ctxt, action, self.target) + + def test_authorize_do_raise_false(self): + """Tests that authorize does not raise an exception when the check + fails. + """ + fixture = self.useFixture(policy_fixture.PlacementPolicyFixture()) + fixture.set_rules({'placement': '!'}) + self.assertFalse( + policy.authorize( + self.ctxt, 'placement', self.target, do_raise=False)) diff --git a/nova/tests/unit/policy_fixture.py b/nova/tests/unit/policy_fixture.py index a076afa93d02..651f096bcba4 100644 --- a/nova/tests/unit/policy_fixture.py +++ b/nova/tests/unit/policy_fixture.py @@ -18,6 +18,7 @@ import fixtures from oslo_policy import policy as oslo_policy from oslo_serialization import jsonutils +from nova.api.openstack.placement import policy as placement_policy import nova.conf from nova.conf import paths from nova import policies @@ -126,3 +127,32 @@ class RoleBasedPolicyFixture(RealPolicyFixture): self.policy_file = os.path.join(self.policy_dir.path, 'policy.json') with open(self.policy_file, 'w') as f: jsonutils.dump(policy, f) + + +class PlacementPolicyFixture(fixtures.Fixture): + """Load the default placement policy for tests. + + This fixture requires nova.tests.unit.conf_fixture.ConfFixture. + """ + def setUp(self): + super(PlacementPolicyFixture, self).setUp() + policy_file = paths.state_path_def('etc/nova/placement-policy.yaml') + CONF.set_override('policy_file', policy_file, group='placement') + placement_policy.reset() + placement_policy.init() + self.addCleanup(placement_policy.reset) + + @staticmethod + def set_rules(rules, overwrite=True): + """Set placement policy rules. + + .. note:: The rules must first be registered via the + Enforcer.register_defaults method. + + :param rules: dict of action=rule mappings to set + :param overwrite: Whether to overwrite current rules or update them + with the new rules. + """ + enforcer = placement_policy.get_enforcer() + enforcer.set_rules(oslo_policy.Rules.from_dict(rules), + overwrite=overwrite) diff --git a/nova/tests/unit/test_fixtures.py b/nova/tests/unit/test_fixtures.py index cb5a4ceb1d91..4e8b3e6f71f4 100644 --- a/nova/tests/unit/test_fixtures.py +++ b/nova/tests/unit/test_fixtures.py @@ -34,6 +34,7 @@ from nova.objects import base as obj_base from nova.objects import service as service_obj from nova.tests import fixtures from nova.tests.unit import conf_fixture +from nova.tests.unit import policy_fixture from nova import utils CONF = cfg.CONF @@ -471,6 +472,13 @@ class TestSingleCellSimpleFixture(testtools.TestCase): class TestPlacementFixture(testtools.TestCase): + def setUp(self): + super(TestPlacementFixture, self).setUp() + # We need ConfFixture since PlacementPolicyFixture reads from config. + self.useFixture(conf_fixture.ConfFixture()) + # We need PlacementPolicyFixture because placement-api checks policy. + self.useFixture(policy_fixture.PlacementPolicyFixture()) + def test_responds_to_version(self): """Ensure the Placement server responds to calls sensibly.""" placement_fixture = self.useFixture(fixtures.PlacementFixture()) diff --git a/releasenotes/notes/bp-granular-placement-policy-65722fc6d7cb1359.yaml b/releasenotes/notes/bp-granular-placement-policy-65722fc6d7cb1359.yaml new file mode 100644 index 000000000000..90fc54a9e575 --- /dev/null +++ b/releasenotes/notes/bp-granular-placement-policy-65722fc6d7cb1359.yaml @@ -0,0 +1,31 @@ +--- +features: + - | + It is now possible to configure granular policy rules for placement + REST API operations. + + By default, all operations continue to use the ``role:admin`` check string + so there is no upgrade impact. + + A new configuration option is introduced, ``[placement]/policy_file``, + which is used to configure the location of the placement policy file. + By default, the ``placement-policy.yaml`` file may live alongside the + nova policy file, e.g.: + + * /etc/nova/policy.yaml + * /etc/nova/placement-policy.yaml + + However, if desired, ``[placement]/policy_file`` makes it possible to + package and deploy the placement policy file separately to make the future + split of placement and nova packages easier, e.g.: + + * /etc/placement/policy.yaml + + All placement policy rules are defined in code so by default no extra + configuration is required and the default rules will be used on start of + the placement service. + + For more information about placement policy including a sample file, see + the configuration reference documentation: + + https://docs.openstack.org/nova/latest/configuration/index.html#placement-policy diff --git a/requirements.txt b/requirements.txt index 43b82f6cfbbe..5dc08840c476 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,7 +44,7 @@ oslo.utils>=3.33.0 # Apache-2.0 oslo.db>=4.27.0 # Apache-2.0 oslo.rootwrap>=5.8.0 # Apache-2.0 oslo.messaging>=5.29.0 # Apache-2.0 -oslo.policy>=1.30.0 # Apache-2.0 +oslo.policy>=1.35.0 # Apache-2.0 oslo.privsep>=1.23.0 # Apache-2.0 oslo.i18n>=3.15.3 # Apache-2.0 oslo.service!=1.28.1,>=1.24.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 12f1286dd7ce..d2e073153ca6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,6 +40,7 @@ oslo.config.opts.defaults = oslo.policy.enforcer = nova = nova.policy:get_enforcer + placement = nova.api.openstack.placement.policy:get_enforcer oslo.policy.policies = # The sample policies will be ordered by entry point and then by list @@ -47,6 +48,7 @@ oslo.policy.policies = # list_rules method into a separate entry point rather than using the # aggregate method. nova = nova.policies:list_rules + placement = nova.api.openstack.placement.policies:list_rules nova.compute.monitors.cpu = virt_driver = nova.compute.monitors.cpu.virt_driver:Monitor diff --git a/tox.ini b/tox.ini index 8a3d2d8af301..c10d07f9e0c9 100644 --- a/tox.ini +++ b/tox.ini @@ -116,6 +116,9 @@ commands = oslo-config-generator --config-file=etc/nova/nova-config-generator.co [testenv:genpolicy] commands = oslopolicy-sample-generator --config-file=etc/nova/nova-policy-generator.conf +[testenv:genplacementpolicy] +commands = oslopolicy-sample-generator --config-file=etc/nova/placement-policy-generator.conf + [testenv:cover] # Also do not run test_coverage_ext tests while gathering coverage as those # tests conflict with coverage.