Add support for app cred access rules

This commit introduces the --access-rules option for 'application
credential create' as well as new 'access rule' commands for listing,
showing, and deleting access rules.

bp whitelist-extension-for-app-creds

Change-Id: I04834b2874ec2a70da456a380b5bef03a392effa
This commit is contained in:
Colleen Murphy 2019-08-21 17:38:29 -07:00 committed by Colleen Murphy
parent db29e28b7c
commit 70ab3f9dd5
11 changed files with 548 additions and 11 deletions

View File

@ -0,0 +1,61 @@
===========
access rule
===========
Identity v3
Access rules are fine-grained permissions for application credentials. An access
rule comprises of a service type, a request path, and a request method. Access
rules may only be created as attributes of application credentials, but they may
be viewed and deleted independently.
access rule delete
------------------
Delete access rule(s)
.. program:: access rule delete
.. code:: bash
openstack access rule delete <access-rule> [<access-rule> ...]
.. describe:: <access-rule>
Access rule(s) to delete (ID)
access rule list
----------------
List access rules
.. program:: access rule list
.. code:: bash
openstack access rule list
[--user <user>]
[--user-domain <user-domain>]
.. option:: --user
User whose access rules to list (name or ID). If not provided, looks up the
current user's access rules.
.. option:: --user-domain
Domain the user belongs to (name or ID). This can be
used in case collisions between user names exist.
access rule show
---------------------------
Display access rule details
.. program:: access rule show
.. code:: bash
openstack access rule show <access-rule>
.. describe:: <access-rule>
Access rule to display (ID)

View File

@ -22,6 +22,7 @@ Create new application credential
[--expiration <expiration>] [--expiration <expiration>]
[--description <description>] [--description <description>]
[--restricted|--unrestricted] [--restricted|--unrestricted]
[--access-rules <access-rules>]
<name> <name>
.. option:: --secret <secret> .. option:: --secret <secret>
@ -52,6 +53,12 @@ Create new application credential
Prohibit application credential from creating and deleting other Prohibit application credential from creating and deleting other
application credentials and trusts (this is the default behavior) application credentials and trusts (this is the default behavior)
.. option:: --access-rules
Either a string or file path containing a JSON-formatted list of access
rules, each containing a request method, path, and service, for example
'[{"method": "GET", "path": "/v2.1/servers", "service": "compute"}]'
.. describe:: <name> .. describe:: <name>
Name of the application credential Name of the application credential

View File

@ -95,7 +95,7 @@ python-heatclient==1.10.0
python-ironic-inspector-client==1.5.0 python-ironic-inspector-client==1.5.0
python-ironicclient==2.3.0 python-ironicclient==2.3.0
python-karborclient==0.6.0 python-karborclient==0.6.0
python-keystoneclient==3.17.0 python-keystoneclient==3.22.0
python-mimeparse==1.6.0 python-mimeparse==1.6.0
python-mistralclient==3.1.0 python-mistralclient==3.1.0
python-muranoclient==0.8.2 python-muranoclient==0.8.2

View File

@ -0,0 +1,118 @@
# Copyright 2019 SUSE LLC
#
# 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.
#
"""Identity v3 Access Rule action implementations"""
import logging
from osc_lib.command import command
from osc_lib import exceptions
from osc_lib import utils
import six
from openstackclient.i18n import _
from openstackclient.identity import common
LOG = logging.getLogger(__name__)
class DeleteAccessRule(command.Command):
_description = _("Delete access rule(s)")
def get_parser(self, prog_name):
parser = super(DeleteAccessRule, self).get_parser(prog_name)
parser.add_argument(
'access_rule',
metavar='<access-rule>',
nargs="+",
help=_('Application credentials(s) to delete (name or ID)'),
)
return parser
def take_action(self, parsed_args):
identity_client = self.app.client_manager.identity
errors = 0
for ac in parsed_args.access_rule:
try:
access_rule = utils.find_resource(
identity_client.access_rules, ac)
identity_client.access_rules.delete(access_rule.id)
except Exception as e:
errors += 1
LOG.error(_("Failed to delete access rule with "
"ID '%(ac)s': %(e)s"),
{'ac': ac, 'e': e})
if errors > 0:
total = len(parsed_args.access_rule)
msg = (_("%(errors)s of %(total)s access rules failed "
"to delete.") % {'errors': errors, 'total': total})
raise exceptions.CommandError(msg)
class ListAccessRule(command.Lister):
_description = _("List access rules")
def get_parser(self, prog_name):
parser = super(ListAccessRule, self).get_parser(prog_name)
parser.add_argument(
'--user',
metavar='<user>',
help=_('User whose access rules to list (name or ID)'),
)
common.add_user_domain_option_to_parser(parser)
return parser
def take_action(self, parsed_args):
identity_client = self.app.client_manager.identity
if parsed_args.user:
user_id = common.find_user(identity_client,
parsed_args.user,
parsed_args.user_domain).id
else:
user_id = None
columns = ('ID', 'Service', 'Method', 'Path')
data = identity_client.access_rules.list(
user=user_id)
return (columns,
(utils.get_item_properties(
s, columns,
formatters={},
) for s in data))
class ShowAccessRule(command.ShowOne):
_description = _("Display access rule details")
def get_parser(self, prog_name):
parser = super(ShowAccessRule, self).get_parser(prog_name)
parser.add_argument(
'access_rule',
metavar='<access-rule>',
help=_('Application credential to display (name or ID)'),
)
return parser
def take_action(self, parsed_args):
identity_client = self.app.client_manager.identity
access_rule = utils.find_resource(identity_client.access_rules,
parsed_args.access_rule)
access_rule._info.pop('links', None)
return zip(*sorted(six.iteritems(access_rule._info)))

