Merge "RequirementsAuthority multi role support enhancement"
This commit is contained in:
commit
ba9829d547
|
@ -7,8 +7,9 @@ Overview
|
||||||
--------
|
--------
|
||||||
|
|
||||||
Requirements-driven approach to declaring the expected RBAC test results
|
Requirements-driven approach to declaring the expected RBAC test results
|
||||||
referenced by Patrole. Uses a high-level YAML syntax to crystallize policy
|
referenced by Patrole. These requirements express the *intention* behind the
|
||||||
requirements concisely and unambiguously.
|
policy. A high-level YAML syntax is used to concisely and clearly map each
|
||||||
|
policy action to the list of associated roles.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
|
@ -29,10 +30,6 @@ expected test results by performing lookups against a
|
||||||
:ref:`custom-requirements-file` which precisely defines the cloud's RBAC
|
:ref:`custom-requirements-file` which precisely defines the cloud's RBAC
|
||||||
requirements.
|
requirements.
|
||||||
|
|
||||||
Using a high-level declarative language, the requirements are captured
|
|
||||||
unambiguously in the :ref:`custom-requirements-file`, allowing operators to
|
|
||||||
validate their requirements against their OpenStack cloud.
|
|
||||||
|
|
||||||
This validation approach should be used when:
|
This validation approach should be used when:
|
||||||
|
|
||||||
* The cloud has heavily customized policy files that require careful validation
|
* The cloud has heavily customized policy files that require careful validation
|
||||||
|
@ -74,28 +71,84 @@ file should be written as follows:
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
<service_foo>:
|
<service_foo>:
|
||||||
<api_action_a>:
|
<logical_or_example>:
|
||||||
- <allowed_role_1>
|
- <allowed_role_1>
|
||||||
- <allowed_role_2>
|
- <allowed_role_2>
|
||||||
- <allowed_role_3>
|
<logical_and_example>:
|
||||||
<api_action_b>:
|
- <allowed_role_3>, <allowed_role_4>
|
||||||
- <allowed_role_2>
|
|
||||||
- <allowed_role_4>
|
|
||||||
<service_bar>:
|
<service_bar>:
|
||||||
<api_action_c>:
|
<logical_not_example>:
|
||||||
- <allowed_role_3>
|
- <!disallowed_role_5>
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
|
|
||||||
service = the service that is being tested (Cinder, Nova, etc.).
|
* ``service`` - the service that is being tested (Cinder, Nova, etc.).
|
||||||
|
* ``api_action`` - the policy action that is being tested. Examples:
|
||||||
api_action = the policy action that is being tested. Examples:
|
|
||||||
|
|
||||||
* volume:create
|
* volume:create
|
||||||
* os_compute_api:servers:start
|
* os_compute_api:servers:start
|
||||||
* add_image
|
* add_image
|
||||||
|
|
||||||
allowed_role = the ``oslo.policy`` role that is allowed to perform the API.
|
* ``allowed_role`` - the ``oslo.policy`` role that is allowed to perform the
|
||||||
|
API.
|
||||||
|
|
||||||
|
Each item under ``logical_or_example`` is "logical OR"-ed together. Each role
|
||||||
|
in the comma-separated string under ``logical_and_example`` is "logical AND"-ed
|
||||||
|
together. And each item prefixed with "!" under ``logical_not_example`` is
|
||||||
|
"logical negated".
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
The custom requirements file only allows policy actions to be mapped to
|
||||||
|
the associated roles that define it. Complex ``oslo.policy`` constructs
|
||||||
|
like ``literals`` or ``GenericChecks`` are not supported. For more
|
||||||
|
information, reference the `oslo.policy documentation`_.
|
||||||
|
|
||||||
|
.. _oslo.policy documentation: https://docs.openstack.org/oslo.policy/latest/reference/api/oslo_policy.policy.html#policy-rule-expressions
|
||||||
|
|
||||||
|
Examples
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
Items within ``api_action`` are considered as logical or, so you may read:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
<service_foo>:
|
||||||
|
# "api_action_a: allowed_role_1 or allowed_role_2 or allowed_role_3"
|
||||||
|
<api_action_a>:
|
||||||
|
- <allowed_role_1>
|
||||||
|
- <allowed_role_2>
|
||||||
|
- <allowed_role_3>
|
||||||
|
|
||||||
|
as ``<allowed_role_1> or <allowed_role_2> or <allowed_role_3>``.
|
||||||
|
|
||||||
|
Roles within comma-separated items are considered as logic and, so you may
|
||||||
|
read:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
<service_foo>:
|
||||||
|
# "api_action_a: (allowed_role_1 and allowed_role_2) or allowed_role_3"
|
||||||
|
<api_action_a>:
|
||||||
|
- <allowed_role_1>, <allowed_role_2>
|
||||||
|
- <allowed_role_3>
|
||||||
|
|
||||||
|
as ``<allowed_role_1> and <allowed_role_2> or <allowed_role_3>``.
|
||||||
|
|
||||||
|
Also negative roles may be defined with an exclamation mark ahead of role:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
<service_foo>:
|
||||||
|
# "api_action_a: (allowed_role_1 and allowed_role_2 and not
|
||||||
|
# disallowed_role_4) or allowed_role_3"
|
||||||
|
<api_action_a>:
|
||||||
|
- <allowed_role_1>, <allowed_role_2>, !<disallowed_role_4>
|
||||||
|
- <allowed_role_3>
|
||||||
|
|
||||||
|
This example must be read as ``<allowed_role_1> and <allowed_role_2> and not
|
||||||
|
<disallowed_role_4> or <allowed_role_3>``.
|
||||||
|
|
||||||
|
|
||||||
Implementation
|
Implementation
|
||||||
--------------
|
--------------
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
import copy
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from oslo_log import log as logging
|
from oslo_log import log as logging
|
||||||
|
@ -50,7 +51,7 @@ class RequirementsParser(object):
|
||||||
<service_foo>:
|
<service_foo>:
|
||||||
<api_action_a>:
|
<api_action_a>:
|
||||||
- <allowed_role_1>
|
- <allowed_role_1>
|
||||||
- <allowed_role_2>
|
- <allowed_role_2>,<allowed_role_3>
|
||||||
- <allowed_role_3>
|
- <allowed_role_3>
|
||||||
<api_action_b>:
|
<api_action_b>:
|
||||||
- <allowed_role_2>
|
- <allowed_role_2>
|
||||||
|
@ -67,7 +68,16 @@ class RequirementsParser(object):
|
||||||
try:
|
try:
|
||||||
for section in RequirementsParser.Inner._rbac_map:
|
for section in RequirementsParser.Inner._rbac_map:
|
||||||
if component in section:
|
if component in section:
|
||||||
return section[component]
|
rules = copy.copy(section[component])
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
rules[rule] = [
|
||||||
|
roles.split(',') for roles in rules[rule]]
|
||||||
|
|
||||||
|
for i, role_pack in enumerate(rules[rule]):
|
||||||
|
rules[rule][i] = [r.strip() for r in role_pack]
|
||||||
|
|
||||||
|
return rules
|
||||||
except yaml.parser.ParserError:
|
except yaml.parser.ParserError:
|
||||||
LOG.error("Error while parsing the requirements YAML file. Did "
|
LOG.error("Error while parsing the requirements YAML file. Did "
|
||||||
"you pass a valid component name from the test case?")
|
"you pass a valid component name from the test case?")
|
||||||
|
@ -115,8 +125,24 @@ class RequirementsAuthority(RbacAuthority):
|
||||||
"empty. Ensure the requirements YAML file is correctly "
|
"empty. Ensure the requirements YAML file is correctly "
|
||||||
"formatted.")
|
"formatted.")
|
||||||
try:
|
try:
|
||||||
_api = self.roles_dict[rule_name]
|
requirement_roles = self.roles_dict[rule_name]
|
||||||
return all(role in _api for role in roles)
|
|
||||||
|
for role_reqs in requirement_roles:
|
||||||
|
required_roles = [
|
||||||
|
role for role in role_reqs if not role.startswith("!")]
|
||||||
|
forbidden_roles = [
|
||||||
|
role[1:] for role in role_reqs if role.startswith("!")]
|
||||||
|
|
||||||
|
# User must have all required roles
|
||||||
|
required_passed = all([r in roles for r in required_roles])
|
||||||
|
# User must not have any forbidden roles
|
||||||
|
forbidden_passed = all([r not in forbidden_roles
|
||||||
|
for r in roles])
|
||||||
|
|
||||||
|
if required_passed and forbidden_passed:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise KeyError("'%s' API is not defined in the requirements YAML "
|
raise KeyError("'%s' API is not defined in the requirements YAML "
|
||||||
"file" % rule_name)
|
"file" % rule_name)
|
||||||
|
|
|
@ -4,3 +4,7 @@ Test:
|
||||||
- _member_
|
- _member_
|
||||||
test:create2:
|
test:create2:
|
||||||
- test_member
|
- test_member
|
||||||
|
test:create3:
|
||||||
|
- test_member, _member_
|
||||||
|
test:create4:
|
||||||
|
- test_member, !_member_
|
|
@ -20,16 +20,25 @@ from tempest.tests import base
|
||||||
from patrole_tempest_plugin import requirements_authority as req_auth
|
from patrole_tempest_plugin import requirements_authority as req_auth
|
||||||
|
|
||||||
|
|
||||||
class RequirementsAuthorityTest(base.TestCase):
|
class BaseRequirementsAuthorityTest(base.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(RequirementsAuthorityTest, self).setUp()
|
super(BaseRequirementsAuthorityTest, self).setUp()
|
||||||
self.rbac_auth = req_auth.RequirementsAuthority()
|
self.rbac_auth = req_auth.RequirementsAuthority()
|
||||||
self.current_directory = os.path.dirname(os.path.realpath(__file__))
|
self.current_directory = os.path.dirname(os.path.realpath(__file__))
|
||||||
self.yaml_test_file = os.path.join(self.current_directory,
|
self.yaml_test_file = os.path.join(self.current_directory,
|
||||||
'resources',
|
'resources',
|
||||||
'rbac_roles.yaml')
|
'rbac_roles.yaml')
|
||||||
self.expected_result = {'test:create': ['test_member', '_member_'],
|
self.expected_result = {'test:create': [['test_member'], ['_member_']],
|
||||||
'test:create2': ['test_member']}
|
'test:create2': [['test_member']],
|
||||||
|
'test:create3': [['test_member', '_member_']],
|
||||||
|
'test:create4': [['test_member', '!_member_']]}
|
||||||
|
self.expected_rbac_map = {'test:create': ['test_member', '_member_'],
|
||||||
|
'test:create2': ['test_member'],
|
||||||
|
'test:create3': ['test_member, _member_'],
|
||||||
|
'test:create4': ['test_member, !_member_']}
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsAuthorityTest(BaseRequirementsAuthorityTest):
|
||||||
|
|
||||||
def test_requirements_auth_init(self):
|
def test_requirements_auth_init(self):
|
||||||
rbac_auth = req_auth.RequirementsAuthority(self.yaml_test_file, 'Test')
|
rbac_auth = req_auth.RequirementsAuthority(self.yaml_test_file, 'Test')
|
||||||
|
@ -41,11 +50,11 @@ class RequirementsAuthorityTest(base.TestCase):
|
||||||
self.rbac_auth.allowed, "", [""])
|
self.rbac_auth.allowed, "", [""])
|
||||||
|
|
||||||
def test_auth_allowed_role_in_api(self):
|
def test_auth_allowed_role_in_api(self):
|
||||||
self.rbac_auth.roles_dict = {'api': ['_member_']}
|
self.rbac_auth.roles_dict = {'api': [['_member_']]}
|
||||||
self.assertTrue(self.rbac_auth.allowed("api", ["_member_"]))
|
self.assertTrue(self.rbac_auth.allowed("api", ["_member_"]))
|
||||||
|
|
||||||
def test_auth_allowed_role_not_in_api(self):
|
def test_auth_allowed_role_not_in_api(self):
|
||||||
self.rbac_auth.roles_dict = {'api': ['_member_']}
|
self.rbac_auth.roles_dict = {'api': [['_member_']]}
|
||||||
self.assertFalse(self.rbac_auth.allowed("api", "support_member"))
|
self.assertFalse(self.rbac_auth.allowed("api", "support_member"))
|
||||||
|
|
||||||
def test_parser_get_allowed_except_keyerror(self):
|
def test_parser_get_allowed_except_keyerror(self):
|
||||||
|
@ -55,12 +64,12 @@ class RequirementsAuthorityTest(base.TestCase):
|
||||||
|
|
||||||
def test_parser_init(self):
|
def test_parser_init(self):
|
||||||
req_auth.RequirementsParser(self.yaml_test_file)
|
req_auth.RequirementsParser(self.yaml_test_file)
|
||||||
self.assertEqual([{'Test': self.expected_result}],
|
self.assertEqual([{'Test': self.expected_rbac_map}],
|
||||||
req_auth.RequirementsParser.Inner._rbac_map)
|
req_auth.RequirementsParser.Inner._rbac_map)
|
||||||
|
|
||||||
def test_parser_role_in_api(self):
|
def test_parser_role_in_api(self):
|
||||||
req_auth.RequirementsParser.Inner._rbac_map = \
|
req_auth.RequirementsParser.Inner._rbac_map = \
|
||||||
[{'Test': self.expected_result}]
|
[{'Test': self.expected_rbac_map}]
|
||||||
self.rbac_auth.roles_dict = req_auth.RequirementsParser.parse("Test")
|
self.rbac_auth.roles_dict = req_auth.RequirementsParser.parse("Test")
|
||||||
|
|
||||||
self.assertEqual(self.expected_result, self.rbac_auth.roles_dict)
|
self.assertEqual(self.expected_result, self.rbac_auth.roles_dict)
|
||||||
|
@ -69,7 +78,7 @@ class RequirementsAuthorityTest(base.TestCase):
|
||||||
|
|
||||||
def test_parser_role_not_in_api(self):
|
def test_parser_role_not_in_api(self):
|
||||||
req_auth.RequirementsParser.Inner._rbac_map = \
|
req_auth.RequirementsParser.Inner._rbac_map = \
|
||||||
[{'Test': self.expected_result}]
|
[{'Test': self.expected_rbac_map}]
|
||||||
self.rbac_auth.roles_dict = req_auth.RequirementsParser.parse("Test")
|
self.rbac_auth.roles_dict = req_auth.RequirementsParser.parse("Test")
|
||||||
|
|
||||||
self.assertEqual(self.expected_result, self.rbac_auth.roles_dict)
|
self.assertEqual(self.expected_result, self.rbac_auth.roles_dict)
|
||||||
|
@ -77,10 +86,102 @@ class RequirementsAuthorityTest(base.TestCase):
|
||||||
|
|
||||||
def test_parser_except_invalid_configuration(self):
|
def test_parser_except_invalid_configuration(self):
|
||||||
req_auth.RequirementsParser.Inner._rbac_map = \
|
req_auth.RequirementsParser.Inner._rbac_map = \
|
||||||
[{'Test': self.expected_result}]
|
[{'Test': self.expected_rbac_map}]
|
||||||
self.rbac_auth.roles_dict = \
|
self.rbac_auth.roles_dict = \
|
||||||
req_auth.RequirementsParser.parse("Failure")
|
req_auth.RequirementsParser.parse("Failure")
|
||||||
|
|
||||||
self.assertIsNone(self.rbac_auth.roles_dict)
|
self.assertIsNone(self.rbac_auth.roles_dict)
|
||||||
self.assertRaises(exceptions.InvalidConfiguration,
|
self.assertRaises(exceptions.InvalidConfiguration,
|
||||||
self.rbac_auth.allowed, "", [""])
|
self.rbac_auth.allowed, "", [""])
|
||||||
|
|
||||||
|
def test_auth_allowed_exclamation_mark_syntax_single_role(self):
|
||||||
|
"""Ensure that exclamation mark in front of role is dropped, and not
|
||||||
|
considered as part of role itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.rbac_auth.roles_dict = {'api': [['!admin']]}
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member"]))
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["!admin"]))
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["admin"]))
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementsAuthorityMultiRoleTest(BaseRequirementsAuthorityTest):
|
||||||
|
|
||||||
|
def test_auth_allowed_exclamation_mark_syntax_multi_role(self):
|
||||||
|
"""Ensure that exclamation mark in front of role is dropped, and not
|
||||||
|
considered as part of role itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.rbac_auth.roles_dict = {'api': [['member', '!admin']]}
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["member", "admin"]))
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member", "!admin"]))
|
||||||
|
|
||||||
|
def test_auth_allowed_single_rule_scenario(self):
|
||||||
|
# member and support and not admin and not manager
|
||||||
|
self.rbac_auth.roles_dict = {'api': [['member', 'support',
|
||||||
|
'!admin', '!manager']]}
|
||||||
|
|
||||||
|
# User is member and support and not manager or admin
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"support"]))
|
||||||
|
|
||||||
|
# User is member and not manager or admin, but not support
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["member"]))
|
||||||
|
|
||||||
|
# User is support and not manager or admin, but not member
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["support"]))
|
||||||
|
|
||||||
|
# User is member and support and not manager, but have admin role
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"support",
|
||||||
|
"admin"]))
|
||||||
|
|
||||||
|
# User is member and not manager, but have admin role and not support
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"admin"]))
|
||||||
|
|
||||||
|
# User is member and support, but have manager and admin roles
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"support",
|
||||||
|
"admin",
|
||||||
|
"manager"]))
|
||||||
|
|
||||||
|
def test_auth_allowed_multi_rule_scenario(self):
|
||||||
|
rules = [
|
||||||
|
['member', 'support', '!admin', '!manager'],
|
||||||
|
['member', 'admin'],
|
||||||
|
["manager"]
|
||||||
|
]
|
||||||
|
self.rbac_auth.roles_dict = {'api': rules}
|
||||||
|
|
||||||
|
# Not a single role allows viewer
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["viewer"]))
|
||||||
|
# We have no rule that allows support and admin
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["support",
|
||||||
|
"admin"]))
|
||||||
|
# There is no rule that requires member without additional requirements
|
||||||
|
self.assertFalse(self.rbac_auth.allowed("api", ["member"]))
|
||||||
|
|
||||||
|
# Pass with rules[2]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["manager"]))
|
||||||
|
# Pass with rules[0]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"support"]))
|
||||||
|
# Pass with rules[1]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"admin"]))
|
||||||
|
# Pass with rules[2]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["manager",
|
||||||
|
"admin"]))
|
||||||
|
# Pass with rules[1]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"support",
|
||||||
|
"admin"]))
|
||||||
|
# Pass with rules[1]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["member",
|
||||||
|
"support",
|
||||||
|
"admin",
|
||||||
|
"manager"]))
|
||||||
|
# Pass with rules[2]
|
||||||
|
self.assertTrue(self.rbac_auth.allowed("api", ["admin",
|
||||||
|
"manager"]))
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
The ``requirements_authority`` module now supports the following 3 cases:
|
||||||
|
|
||||||
|
* logical or operation of roles (existing functionality)
|
||||||
|
* logical and operation of roles (new functionality)
|
||||||
|
* logical not operation of roles (new functionality)
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
<service_foo>:
|
||||||
|
<logical_or_example>:
|
||||||
|
- <allowed_role_1>
|
||||||
|
- <allowed_role_2>
|
||||||
|
<logical_and_example>:
|
||||||
|
- <allowed_role_3>, <allowed_role_4>
|
||||||
|
<service_bar>:
|
||||||
|
<logical_not_example>:
|
||||||
|
- <!disallowed_role_5>
|
||||||
|
|
||||||
|
Each item under ``logical_or_example`` is "logical OR"-ed together. Each
|
||||||
|
role in the comma-separated string under ``logical_and_example`` is
|
||||||
|
"logical AND"-ed together. And each item prefixed with "!" under
|
||||||
|
``logical_not_example`` is "logical negated".
|
||||||
|
|
||||||
|
This allows for expressing many more complex cases using the
|
||||||
|
``requirements_authority`` YAML syntax. For example, the policy rule
|
||||||
|
(i.e. what may exist in a ``policy.yaml`` file)::
|
||||||
|
|
||||||
|
"foo_rule: (role:a and not role:b) or role:c"
|
||||||
|
|
||||||
|
May now be expressed using the YAML syntax as::
|
||||||
|
|
||||||
|
foo_rule:
|
||||||
|
- a, !b
|
||||||
|
- c
|
Loading…
Reference in New Issue