Merge pull request #103 from fnordahl/add-keystone-charm-tests
Add keystone setup and test code
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Copyright 2018 Canonical Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Collection of code for setting up and testing keystone."""
|
||||
import zaza
|
||||
import zaza.charm_tests.test_utils as test_utils
|
||||
import zaza.utilities.openstack as openstack_utils
|
||||
|
||||
DEMO_TENANT = 'demoTenant'
|
||||
DEMO_DOMAIN = 'demoDomain'
|
||||
DEMO_PROJECT = 'demoProject'
|
||||
DEMO_ADMIN_USER = 'demo_admin'
|
||||
DEMO_ADMIN_USER_PASSWORD = 'password'
|
||||
DEMO_USER = 'demo'
|
||||
DEMO_PASSWORD = 'password'
|
||||
|
||||
|
||||
class BaseKeystoneTest(test_utils.OpenStackBaseTest):
|
||||
"""Base for Keystone charm tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Run class setup for running Keystone charm operation tests."""
|
||||
super(BaseKeystoneTest, cls).setUpClass()
|
||||
cls.keystone_ips = zaza.model.get_app_ips('keystone')
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('xenial_queens')):
|
||||
cls.default_api_version = '2'
|
||||
else:
|
||||
cls.default_api_version = '3'
|
||||
@@ -0,0 +1,115 @@
|
||||
# Copyright 2018 Canonical Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Code for setting up keystone."""
|
||||
|
||||
import zaza.utilities.openstack as openstack_utils
|
||||
from zaza.charm_tests.keystone import (
|
||||
BaseKeystoneTest,
|
||||
DEMO_TENANT,
|
||||
DEMO_DOMAIN,
|
||||
DEMO_PROJECT,
|
||||
DEMO_ADMIN_USER,
|
||||
DEMO_ADMIN_USER_PASSWORD,
|
||||
DEMO_USER,
|
||||
DEMO_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def add_demo_user():
|
||||
"""Add a demo user to the current deployment."""
|
||||
def _v2():
|
||||
keystone_session = openstack_utils.get_overcloud_keystone_session()
|
||||
keystone_client = openstack_utils.get_keystone_session_client(
|
||||
keystone_session, client_api_version=2)
|
||||
tenant = keystone_client.tenants.create(tenant_name=DEMO_TENANT,
|
||||
description='Demo Tenant',
|
||||
enabled=True)
|
||||
keystone_client.users.create(name=DEMO_USER,
|
||||
password=DEMO_PASSWORD,
|
||||
tenant_id=tenant.id)
|
||||
|
||||
def _v3():
|
||||
keystone_session = openstack_utils.get_overcloud_keystone_session()
|
||||
keystone_client = openstack_utils.get_keystone_session_client(
|
||||
keystone_session)
|
||||
domain = keystone_client.domains.create(
|
||||
DEMO_DOMAIN,
|
||||
description='Demo Domain',
|
||||
enabled=True)
|
||||
project = keystone_client.projects.create(
|
||||
DEMO_PROJECT,
|
||||
domain,
|
||||
description='Demo Project',
|
||||
enabled=True)
|
||||
demo_user = keystone_client.users.create(
|
||||
DEMO_USER,
|
||||
domain=domain,
|
||||
project=project,
|
||||
password=DEMO_PASSWORD,
|
||||
email='demo@demo.com',
|
||||
description='Demo User',
|
||||
enabled=True)
|
||||
member_role = keystone_client.roles.find(name='Member')
|
||||
keystone_client.roles.grant(
|
||||
member_role,
|
||||
user=demo_user,
|
||||
project_domain=domain,
|
||||
project=project)
|
||||
demo_admin_user = keystone_client.users.create(
|
||||
DEMO_ADMIN_USER,
|
||||
domain=domain,
|
||||
project=project,
|
||||
password=DEMO_ADMIN_USER_PASSWORD,
|
||||
email='demo_admin@demo.com',
|
||||
description='Demo Admin User',
|
||||
enabled=True)
|
||||
admin_role = keystone_client.roles.find(name='Admin')
|
||||
keystone_client.roles.grant(
|
||||
admin_role,
|
||||
user=demo_admin_user,
|
||||
domain=domain)
|
||||
keystone_client.roles.grant(
|
||||
member_role,
|
||||
user=demo_admin_user,
|
||||
project_domain=domain,
|
||||
project=project)
|
||||
keystone_client.roles.grant(
|
||||
admin_role,
|
||||
user=demo_admin_user,
|
||||
project_domain=domain,
|
||||
project=project)
|
||||
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('trusty_mitaka')):
|
||||
# create only V2 user
|
||||
_v2()
|
||||
return
|
||||
|
||||
if (openstack_utils.get_os_release() >=
|
||||
openstack_utils.get_os_release('trusty_mitaka') and
|
||||
openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('xenial_queens')):
|
||||
# create V2 and V3 user
|
||||
_v2()
|
||||
|
||||
_singleton = BaseKeystoneTest()
|
||||
_singleton.setUpClass()
|
||||
with _singleton.config_change(
|
||||
{'preferred-api-version': _singleton.default_api_version},
|
||||
{'preferred-api-version': '3'}):
|
||||
_v3()
|
||||
else:
|
||||
# create only V3 user
|
||||
_v3()
|
||||
@@ -0,0 +1,306 @@
|
||||
# Copyright 2018 Canonical Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Encapsulate keystone testing."""
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
import keystoneauth1
|
||||
|
||||
import zaza.model
|
||||
import zaza.utilities.exceptions as zaza_exceptions
|
||||
import zaza.utilities.juju as juju_utils
|
||||
import zaza.utilities.openstack as openstack_utils
|
||||
|
||||
from zaza.charm_tests.keystone import (
|
||||
BaseKeystoneTest,
|
||||
DEMO_DOMAIN,
|
||||
DEMO_TENANT,
|
||||
DEMO_USER,
|
||||
DEMO_PASSWORD,
|
||||
DEMO_PROJECT,
|
||||
DEMO_ADMIN_USER,
|
||||
DEMO_ADMIN_USER_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
class CharmOperationTest(BaseKeystoneTest):
|
||||
"""Charm operation tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Run class setup for running Keystone charm operation tests."""
|
||||
super(CharmOperationTest, cls).setUpClass()
|
||||
|
||||
def test_pause_resume(self):
|
||||
"""Run pause and resume tests.
|
||||
|
||||
Pause service and check services are stopped, then resume and check
|
||||
they are started.
|
||||
"""
|
||||
self.pause_resume(['apache2'])
|
||||
|
||||
def test_key_distribution_and_rotation(self):
|
||||
"""Verify key rotation.
|
||||
|
||||
Note that we make the assumption that test bundle configure
|
||||
`token-expiration` to 60 and that it takes > 60s from deployment
|
||||
completes until we get to this test.
|
||||
"""
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('xenial_ocata')):
|
||||
logging.info('skipping test < xenial_ocata')
|
||||
return
|
||||
|
||||
KEY_KEY_REPOSITORY = 'key_repository'
|
||||
CREDENTIAL_KEY_REPOSITORY = '/etc/keystone/credential-keys/'
|
||||
FERNET_KEY_REPOSITORY = '/etc/keystone/fernet-keys/'
|
||||
|
||||
# get key repostiroy from leader storage
|
||||
key_repository = json.loads(juju_utils.leader_get(
|
||||
self.application_name, KEY_KEY_REPOSITORY))
|
||||
# sort keys so we can compare it to on-disk repositories
|
||||
key_repository = json.loads(json.dumps(
|
||||
key_repository, sort_keys=True),
|
||||
object_pairs_hook=collections.OrderedDict)
|
||||
logging.info('key_repository: "{}"'
|
||||
.format(pprint.pformat(key_repository)))
|
||||
for repo in [CREDENTIAL_KEY_REPOSITORY, FERNET_KEY_REPOSITORY]:
|
||||
try:
|
||||
for key_name, key in key_repository[repo].items():
|
||||
if int(key_name) > 1:
|
||||
# after initialization the repository contains the
|
||||
# staging key (0) and the primary key (1). After
|
||||
# rotation the repository contains at least one key
|
||||
# with higher index.
|
||||
break
|
||||
else:
|
||||
# NOTE the charm should only rotate the fernet key
|
||||
# repostiory and not rotate the credential key repository.
|
||||
if repo == FERNET_KEY_REPOSITORY:
|
||||
raise zaza_exceptions.KeystoneKeyRepositoryError(
|
||||
'Keys in Fernet key repository has not been '
|
||||
'rotated.')
|
||||
except KeyError:
|
||||
raise zaza_exceptions.KeystoneKeyRepositoryError(
|
||||
'Dict in leader setting "{}" does not contain key '
|
||||
'repository "{}"'.format(KEY_KEY_REPOSITORY, repo))
|
||||
|
||||
# get on-disk key repository from all units
|
||||
on_disk = {}
|
||||
units = zaza.model.get_units(self.application_name)
|
||||
for unit in units:
|
||||
on_disk[unit.entity_id] = {}
|
||||
for repo in [CREDENTIAL_KEY_REPOSITORY, FERNET_KEY_REPOSITORY]:
|
||||
on_disk[unit.entity_id][repo] = {}
|
||||
result = zaza.model.run_on_unit(
|
||||
unit.entity_id, 'sudo ls -1 {}'.format(repo))
|
||||
for key_name in result.get('Stdout').split():
|
||||
result = zaza.model.run_on_unit(
|
||||
unit.entity_id,
|
||||
'sudo cat {}/{}'.format(repo, key_name))
|
||||
on_disk[unit.entity_id][repo][key_name] = result.get(
|
||||
'Stdout')
|
||||
# sort keys so we can compare it to leader storage repositories
|
||||
on_disk = json.loads(
|
||||
json.dumps(on_disk, sort_keys=True),
|
||||
object_pairs_hook=collections.OrderedDict)
|
||||
logging.info('on_disk: "{}"'.format(pprint.pformat(on_disk)))
|
||||
|
||||
for unit in units:
|
||||
unit_repo = on_disk[unit.entity_id]
|
||||
lead_repo = key_repository
|
||||
if unit_repo != lead_repo:
|
||||
raise zaza_exceptions.KeystoneKeyRepositoryError(
|
||||
'expect: "{}" actual({}): "{}"'
|
||||
.format(pprint.pformat(lead_repo), unit.entity_id,
|
||||
pprint.pformat(unit_repo)))
|
||||
logging.info('"{}" == "{}"'
|
||||
.format(pprint.pformat(unit_repo),
|
||||
pprint.pformat(lead_repo)))
|
||||
|
||||
|
||||
class AuthenticationAuthorizationTest(BaseKeystoneTest):
|
||||
"""Keystone authentication and authorization tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Run class setup for running Keystone aa-tests."""
|
||||
super(AuthenticationAuthorizationTest, cls).setUpClass()
|
||||
|
||||
def test_admin_project_scoped_access(self):
|
||||
"""Verify cloud admin access using project scoped token.
|
||||
|
||||
`admin` user in `admin_domain` should be able to access API methods
|
||||
guarded by `rule:cloud_admin` policy using a token scoped to `admin`
|
||||
project in `admin_domain`.
|
||||
|
||||
We implement a policy that enables domain segregation and
|
||||
administration delegation [0]. It is important to understand that this
|
||||
differs from the default policy.
|
||||
|
||||
In the initial implementation it was necessary to switch between using
|
||||
a `domain` scoped and `project` scoped token to successfully manage a
|
||||
cloud, but since the introduction of `is_admin` functionality in
|
||||
Keystone [1][2][3] and our subsequent adoption of it in Keystone charm
|
||||
[4], this is no longer necessary.
|
||||
|
||||
This test here to validate this behaviour.
|
||||
|
||||
0: https://github.com/openstack/keystone/commit/c7a5c6c
|
||||
1: https://github.com/openstack/keystone/commit/e702369
|
||||
2: https://github.com/openstack/keystone/commit/e923a14
|
||||
3: https://github.com/openstack/keystone/commit/9804081
|
||||
4: https://github.com/openstack/charm-keystone/commit/10e3d84
|
||||
"""
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('trusty_mitaka')):
|
||||
logging.info('skipping test < trusty_mitaka')
|
||||
return
|
||||
with self.config_change(
|
||||
{'preferred-api-version': self.default_api_version},
|
||||
{'preferred-api-version': '3'}):
|
||||
for ip in self.keystone_ips:
|
||||
try:
|
||||
logging.info('keystone IP {}'.format(ip))
|
||||
ks_session = openstack_utils.get_keystone_session(
|
||||
openstack_utils.get_overcloud_auth(address=ip))
|
||||
ks_client = openstack_utils.get_keystone_session_client(
|
||||
ks_session)
|
||||
result = ks_client.domains.list()
|
||||
logging.info('.domains.list: "{}"'
|
||||
.format(pprint.pformat(result)))
|
||||
except keystoneauth1.exceptions.http.Forbidden as e:
|
||||
raise zaza_exceptions.KeystoneAuthorizationStrict(
|
||||
'Retrieve domain list as admin with project scoped '
|
||||
'token FAILED. ({})'.format(e))
|
||||
logging.info('OK')
|
||||
|
||||
def test_end_user_domain_admin_access(self):
|
||||
"""Verify that end-user domain admin does not have elevated privileges.
|
||||
|
||||
In additon to validating that the `policy.json` is written and the
|
||||
service is restarted on config-changed, the test validates that our
|
||||
`policy.json` is correct.
|
||||
|
||||
Catch regressions like LP: #1651989
|
||||
"""
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('xenial_ocata')):
|
||||
logging.info('skipping test < xenial_ocata')
|
||||
return
|
||||
with self.config_change(
|
||||
{'preferred-api-version': self.default_api_version},
|
||||
{'preferred-api-version': '3'}):
|
||||
for ip in self.keystone_ips:
|
||||
openrc = {
|
||||
'API_VERSION': 3,
|
||||
'OS_USERNAME': DEMO_ADMIN_USER,
|
||||
'OS_PASSWORD': DEMO_ADMIN_USER_PASSWORD,
|
||||
'OS_AUTH_URL': 'http://{}:5000/v3'.format(ip),
|
||||
'OS_USER_DOMAIN_NAME': DEMO_DOMAIN,
|
||||
'OS_DOMAIN_NAME': DEMO_DOMAIN,
|
||||
}
|
||||
logging.info('keystone IP {}'.format(ip))
|
||||
keystone_session = openstack_utils.get_keystone_session(
|
||||
openrc, scope='DOMAIN')
|
||||
keystone_client = openstack_utils.get_keystone_session_client(
|
||||
keystone_session)
|
||||
try:
|
||||
# expect failure
|
||||
keystone_client.domains.list()
|
||||
except keystoneauth1.exceptions.http.Forbidden as e:
|
||||
logging.debug('Retrieve domain list as end-user domain '
|
||||
'admin NOT allowed...OK ({})'.format(e))
|
||||
pass
|
||||
else:
|
||||
raise zaza_exceptions.KeystoneAuthorizationPermissive(
|
||||
'Retrieve domain list as end-user domain admin '
|
||||
'allowed when it should not be.')
|
||||
logging.info('OK')
|
||||
|
||||
def test_end_user_acccess_and_token(self):
|
||||
"""Verify regular end-user access resources and validate token data.
|
||||
|
||||
In effect this also validates user creation, presence of standard
|
||||
roles (`_member_`, `Member`), effect of policy and configuration
|
||||
of `token-provider`.
|
||||
"""
|
||||
def _validate_token_data(openrc):
|
||||
keystone_session = openstack_utils.get_keystone_session(
|
||||
openrc)
|
||||
keystone_client = openstack_utils.get_keystone_session_client(
|
||||
keystone_session)
|
||||
token = keystone_session.get_token()
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('xenial_ocata')):
|
||||
if len(token) != 32:
|
||||
raise zaza_exceptions.KeystoneWrongTokenProvider(
|
||||
'We expected a UUID token and got this: "{}"'
|
||||
.format(token))
|
||||
else:
|
||||
if len(token) < 180:
|
||||
raise zaza_exceptions.KeystoneWrongTokenProvider(
|
||||
'We expected a Fernet token and got this: "{}"'
|
||||
.format(token))
|
||||
logging.info('token: "{}"'.format(pprint.pformat(token)))
|
||||
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('trusty_mitaka')):
|
||||
logging.info('skip: tokens.get_token_data() not allowed prior '
|
||||
'to trusty_mitaka')
|
||||
return
|
||||
# get_token_data call also gets the service catalog
|
||||
token_data = keystone_client.tokens.get_token_data(token)
|
||||
if token_data.get('token', {}).get('catalog', None) is None:
|
||||
raise zaza_exceptions.KeystoneAuthorizationStrict(
|
||||
# NOTE(fnordahl) the above call will probably throw a
|
||||
# http.Forbidden exception, but just in case
|
||||
'Regular end user not allowed to retrieve the service '
|
||||
'catalog. ("{}")'.format(pprint.pformat(token_data)))
|
||||
logging.info('token_data: "{}"'.format(pprint.pformat(token_data)))
|
||||
|
||||
if (openstack_utils.get_os_release() <
|
||||
openstack_utils.get_os_release('xenial_queens')):
|
||||
openrc = {
|
||||
'API_VERSION': 2,
|
||||
'OS_USERNAME': DEMO_USER,
|
||||
'OS_PASSWORD': DEMO_PASSWORD,
|
||||
'OS_TENANT_NAME': DEMO_TENANT,
|
||||
}
|
||||
for ip in self.keystone_ips:
|
||||
openrc.update(
|
||||
{'OS_AUTH_URL': 'http://{}:5000/v2.0'.format(ip)})
|
||||
_validate_token_data(openrc)
|
||||
|
||||
if (openstack_utils.get_os_release() >=
|
||||
openstack_utils.get_os_release('trusty_mitaka')):
|
||||
openrc = {
|
||||
'API_VERSION': 3,
|
||||
'OS_REGION_NAME': 'RegionOne',
|
||||
'OS_USER_DOMAIN_NAME': DEMO_DOMAIN,
|
||||
'OS_USERNAME': DEMO_USER,
|
||||
'OS_PASSWORD': DEMO_PASSWORD,
|
||||
'OS_PROJECT_DOMAIN_NAME': DEMO_DOMAIN,
|
||||
'OS_PROJECT_NAME': DEMO_PROJECT,
|
||||
}
|
||||
with self.config_change(
|
||||
{'preferred-api-version': self.default_api_version},
|
||||
{'preferred-api-version': '3'}):
|
||||
for ip in self.keystone_ips:
|
||||
openrc.update(
|
||||
{'OS_AUTH_URL': 'http://{}:5000/v3'.format(ip)})
|
||||
_validate_token_data(openrc)
|
||||
@@ -76,3 +76,33 @@ class ReleasePairNotFound(Exception):
|
||||
"""Release pair was not found in OPENSTACK_RELEASES_PAIRS."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KeystoneAuthorizationStrict(Exception):
|
||||
"""Authorization/Policy too strict."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KeystoneAuthorizationPermissive(Exception):
|
||||
"""Authorization/Policy too permissive."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KeystoneWrongTokenProvider(Exception):
|
||||
"""A token was issued from the wrong token provider."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class KeystoneKeyRepositoryError(Exception):
|
||||
"""Error in key repository.
|
||||
|
||||
This may be caused by isues with one of:
|
||||
- incomplete or missing data in `key_repository` in leader storage
|
||||
- synchronization of keys to non-leader units
|
||||
- rotation of keys
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user