View File

@ -16,6 +16,7 @@
"""Identity v3 Application Credential action implementations""" """Identity v3 Application Credential action implementations"""
import datetime import datetime
import json
import logging import logging
from osc_lib.command import command from osc_lib.command import command
@ -79,6 +80,17 @@ class CreateApplicationCredential(command.ShowOne):
' other application credentials and trusts (this is the' ' other application credentials and trusts (this is the'
' default behavior)'), ' default behavior)'),
) )
parser.add_argument(
'--access-rules',
metavar='<access-rules>',
help=_('Either a string or file path containing a JSON-formatted '
'list of access rules, each containing a request method, '
'path, and service, for example '
'\'[{"method": "GET", '
'"path": "/v2.1/servers", '
'"service": "compute"}]\''),
)
return parser return parser
def take_action(self, parsed_args): def take_action(self, parsed_args):
@ -105,6 +117,20 @@ class CreateApplicationCredential(command.ShowOne):
else: else:
unrestricted = parsed_args.unrestricted unrestricted = parsed_args.unrestricted
if parsed_args.access_rules:
try:
access_rules = json.loads(parsed_args.access_rules)
except ValueError:
try:
with open(parsed_args.access_rules) as f:
access_rules = json.load(f)
except IOError:
raise exceptions.CommandError(
_("Access rules is not valid JSON string or file does"
" not exist."))
else:
access_rules = None
app_cred_manager = identity_client.application_credentials app_cred_manager = identity_client.application_credentials
application_credential = app_cred_manager.create( application_credential = app_cred_manager.create(
parsed_args.name, parsed_args.name,
@ -113,6 +139,7 @@ class CreateApplicationCredential(command.ShowOne):
description=parsed_args.description, description=parsed_args.description,
secret=parsed_args.secret, secret=parsed_args.secret,
unrestricted=unrestricted, unrestricted=unrestricted,
access_rules=access_rules,
) )
application_credential._info.pop('links', None) application_credential._info.pop('links', None)

View File

