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
This commit is contained in:
parent
87a2a63144
commit
519e5a22d1
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
Normal file
10
doc/source/configuration/placement-policy.rst
Normal file
@ -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
|
16
doc/source/configuration/sample-placement-policy.rst
Normal file
16
doc/source/configuration/sample-placement-policy.rst
Normal file
@ -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
|
@ -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
Normal file
5
etc/nova/placement-policy-generator.conf
Normal file
@ -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
|
@ -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
|
||||
|
@ -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
Normal file
52
nova/api/openstack/placement/context.py
Normal file
@ -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
|
@ -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.")
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
Normal file
23
nova/api/openstack/placement/policies/__init__.py
Normal file
@ -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
Normal file
41
nova/api/openstack/placement/policies/base.py
Normal file
@ -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
Normal file
81
nova/api/openstack/placement/policies/resource_provider.py
Normal file
@ -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
|
@ -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})
|
||||
|
@ -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.'),
|
||||
]
|
||||
|
||||
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
|
@ -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
Normal file
68
nova/tests/unit/api/openstack/placement/test_context.py
Normal file
@ -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
Normal file
80
nova/tests/unit/api/openstack/placement/test_policy.py
Normal file
@ -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))
|
@ -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)
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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
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…
Reference in New Issue
Block a user