Browse Source

Implement granular policy rules for placement

This adds a granular policy checking framework for
placement based on nova.policy but with a lot of
the legacy cruft removed, like the is_admin and
context_is_admin rules.

A new PlacementPolicyFixture is added along with
a new configuration option, [placement]/policy_file,
which is needed because the default policy file
that gets used in config is from [oslo_policy]/policy_file
which is being used as the nova policy file. As
far as I can tell, oslo.policy doesn't allow for
multiple policy files with different names unless
I'm misunderstanding how the policy_dirs option works.

With these changes, we can have something like:

  /etc/nova/policy.json - for nova policy rules
  /etc/nova/placement-policy.yaml - for placement rules

The docs are also updated to include the placement
policy sample along with a tox builder for the sample.

This starts by adding granular rules for CRUD operations
on the /resource_providers and /resource_providers/{uuid}
routes which use the same descriptions from the placement
API reference. Subsequent patches will add new granular
rules for the other routes.

Part of blueprint granular-placement-policy

Change-Id: I17573f5210314341c332fdcb1ce462a989c21940
changes/25/524425/16
Matt Riedemann 4 years ago
parent
commit
0a461979df
  1. 1
      .gitignore
  2. 6
      doc/source/conf.py
  3. 22
      doc/source/configuration/index.rst
  4. 10
      doc/source/configuration/placement-policy.rst
  5. 6
      doc/source/configuration/policy.rst
  6. 16
      doc/source/configuration/sample-placement-policy.rst
  7. 6
      doc/source/configuration/sample-policy.rst
  8. 22
      etc/nova/README-policy.yaml.txt
  9. 5
      etc/nova/placement-policy-generator.conf
  10. 2
      lower-constraints.txt
  11. 10
      nova/api/openstack/placement/auth.py
  12. 52
      nova/api/openstack/placement/context.py
  13. 4
      nova/api/openstack/placement/exception.py
  14. 51
      nova/api/openstack/placement/handler.py
  15. 8
      nova/api/openstack/placement/handlers/resource_provider.py
  16. 23
      nova/api/openstack/placement/policies/__init__.py
  17. 41
      nova/api/openstack/placement/policies/base.py
  18. 81
      nova/api/openstack/placement/policies/resource_provider.py
  19. 103
      nova/api/openstack/placement/policy.py
  20. 9
      nova/conf/placement.py
  21. 11
      nova/hacking/checks.py
  22. 2
      nova/test.py
  23. 27
      nova/tests/functional/api/openstack/placement/fixtures.py
  24. 48
      nova/tests/functional/api/openstack/placement/gabbits/resource-provider-policy.yaml
  25. 9
      nova/tests/functional/api/openstack/placement/gabbits/resource-provider.yaml
  26. 68
      nova/tests/unit/api/openstack/placement/test_context.py
  27. 80
      nova/tests/unit/api/openstack/placement/test_policy.py
  28. 30
      nova/tests/unit/policy_fixture.py
  29. 8
      nova/tests/unit/test_fixtures.py
  30. 31
      releasenotes/notes/bp-granular-placement-policy-65722fc6d7cb1359.yaml
  31. 2
      requirements.txt
  32. 2
      setup.cfg
  33. 3
      tox.ini

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

6
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

22
doc/source/configuration/index.rst

@ -20,8 +20,8 @@ Configuration
* :doc:`Sample Config File <sample-config>`: 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 <policy>`: A complete reference of all
policy points in nova and what they impact.
* :doc:`Sample Policy File <sample-policy>`: A sample policy
file with inline documentation.
* :doc:`Sample Policy File <sample-policy>`: 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 <placement-policy>`: A complete
reference of all policy points in placement and what they impact.
* :doc:`Sample Policy File <sample-placement-policy>`: 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

10
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

6
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`.

16
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
</_static/placement.policy.yaml.sample>`.
.. 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

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

22
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

5
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

2
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

10
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'] != '/':

52
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

4
nova/api/openstack/placement/exception.py

@ -121,6 +121,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.")

51
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

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

23
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(),
)

41
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

81
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

103
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):
"""Verifies that the action is valid on the target in this context.
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
: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}``
def authorize(context, action, target, do_raise=True):
"""Verifies that the action is valid on the target in this context.
: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})

9
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.'),
]

11
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):

2
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())

27
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()

48
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

9
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

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

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

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

8
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())

31
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

2
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

2
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

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

Loading…
Cancel
Save