@ -470,6 +470,14 @@ app_cred_description = 'app credential for testing'
app_cred_expires = datetime.datetime(2022, 1, 1, 0, 0) app_cred_expires = datetime.datetime(2022, 1, 1, 0, 0)
app_cred_expires_str = app_cred_expires.strftime('%Y-%m-%dT%H:%M:%S%z') app_cred_expires_str = app_cred_expires.strftime('%Y-%m-%dT%H:%M:%S%z')
app_cred_secret = 'moresecuresecret' app_cred_secret = 'moresecuresecret'
app_cred_access_rules = (
'[{"path": "/v2.1/servers", "method": "GET", "service": "compute"}]'
)
app_cred_access_rules_path = '/tmp/access_rules.json'
access_rule_id = 'access-rule-id'
access_rule_service = 'compute'
access_rule_path = '/v2.1/servers'
access_rule_method = 'GET'
APP_CRED_BASIC = { APP_CRED_BASIC = {
'id': app_cred_id, 'id': app_cred_id,
'name': app_cred_name, 'name': app_cred_name,
@ -478,7 +486,8 @@ APP_CRED_BASIC = {
'description': None, 'description': None,
'expires_at': None, 'expires_at': None,
'unrestricted': False, 'unrestricted': False,
'secret': app_cred_secret 'secret': app_cred_secret,
'access_rules': None
} }
APP_CRED_OPTIONS = { APP_CRED_OPTIONS = {
'id': app_cred_id, 'id': app_cred_id,
@ -488,7 +497,25 @@ APP_CRED_OPTIONS = {
'description': app_cred_description, 'description': app_cred_description,
'expires_at': app_cred_expires_str, 'expires_at': app_cred_expires_str,
'unrestricted': False, 'unrestricted': False,
'secret': app_cred_secret 'secret': app_cred_secret,
'access_rules': None,
}
ACCESS_RULE = {
'id': access_rule_id,
'service': access_rule_service,
'path': access_rule_path,
'method': access_rule_method,
}
APP_CRED_ACCESS_RULES = {
'id': app_cred_id,
'name': app_cred_name,
'project_id': project_id,
'roles': app_cred_role,
'description': None,
'expires_at': None,
'unrestricted': False,
'secret': app_cred_secret,
'access_rules': app_cred_access_rules
} }
registered_limit_id = 'registered-limit-id' registered_limit_id = 'registered-limit-id'
@ -625,6 +652,8 @@ class FakeIdentityv3Client(object):
self.application_credentials = mock.Mock() self.application_credentials = mock.Mock()
self.application_credentials.resource_class = fakes.FakeResource(None, self.application_credentials.resource_class = fakes.FakeResource(None,
{}) {})
self.access_rules = mock.Mock()
self.access_rules.resource_class = fakes.FakeResource(None, {})
self.inference_rules = mock.Mock() self.inference_rules = mock.Mock()
self.inference_rules.resource_class = fakes.FakeResource(None, {}) self.inference_rules.resource_class = fakes.FakeResource(None, {})
self.registered_limits = mock.Mock() self.registered_limits = mock.Mock()

View File

@ -0,0 +1,174 @@
# Copyright 2019 SUSE LLC
#
# 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 copy
import mock
from osc_lib import exceptions
from osc_lib import utils
from openstackclient.identity.v3 import access_rule
from openstackclient.tests.unit import fakes
from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes
class TestAccessRule(identity_fakes.TestIdentityv3):
def setUp(self):
super(TestAccessRule, self).setUp()
identity_manager = self.app.client_manager.identity
self.access_rules_mock = identity_manager.access_rules
self.access_rules_mock.reset_mock()
self.roles_mock = identity_manager.roles
self.roles_mock.reset_mock()
class TestAccessRuleDelete(TestAccessRule):
def setUp(self):
super(TestAccessRuleDelete, self).setUp()
# This is the return value for utils.find_resource()
self.access_rules_mock.get.return_value = fakes.FakeResource(
None,
copy.deepcopy(identity_fakes.ACCESS_RULE),
loaded=True,
)
self.access_rules_mock.delete.return_value = None
# Get the command object to test
self.cmd = access_rule.DeleteAccessRule(
self.app, None)
def test_access_rule_delete(self):
arglist = [
identity_fakes.access_rule_id,
]
verifylist = [
('access_rule', [identity_fakes.access_rule_id])
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
self.access_rules_mock.delete.assert_called_with(
identity_fakes.access_rule_id,
)
self.assertIsNone(result)
@mock.patch.object(utils, 'find_resource')
def test_delete_multi_access_rules_with_exception(self, find_mock):
find_mock.side_effect = [self.access_rules_mock.get.return_value,
exceptions.CommandError]
arglist = [
identity_fakes.access_rule_id,
'nonexistent_access_rule',
]
verifylist = [
('access_rule', arglist),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
try:
self.cmd.take_action(parsed_args)
self.fail('CommandError should be raised.')
except exceptions.CommandError as e:
self.assertEqual('1 of 2 access rules failed to'
' delete.', str(e))
find_mock.assert_any_call(self.access_rules_mock,
identity_fakes.access_rule_id)
find_mock.assert_any_call(self.access_rules_mock,
'nonexistent_access_rule')
self.assertEqual(2, find_mock.call_count)
self.access_rules_mock.delete.assert_called_once_with(
identity_fakes.access_rule_id)
class TestAccessRuleList(TestAccessRule):
def setUp(self):
super(TestAccessRuleList, self).setUp()
self.access_rules_mock.list.return_value = [
fakes.FakeResource(
None,
copy.deepcopy(identity_fakes.ACCESS_RULE),
loaded=True,
),
]
# Get the command object to test
self.cmd = access_rule.ListAccessRule(self.app, None)
def test_access_rule_list(self):
arglist = []
verifylist = []
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.access_rules_mock.list.assert_called_with(user=None)
collist = ('ID', 'Service', 'Method', 'Path')
self.assertEqual(collist, columns)
datalist = ((
identity_fakes.access_rule_id,
identity_fakes.access_rule_service,
identity_fakes.access_rule_method,
identity_fakes.access_rule_path,
), )
self.assertEqual(datalist, tuple(data))
class TestAccessRuleShow(TestAccessRule):
def setUp(self):
super(TestAccessRuleShow, self).setUp()
self.access_rules_mock.get.return_value = fakes.FakeResource(
None,
copy.deepcopy(identity_fakes.ACCESS_RULE),
loaded=True,
)
# Get the command object to test
self.cmd = access_rule.ShowAccessRule(self.app, None)
def test_access_rule_show(self):
arglist = [
identity_fakes.access_rule_id,
]
verifylist = [
('access_rule', identity_fakes.access_rule_id),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.access_rules_mock.get.assert_called_with(
identity_fakes.access_rule_id)
collist = ('id', 'method', 'path', 'service')
self.assertEqual(collist, columns)
datalist = (
identity_fakes.access_rule_id,
identity_fakes.access_rule_method,
identity_fakes.access_rule_path,
identity_fakes.access_rule_service,
)
self.assertEqual(datalist, data)

View File

@ -14,6 +14,7 @@
# #
import copy import copy
import json
from unittest import mock from unittest import mock
from osc_lib import exceptions from osc_lib import exceptions
@ -79,16 +80,18 @@ class TestApplicationCredentialCreate(TestApplicationCredential):
'expires_at': None, 'expires_at': None,
'description': None, 'description': None,
'unrestricted': False, 'unrestricted': False,
'access_rules': None,
} }
self.app_creds_mock.create.assert_called_with( self.app_creds_mock.create.assert_called_with(
name, name,
**kwargs **kwargs
) )
collist = ('description', 'expires_at', 'id', 'name', 'project_id', collist = ('access_rules', 'description', 'expires_at', 'id', 'name',
'roles', 'secret', 'unrestricted') 'project_id', 'roles', 'secret', 'unrestricted')
self.assertEqual(collist, columns) self.assertEqual(collist, columns)
datalist = ( datalist = (
None,
None, None,
None, None,
identity_fakes.app_cred_id, identity_fakes.app_cred_id,
@ -135,17 +138,19 @@ class TestApplicationCredentialCreate(TestApplicationCredential):
'roles': [identity_fakes.role_id], 'roles': [identity_fakes.role_id],
'expires_at': identity_fakes.app_cred_expires, 'expires_at': identity_fakes.app_cred_expires,
'description': 'credential for testing', 'description': 'credential for testing',
'unrestricted': False 'unrestricted': False,
'access_rules': None,
} }
self.app_creds_mock.create.assert_called_with( self.app_creds_mock.create.assert_called_with(
name, name,
**kwargs **kwargs
) )
collist = ('description', 'expires_at', 'id', 'name', 'project_id', collist = ('access_rules', 'description', 'expires_at', 'id', 'name',
'roles', 'secret', 'unrestricted') 'project_id', 'roles', 'secret', 'unrestricted')
self.assertEqual(collist, columns) self.assertEqual(collist, columns)
datalist = ( datalist = (
None,
identity_fakes.app_cred_description, identity_fakes.app_cred_description,
identity_fakes.app_cred_expires_str, identity_fakes.app_cred_expires_str,
identity_fakes.app_cred_id, identity_fakes.app_cred_id,
@ -157,6 +162,111 @@ class TestApplicationCredentialCreate(TestApplicationCredential):
) )
self.assertEqual(datalist, data) self.assertEqual(datalist, data)
def test_application_credential_create_with_access_rules_string(self):
name = identity_fakes.app_cred_name
self.app_creds_mock.create.return_value = fakes.FakeResource(
None,
copy.deepcopy(identity_fakes.APP_CRED_ACCESS_RULES),
loaded=True,
)
arglist = [
name,
'--access-rules', identity_fakes.app_cred_access_rules,
]
verifylist = [
('name', identity_fakes.app_cred_name),
('access_rules', identity_fakes.app_cred_access_rules),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
# Set expected values
kwargs = {
'secret': None,
'roles': [],
'expires_at': None,
'description': None,
'unrestricted': False,
'access_rules': json.loads(identity_fakes.app_cred_access_rules)
}
self.app_creds_mock.create.assert_called_with(
name,
**kwargs
)
collist = ('access_rules', 'description', 'expires_at', 'id', 'name',
'project_id', 'roles', 'secret', 'unrestricted')
self.assertEqual(collist, columns)
datalist = (
identity_fakes.app_cred_access_rules,
None,
None,
identity_fakes.app_cred_id,
identity_fakes.app_cred_name,
identity_fakes.project_id,
identity_fakes.role_name,
identity_fakes.app_cred_secret,
False,
)
self.assertEqual(datalist, data)
@mock.patch('openstackclient.identity.v3.application_credential.json.load')
@mock.patch('openstackclient.identity.v3.application_credential.open')
def test_application_credential_create_with_access_rules_file(
self, _, mock_json_load):
mock_json_load.return_value = identity_fakes.app_cred_access_rules
name = identity_fakes.app_cred_name
self.app_creds_mock.create.return_value = fakes.FakeResource(
None,
copy.deepcopy(identity_fakes.APP_CRED_ACCESS_RULES),
loaded=True,
)
arglist = [
name,
'--access-rules', identity_fakes.app_cred_access_rules_path,
]
verifylist = [
('name', identity_fakes.app_cred_name),
('access_rules', identity_fakes.app_cred_access_rules_path),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
# Set expected values
kwargs = {
'secret': None,
'roles': [],
'expires_at': None,
'description': None,
'unrestricted': False,
'access_rules': identity_fakes.app_cred_access_rules
}
self.app_creds_mock.create.assert_called_with(
name,
**kwargs
)
collist = ('access_rules', 'description', 'expires_at', 'id', 'name',
'project_id', 'roles', 'secret', 'unrestricted')
self.assertEqual(collist, columns)
datalist = (
identity_fakes.app_cred_access_rules,
None,
None,
identity_fakes.app_cred_id,
identity_fakes.app_cred_name,
identity_fakes.project_id,
identity_fakes.role_name,
identity_fakes.app_cred_secret,
False,
)
self.assertEqual(datalist, data)
class TestApplicationCredentialDelete(TestApplicationCredential): class TestApplicationCredentialDelete(TestApplicationCredential):
@ -293,10 +403,11 @@ class TestApplicationCredentialShow(TestApplicationCredential):
self.app_creds_mock.get.assert_called_with(identity_fakes.app_cred_id) self.app_creds_mock.get.assert_called_with(identity_fakes.app_cred_id)
collist = ('description', 'expires_at', 'id', 'name', 'project_id', collist = ('access_rules', 'description', 'expires_at', 'id', 'name',
'roles', 'secret', 'unrestricted') 'project_id', 'roles', 'secret', 'unrestricted')
self.assertEqual(collist, columns) self.assertEqual(collist, columns)
datalist = ( datalist = (
None,
None, None,
None, None,
identity_fakes.app_cred_id, identity_fakes.app_cred_id,

View File

@ -0,0 +1,6 @@
---
features:
- |
[`blueprint whitelist-extension-for-app-creds <https://blueprints.launchpad.net/keystone/+spec/whitelist-extension-for-app-creds>`_]
Added support for creating access rules as an attribute of application
credentials as well as for listing, showing, and deleting access rules.

View File

@ -12,6 +12,6 @@ osc-lib>=2.0.0 # Apache-2.0
oslo.i18n>=3.15.3 # Apache-2.0 oslo.i18n>=3.15.3 # Apache-2.0
oslo.utils>=3.33.0 # Apache-2.0 oslo.utils>=3.33.0 # Apache-2.0
python-glanceclient>=2.8.0 # Apache-2.0 python-glanceclient>=2.8.0 # Apache-2.0
python-keystoneclient>=3.17.0 # Apache-2.0 python-keystoneclient>=3.22.0 # Apache-2.0
python-novaclient>=15.1.0 # Apache-2.0 python-novaclient>=15.1.0 # Apache-2.0
python-cinderclient>=3.3.0 # Apache-2.0 python-cinderclient>=3.3.0 # Apache-2.0

View File

@ -197,6 +197,10 @@ openstack.identity.v2 =
openstack.identity.v3 = openstack.identity.v3 =
access_token_create = openstackclient.identity.v3.token:CreateAccessToken access_token_create = openstackclient.identity.v3.token:CreateAccessToken
access_rule_delete = openstackclient.identity.v3.access_rule:DeleteAccessRule
access_rule_list = openstackclient.identity.v3.access_rule:ListAccessRule
access_rule_show = openstackclient.identity.v3.access_rule:ShowAccessRule
application_credential_create = openstackclient.identity.v3.application_credential:CreateApplicationCredential application_credential_create = openstackclient.identity.v3.application_credential:CreateApplicationCredential
application_credential_delete = openstackclient.identity.v3.application_credential:DeleteApplicationCredential application_credential_delete = openstackclient.identity.v3.application_credential:DeleteApplicationCredential
application_credential_list = openstackclient.identity.v3.application_credential:ListApplicationCredential application_credential_list = openstackclient.identity.v3.application_credential:ListApplicationCredential