Support identity groups in DC

This commit supports synchronization of Identity Group Resource
from central cloud to subclouds. The dcorch audit makes use of
dbsync service to handle creation, modification and deletion of
the groups and the user group memberships. It also handles the
the grant and revocation of group role assignments.

Tests executed:

1) Initial sync
- Verify in subcloud DB that users, groups,user-group
  memberships and project assignments are synced as expected
- Add/Delete new users to existing subcloud groups
- Add/Delete role assigments for existing subcloud groups
- Update group information for existing subcloud groups
- Update information of existing users belonging to existing
  groups
- Verify behaviour on subclouds which have additional
  identity groups (i.e. superset of SystemController);
  which may have been created by admin user for that subcloud

2) Execute all the above test cases as a part of dcorch audit

3) Execute all the above test cases using proxy

4) Execute all the above test cases in a larger env

Change-Id: Ic6c5794be39ec93edc769e72b2a2d53eaba3ecc3
Signed-off-by: Jessica Castelino <jessica.castelino@windriver.com>
Closes-Bug: 1942939
This commit is contained in:
Jessica Castelino 2021-08-20 18:48:28 -04:00
parent 1184e9af51
commit 8ed5018d8b
12 changed files with 831 additions and 68 deletions

View File

@ -13,7 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# Copyright (c) 2019 Wind River Systems, Inc.
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@ -131,3 +131,99 @@ class UsersController(object):
except Exception as e:
LOG.exception(e)
pecan.abort(500, _('Unable to update user'))
class GroupsController(object):
VERSION_ALIASES = {
'Stein': '1.0',
}
def __init__(self):
super(GroupsController, self).__init__()
# to do the version compatibility for future purpose
def _determine_version_cap(self, target):
version_cap = 1.0
return version_cap
@expose(generic=True, template='json')
def index(self):
# Route the request to specific methods with parameters
pass
@index.when(method='GET', template='json')
def get(self, group_ref=None):
"""Get a list of groups."""
context = restcomm.extract_context_from_environ()
try:
if group_ref is None:
return db_api.group_get_all(context)
else:
group = db_api.group_get(context, group_ref)
return group
except exceptions.GroupNotFound as e:
pecan.abort(404, _("Group not found: %s") % e)
except Exception as e:
LOG.exception(e)
pecan.abort(500, _('Unable to get group'))
@index.when(method='POST', template='json')
def post(self):
"""Create a new group."""
context = restcomm.extract_context_from_environ()
# Convert JSON string in request to Python dict
try:
payload = json.loads(request.body)
except ValueError:
pecan.abort(400, _('Request body decoding error'))
if not payload:
pecan.abort(400, _('Body required'))
group_name = payload.get('group').get('name')
if not group_name:
pecan.abort(400, _('Group name required'))
try:
# Insert the group into DB tables
group_ref = db_api.group_create(context, payload)
response.status = 201
return (group_ref)
except Exception as e:
LOG.exception(e)
pecan.abort(500, _('Unable to create group'))
@index.when(method='PUT', template='json')
def put(self, group_ref=None):
"""Update a existing group."""
context = restcomm.extract_context_from_environ()
if group_ref is None:
pecan.abort(400, _('Group ID required'))
# Convert JSON string in request to Python dict
try:
payload = json.loads(request.body)
except ValueError:
pecan.abort(400, _('Request body decoding error'))
if not payload:
pecan.abort(400, _('Body required'))
try:
# Update the group in DB tables
return db_api.group_update(context, group_ref, payload)
except exceptions.GroupNotFound as e:
pecan.abort(404, _("Group not found: %s") % e)
except Exception as e:
LOG.exception(e)
pecan.abort(500, _('Unable to update group'))

View File

@ -13,7 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# Copyright (c) 2019 Wind River Systems, Inc.
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@ -42,6 +42,7 @@ class IdentityController(object):
res_controllers = dict()
res_controllers["users"] = identity.UsersController
res_controllers["groups"] = identity.GroupsController
res_controllers["projects"] = project.ProjectsController
res_controllers["roles"] = role.RolesController
res_controllers["token-revocation-events"] = \

View File

@ -14,7 +14,7 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# Copyright (c) 2019 Wind River Systems, Inc.
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@ -79,6 +79,10 @@ class UserNotFound(NotFound):
message = _("User with id %(user_id)s doesn't exist.")
class GroupNotFound(NotFound):
message = _("Group with id %(group_id)s doesn't exist.")
class ProjectNotFound(NotFound):
message = _("Project with id %(project_id)s doesn't exist.")

View File

@ -13,7 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# Copyright (c) 2019 Wind River Systems, Inc.
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@ -69,6 +69,32 @@ def user_update(context, user_ref, payload):
return IMPL.user_update(context, user_ref, payload)
###################
# group db methods
###################
def group_get_all(context):
"""Retrieve all groups."""
return IMPL.group_get_all(context)
def group_get(context, group_id):
"""Retrieve details of a group."""
return IMPL.group_get(context, group_id)
def group_create(context, payload):
"""Create a group."""
return IMPL.group_create(context, payload)
def group_update(context, group_ref, payload):
"""Update a group"""
return IMPL.group_update(context, group_ref, payload)
###################
# project db methods

View File

@ -13,7 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# Copyright (c) 2019-2020 Wind River Systems, Inc.
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@ -230,8 +230,9 @@ def user_get_all(context):
user_passwords = {'password': [password for password in passwords
if password['local_user_id'] ==
local_user['id']]}
user_consolidated = dict({'local_user': local_user}.items() +
user.items() + user_passwords.items())
user_consolidated = dict(list({'local_user': local_user}.items()) +
list(user.items()) +
list(user_passwords.items()))
result.append(user_consolidated)
return result
@ -328,15 +329,131 @@ def user_update(context, user_id, payload):
updated_local_users[0]['id']
insert(conn, table, password)
# Need to update the actor_id in assignment and system_assignment
# tables if the user id is updated
# along with the user_id in user_group_membership tables if the
# user id is updated
if user_id != new_user_id:
assignment = {'actor_id': new_user_id}
user_group_membership = {'user_id': new_user_id}
update(conn, 'assignment', 'actor_id', user_id, assignment)
update(conn, 'system_assignment', 'actor_id', user_id, assignment)
update(conn, 'user_group_membership', 'user_id', user_id, user_group_membership)
return user_get(context, new_user_id)
###################
# identity groups
###################
@require_context
def group_get_all(context):
result = []
with get_read_connection() as conn:
# groups table
groups = query(conn, 'group')
# user_group_membership table
user_group_memberships = query(conn, 'user_group_membership')
for group in groups:
local_user_id_list = [membership['user_id'] for membership
in user_group_memberships if
membership['group_id'] == group['id']]
local_user_id_list.sort()
local_user_ids = {'local_user_ids': local_user_id_list}
group_consolidated = dict(list({'group': group}.items()) +
list(local_user_ids.items()))
result.append(group_consolidated)
return result
@require_context
def group_get(context, group_id):
result = {}
with get_read_connection() as conn:
local_user_id_list = []
# group table
group = query(conn, 'group', 'id', group_id)
if not group:
raise exception.GroupNotFound(group_id=group_id)
result['group'] = group[0]
# user_group_membership table
user_group_memberships = query(conn, 'user_group_membership', 'group_id', group_id)
for user_group_membership in user_group_memberships:
local_user = query(conn, 'local_user', 'user_id', user_group_membership.get('user_id'))
if not local_user:
raise exception.UserNotFound(user_id=user_group_membership.get('user_id'))
local_user_id_list.append(local_user[0]['user_id'])
result['local_user_ids'] = local_user_id_list
return result
@require_admin_context
def group_create(context, payload):
group = payload['group']
local_user_ids = payload['local_user_ids']
with get_write_connection() as conn:
insert(conn, 'group', group)
for local_user_id in local_user_ids:
user_group_membership = {'user_id': local_user_id, 'group_id': group['id']}
insert(conn, 'user_group_membership', user_group_membership)
return group_get(context, payload['group']['id'])
@require_admin_context
def group_update(context, group_id, payload):
with get_write_connection() as conn:
new_group_id = group_id
if 'group' in payload and 'local_user_ids' in payload:
group = payload['group']
new_group_id = group.get('id')
# local_user_id_list is a sorted list of user IDs that
# belong to this group
local_user_id_list = payload['local_user_ids']
user_group_memberships = query(conn, 'user_group_membership',
'group_id', group_id)
existing_user_list = [user_group_membership['user_id'] for user_group_membership
in user_group_memberships]
existing_user_list.sort()
deleted = False
if (group_id != new_group_id) or (local_user_id_list != existing_user_list):
# Foreign key constraint exists on 'group_id' of user_group_membership table
# and 'id' of group table. So delete user group membership records before
# updating group if groups IDs are different
# Alternatively, if there is a discrepency in the user group memberships,
# delete and re-create them
delete(conn, 'user_group_membership', 'group_id', group_id)
deleted = True
# Update group table
update(conn, 'group', 'id', group_id, group)
if deleted:
for local_user_id in local_user_id_list:
item = {'user_id': local_user_id, 'group_id': new_group_id}
insert(conn, 'user_group_membership', item)
# Need to update the actor_id in assignment and system_assignment
# tables if the group id is updated
if group_id != new_group_id:
assignment = {'actor_id': new_group_id}
update(conn, 'assignment', 'actor_id', group_id, assignment)
update(conn, 'system_assignment', 'actor_id', group_id, assignment)
return group_get(context, new_group_id)
###################
# identity projects

View File

@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Copyright (c) 2019 Wind River Systems, Inc.
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@ -23,7 +23,8 @@ import keystoneauth1.identity.generic as auth_plugin
from keystoneauth1 import session as ks_session
from dcdbsync.dbsyncclient import httpclient
from dcdbsync.dbsyncclient.v1.identity import identity_manager as im
from dcdbsync.dbsyncclient.v1.identity import identity_group_manager as igm
from dcdbsync.dbsyncclient.v1.identity import identity_user_manager as ium
from dcdbsync.dbsyncclient.v1.identity import project_manager as pm
from dcdbsync.dbsyncclient.v1.identity import role_manager as rm
from dcdbsync.dbsyncclient.v1.identity \
@ -97,7 +98,8 @@ class Client(object):
)
# Create all managers
self.identity_manager = im.identity_manager(self.http_client)
self.identity_user_manager = ium.identity_user_manager(self.http_client)
self.identity_group_manager = igm.identity_group_manager(self.http_client)
self.project_manager = pm.project_manager(self.http_client)
self.role_manager = rm.role_manager(self.http_client)
self.revoke_event_manager = trem.revoke_event_manager(self.http_client)

View File

@ -0,0 +1,133 @@
# Copyright (c) 2017 Ericsson AB.
# All Rights Reserved.
#
# 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.
#
# Copyright (c) 2019-2021 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
from dcdbsync.dbsyncclient import base
from dcdbsync.dbsyncclient.base import get_json
from dcdbsync.dbsyncclient import exceptions
class Group(base.Resource):
resource_name = 'group'
def __init__(self, manager, id, domain_id, name,
description, local_user_ids, extra={}):
self.manager = manager
self.id = id
self.domain_id = domain_id
self.name = name
self.description = description
self.local_user_ids = local_user_ids
self.extra = extra
def info(self):
resource_info = dict()
resource_info.update({self.resource_name:
{'name': self.name,
'id': self.id,
'domain_id': self.domain_id}})
return resource_info
class identity_group_manager(base.ResourceManager):
resource_class = Group
def group_create(self, url, data):
resp = self.http_client.post(url, data)
# Unauthorized request
if resp.status_code == 401:
raise exceptions.Unauthorized('Unauthorized request.')
if resp.status_code != 201:
self._raise_api_exception(resp)
# Converted into python dict
json_object = get_json(resp)
return json_object
def group_list(self, url):
resp = self.http_client.get(url)
# Unauthorized
if resp.status_code == 401:
raise exceptions.Unauthorized('Unauthorized request')
if resp.status_code != 200:
self._raise_api_exception(resp)
# Converted into python dict
json_objects = get_json(resp)
groups = []
for json_object in json_objects:
group = Group(
self,
id=json_object['group']['id'],
domain_id=json_object['group']['domain_id'],
name=json_object['group']['name'],
extra=json_object['group']['extra'],
description=json_object['group']['description'],
local_user_ids=json_object['local_user_ids'])
groups.append(group)
return groups
def _group_detail(self, url):
resp = self.http_client.get(url)
# Unauthorized request
if resp.status_code == 401:
raise exceptions.Unauthorized('Unauthorized request.')
if resp.status_code != 200:
self._raise_api_exception(resp)
# Return group details in original json format,
# ie, without convert it into python dict
return resp.content
def _group_update(self, url, data):
resp = self.http_client.put(url, data)
# Unauthorized request
if resp.status_code == 401:
raise exceptions.Unauthorized('Unauthorized request.')
if resp.status_code != 200:
self._raise_api_exception(resp)
# Converted into python dict
json_object = get_json(resp)
return json_object
def add_group(self, data):
url = '/identity/groups/'
return self.group_create(url, data)
def list_groups(self):
url = '/identity/groups/'
return self.group_list(url)
def group_detail(self, group_ref):
url = '/identity/groups/%s' % group_ref
return self._group_detail(url)
def update_group(self, group_ref, data):
url = '/identity/groups/%s' % group_ref
return self._group_update(url, data)

View File

@ -84,7 +84,7 @@ class User(base.Resource):
return resource_info
class identity_manager(base.ResourceManager):
class identity_user_manager(base.ResourceManager):
resource_class = User
def user_create(self, url, data):

View File

@ -675,16 +675,20 @@ class IdentityAPIController(APIController):
def _generate_assignment_rid(self, url, environ):
resource_id = None
# for role assignment or revocation, the URL is of format:
# /v3/projects/{project_id}/users/{user_id}/roles/{role_id}
# /v3/projects/{project_id}/users/{user_id}/roles/{role_id} or
# /v3/projects/{project_id}/groups/{group_id}/roles/{role_id}
# We need to extract all ID parameters from the URL
role_id = proxy_utils.get_routing_match_value(environ, 'role_id')
proj_id = proxy_utils.get_routing_match_value(environ, 'project_id')
user_id = proxy_utils.get_routing_match_value(environ, 'user_id')
if 'user_id' in proxy_utils.get_routing_match_arguments(environ):
actor_id = proxy_utils.get_routing_match_value(environ, 'user_id')
else:
actor_id = proxy_utils.get_routing_match_value(environ, 'group_id')
if (not role_id or not proj_id or not user_id):
if (not role_id or not proj_id or not actor_id):
LOG.error("Malformed Role Assignment or Revocation URL: %s", url)
else:
resource_id = "{}_{}_{}".format(proj_id, user_id, role_id)
resource_id = "{}_{}_{}".format(proj_id, actor_id, role_id)
return resource_id
def _retrieve_token_revoke_event_rid(self, url, environ):
@ -712,7 +716,7 @@ class IdentityAPIController(APIController):
resource_type = self._get_resource_type_from_environ(environ)
# if this is a Role Assignment or Revocation request then
# we need to extract Project ID, User ID and Role ID from the
# we need to extract Project ID, User ID/Group ID and Role ID from the
# URL, and not just the Role ID
if (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_PROJECT_ROLE_ASSIGNMENTS):
@ -738,6 +742,20 @@ class IdentityAPIController(APIController):
if operation_type == consts.OPERATION_TYPE_POST:
operation_type = consts.OPERATION_TYPE_PATCH
resource_type = consts.RESOURCE_TYPE_IDENTITY_USERS
elif (resource_type == consts.RESOURCE_TYPE_IDENTITY_GROUPS
and operation_type != consts.OPERATION_TYPE_POST):
if("users" in request_header):
# Requests for adding a user (PUT) and removing a user (DELETE)
# should be converted to a PUT request
# The url in this case looks like /groups/{group_id}/users/{user_id}
# We need to extract the group_id and assign that to resource_id
index = request_header.find("/users")
resource_id = self.get_resource_id_from_link(request_header[0:index])
resource_info = {'group':
{'id': resource_id}}
operation_type = consts.OPERATION_TYPE_PUT
else:
resource_id = self.get_resource_id_from_link(request_header)
else:
if operation_type == consts.OPERATION_TYPE_POST:
# Retrieve the ID from the response

View File

@ -287,6 +287,7 @@ IDENTITY_PROJECTS_PATH = [
IDENTITY_PROJECTS_ROLE_PATH = [
'/v3/projects/{project_id}/users/{user_id}/roles/{role_id}',
'/v3/projects/{project_id}/groups/{group_id}/roles/{role_id}',
]
IDENTITY_TOKEN_REVOKE_EVENTS_PATH = [
@ -296,6 +297,7 @@ IDENTITY_TOKEN_REVOKE_EVENTS_PATH = [
IDENTITY_PATH_MAP = {
consts.RESOURCE_TYPE_IDENTITY_USERS: IDENTITY_USERS_PATH,
consts.RESOURCE_TYPE_IDENTITY_USERS_PASSWORD: IDENTITY_USERS_PW_PATH,
consts.RESOURCE_TYPE_IDENTITY_GROUPS: IDENTITY_USER_GROUPS_PATH,
consts.RESOURCE_TYPE_IDENTITY_ROLES: IDENTITY_ROLES_PATH,
consts.RESOURCE_TYPE_IDENTITY_PROJECTS: IDENTITY_PROJECTS_PATH,
consts.RESOURCE_TYPE_IDENTITY_PROJECT_ROLE_ASSIGNMENTS:
@ -346,6 +348,8 @@ ROUTE_METHOD_MAP = {
consts.ENDPOINT_TYPE_IDENTITY: {
consts.RESOURCE_TYPE_IDENTITY_USERS:
['POST', 'PATCH', 'DELETE'],
consts.RESOURCE_TYPE_IDENTITY_GROUPS:
['POST', 'PUT', 'PATCH', 'DELETE'],
consts.RESOURCE_TYPE_IDENTITY_USERS_PASSWORD:
['POST'],
consts.RESOURCE_TYPE_IDENTITY_ROLES:

View File

@ -106,6 +106,7 @@ RESOURCE_TYPE_QOS_POLICY = "qos"
# Identity Resources
RESOURCE_TYPE_IDENTITY_USERS = "users"
RESOURCE_TYPE_IDENTITY_GROUPS = "groups"
RESOURCE_TYPE_IDENTITY_USERS_PASSWORD = "users_password"
RESOURCE_TYPE_IDENTITY_ROLES = "roles"
RESOURCE_TYPE_IDENTITY_PROJECTS = "projects"

View File

@ -1,4 +1,4 @@
# Copyright 2018-2020 Wind River
# Copyright 2018-2021 Wind River
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -46,6 +46,8 @@ class IdentitySyncThread(SyncThread):
self.sync_handler_map = {
consts.RESOURCE_TYPE_IDENTITY_USERS:
self.sync_identity_resource,
consts.RESOURCE_TYPE_IDENTITY_GROUPS:
self.sync_identity_resource,
consts.RESOURCE_TYPE_IDENTITY_USERS_PASSWORD:
self.sync_identity_resource,
consts.RESOURCE_TYPE_IDENTITY_ROLES:
@ -63,6 +65,7 @@ class IdentitySyncThread(SyncThread):
# that users are replicated prior to assignment data (roles/projects)
self.audit_resources = [
consts.RESOURCE_TYPE_IDENTITY_USERS,
consts.RESOURCE_TYPE_IDENTITY_GROUPS,
consts.RESOURCE_TYPE_IDENTITY_PROJECTS,
consts.RESOURCE_TYPE_IDENTITY_ROLES,
consts.RESOURCE_TYPE_IDENTITY_PROJECT_ROLE_ASSIGNMENTS,
@ -79,6 +82,8 @@ class IdentitySyncThread(SyncThread):
consts.RESOURCE_TYPE_IDENTITY_ROLES:
['heat_stack_owner', 'heat_stack_user', 'ResellerAdmin'],
consts.RESOURCE_TYPE_IDENTITY_PROJECTS:
[],
consts.RESOURCE_TYPE_IDENTITY_GROUPS:
[]
}
@ -109,8 +114,8 @@ class IdentitySyncThread(SyncThread):
# sysinv, and dcmanager users are special cases as the id's will match
# (as this is forced during the subcloud deploy) but the details will
# not so we still need to sync them here.
m_client = self.get_dbs_client(self.master_region_name).identity_manager
sc_client = self.get_dbs_client(self.region_name).identity_manager
m_client = self.get_dbs_client(self.master_region_name).identity_user_manager
sc_client = self.get_dbs_client(self.region_name).identity_user_manager
for m_user in m_users:
for sc_user in sc_users:
@ -141,7 +146,7 @@ class IdentitySyncThread(SyncThread):
sdk.OpenStackDriver.delete_region_clients(self.region_name)
# Retry with a new token
sc_client = self.get_dbs_client(
self.region_name).identity_manager
self.region_name).identity_user_manager
user_ref = sc_client.update_user(sc_user.id,
user_records)
if not user_ref:
@ -149,6 +154,45 @@ class IdentitySyncThread(SyncThread):
" in subcloud.".format(sc_user.id))
raise exceptions.SyncRequestFailed
def _initial_sync_groups(self, m_groups, sc_groups):
# Particularly sync groups with same name but different ID.
m_client = self.get_dbs_client(self.master_region_name).identity_group_manager
sc_client = self.get_dbs_client(self.region_name).identity_group_manager
for m_group in m_groups:
for sc_group in sc_groups:
if (m_group.name == sc_group.name and
m_group.domain_id == sc_group.domain_id and
m_group.id != sc_group.id):
group_records = m_client.group_detail(m_group.id)
if not group_records:
LOG.error("No data retrieved from master cloud for"
" group {} to update its equivalent in"
" subcloud.".format(m_group.id))
raise exceptions.SyncRequestFailed
# update the group by pushing down the DB records to
# subcloud
try:
group_ref = sc_client.update_group(sc_group.id,
group_records)
# Retry once if unauthorized
except dbsync_exceptions.Unauthorized as e:
LOG.info("Update group {} request failed for {}: {}."
.format(sc_group.id,
self.region_name, str(e)))
# Clear the cache so that the old token will not be validated
sdk.OpenStackDriver.delete_region_clients(self.region_name)
sc_client = self.get_dbs_client(
self.region_name).identity_group_manager
group_ref = sc_client.update_group(sc_group.id,
group_records)
if not group_ref:
LOG.error("No group data returned when updating"
" group {} in subcloud.".
format(sc_group.id))
raise exceptions.SyncRequestFailed
def _initial_sync_projects(self, m_projects, sc_projects):
# Particularly sync projects with same name but different ID.
m_client = self.get_dbs_client(self.master_region_name).project_manager
@ -232,10 +276,10 @@ class IdentitySyncThread(SyncThread):
# before dcorch starts to audit resources. Later on when dcorch audits
# and sync them over(including their IDs) to the subcloud, running
# services at the subcloud with tokens issued before their ID are
# changed will get user/project not found error since their IDs are
# changed will get user/group/project not found error since their IDs are
# changed. This will continue until their tokens expire in up to
# 1 hour. Before that these services basically stop working.
# By an initial synchronization on existing users/projects,
# By an initial synchronization on existing users/groups/projects,
# synchronously followed by a fernet keys synchronization, existing
# tokens at subcloud are revoked and services are forced to
# re-authenticate to get new tokens. This significantly decreases
@ -261,6 +305,25 @@ class IdentitySyncThread(SyncThread):
self._initial_sync_users(m_users, sc_users)
# get groups from master cloud
m_groups = self.get_master_resources(
consts.RESOURCE_TYPE_IDENTITY_GROUPS)
if not m_groups:
LOG.info("No groups returned from {}".
format(dccommon_consts.VIRTUAL_MASTER_CLOUD))
# get groups from the subcloud
sc_groups = self.get_subcloud_resources(
consts.RESOURCE_TYPE_IDENTITY_GROUPS)
if not sc_groups:
LOG.info("No groups returned from subcloud {}".
format(self.region_name))
if m_groups and sc_groups:
self._initial_sync_groups(m_groups, sc_groups)
# get projects from master cloud
m_projects = self.get_master_resources(
consts.RESOURCE_TYPE_IDENTITY_PROJECTS)
@ -368,7 +431,7 @@ class IdentitySyncThread(SyncThread):
# format
try:
user_records = self.get_dbs_client(self.master_region_name).\
identity_manager.user_detail(user_id)
identity_user_manager.user_detail(user_id)
except dbsync_exceptions.Unauthorized:
raise dbsync_exceptions.UnauthorizedMaster
if not user_records:
@ -379,7 +442,7 @@ class IdentitySyncThread(SyncThread):
# Create the user on subcloud by pushing the DB records to subcloud
user_ref = self.get_dbs_client(
self.region_name).identity_manager.add_user(
self.region_name).identity_user_manager.add_user(
user_records)
if not user_ref:
LOG.error("No user data returned when creating user {} in"
@ -421,7 +484,7 @@ class IdentitySyncThread(SyncThread):
# format
try:
user_records = self.get_dbs_client(self.master_region_name).\
identity_manager.user_detail(user_id)
identity_user_manager.user_detail(user_id)
except dbsync_exceptions.Unauthorized:
raise dbsync_exceptions.UnauthorizedMaster
if not user_records:
@ -432,7 +495,7 @@ class IdentitySyncThread(SyncThread):
# Update the corresponding user on subcloud by pushing the DB records
# to subcloud
user_ref = self.get_dbs_client(self.region_name).identity_manager.\
user_ref = self.get_dbs_client(self.region_name).identity_user_manager.\
update_user(sc_user_id, user_records)
if not user_ref:
LOG.error("No user data returned when updating user {} in"
@ -531,6 +594,180 @@ class IdentitySyncThread(SyncThread):
extra=self.log_extra)
user_subcloud_rsrc.delete()
def post_groups(self, request, rsrc):
# Create this group on this subcloud
# The DB level resource creation process is, retrieve the resource
# records from master cloud by its ID, send the records in its original
# JSON format by REST call to the DB synchronization service on this
# subcloud, which then inserts the resource records into DB tables.
group_id = request.orch_job.source_resource_id
if not group_id:
LOG.error("Received group create request without required "
"'source_resource_id' field", extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Retrieve DB records of the group just created. The records is in JSON
# format
try:
group_records = self.get_dbs_client(self.master_region_name).\
identity_group_manager.group_detail(group_id)
except dbsync_exceptions.Unauthorized:
raise dbsync_exceptions.UnauthorizedMaster
if not group_records:
LOG.error("No data retrieved from master cloud for group {} to"
" create its equivalent in subcloud.".format(group_id),
extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Create the group on subcloud by pushing the DB records to subcloud
group_ref = self.get_dbs_client(
self.region_name).identity_group_manager.add_group(
group_records)
if not group_ref:
LOG.error("No group data returned when creating group {} in"
" subcloud.".format(group_id), extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Persist the subcloud resource.
group_ref_id = group_ref.get('group').get('id')
subcloud_rsrc_id = self.persist_db_subcloud_resource(rsrc.id,
group_ref_id)
groupname = group_ref.get('group').get('name')
LOG.info("Created Keystone group {}:{} [{}]"
.format(rsrc.id, subcloud_rsrc_id, groupname),
extra=self.log_extra)
def put_groups(self, request, rsrc):
# Update this group on this subcloud
# The DB level resource update process is, retrieve the resource
# records from master cloud by its ID, send the records in its original
# JSON format by REST call to the DB synchronization service on this
# subcloud, which then updates the resource records in its DB tables.
group_id = request.orch_job.source_resource_id
if not group_id:
LOG.error("Received group update request without required "
"source resource id", extra=self.log_extra)
raise exceptions.SyncRequestFailed
group_dict = jsonutils.loads(request.orch_job.resource_info)
if 'group' in group_dict:
group_dict = group_dict['group']
sc_group_id = group_dict.pop('id', None)
if not sc_group_id:
LOG.error("Received group update request without required "
"subcloud resource id", extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Retrieve DB records of the group. The records is in JSON
# format
try:
group_records = self.get_dbs_client(self.master_region_name).\
identity_group_manager.group_detail(group_id)
except dbsync_exceptions.Unauthorized:
raise dbsync_exceptions.UnauthorizedMaster
if not group_records:
LOG.error("No data retrieved from master cloud for group {} to"
" update its equivalent in subcloud.".format(group_id),
extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Update the corresponding group on subcloud by pushing the DB records
# to subcloud
group_ref = self.get_dbs_client(self.region_name).identity_group_manager.\
update_group(sc_group_id, group_records)
if not group_ref:
LOG.error("No group data returned when updating group {} in"
" subcloud.".format(sc_group_id), extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Persist the subcloud resource.
group_ref_id = group_ref.get('group').get('id')
subcloud_rsrc_id = self.persist_db_subcloud_resource(rsrc.id,
group_ref_id)
groupname = group_ref.get('group').get('name')
LOG.info("Updated Keystone group {}:{} [{}]"
.format(rsrc.id, subcloud_rsrc_id, groupname),
extra=self.log_extra)
def patch_groups(self, request, rsrc):
# Update group reference on this subcloud
group_update_dict = jsonutils.loads(request.orch_job.resource_info)
if not group_update_dict.keys():
LOG.error("Received group update request "
"without any update fields", extra=self.log_extra)
raise exceptions.SyncRequestFailed
group_update_dict = group_update_dict['group']
group_subcloud_rsrc = self.get_db_subcloud_resource(rsrc.id)
if not group_subcloud_rsrc:
LOG.error("Unable to update group reference {}:{}, "
"cannot find equivalent Keystone group in subcloud."
.format(rsrc, group_update_dict),
extra=self.log_extra)
return
# instead of stowing the entire group reference or
# retrieving it, we build an opaque wrapper for the
# v3 Group Manager, containing the ID field which is
# needed to update this group reference
GroupReferenceWrapper = namedtuple('GroupReferenceWrapper',
'id')
group_id = group_subcloud_rsrc.subcloud_resource_id
original_group_ref = GroupReferenceWrapper(id=group_id)
sc_ks_client = self.get_ks_client(self.region_name)
# Update the group in the subcloud
group_ref = sc_ks_client.groups.update(
original_group_ref,
name=group_update_dict.pop('name', None),
domain=group_update_dict.pop('domain', None),
description=group_update_dict.pop('description', None))
if group_ref.id == group_id:
LOG.info("Updated Keystone group: {}:{}"
.format(rsrc.id, group_ref.id), extra=self.log_extra)
else:
LOG.error("Unable to update Keystone group {}:{} for subcloud"
.format(rsrc.id, group_id), extra=self.log_extra)
def delete_groups(self, request, rsrc):
# Delete group reference on this subcloud
group_subcloud_rsrc = self.get_db_subcloud_resource(rsrc.id)
if not group_subcloud_rsrc:
LOG.error("Unable to delete group reference {}, "
"cannot find equivalent Keystone group in subcloud."
.format(rsrc), extra=self.log_extra)
return
# instead of stowing the entire group reference or
# retrieving it, we build an opaque wrapper for the
# v3 User Manager, containing the ID field which is
# needed to delete this group reference
GroupReferenceWrapper = namedtuple('GroupReferenceWrapper',
'id')
group_id = group_subcloud_rsrc.subcloud_resource_id
original_group_ref = GroupReferenceWrapper(id=group_id)
# Delete the group in the subcloud
try:
sc_ks_client = self.get_ks_client(self.region_name)
sc_ks_client.groups.delete(original_group_ref)
except keystone_exceptions.NotFound:
LOG.info("Delete group: group {} not found in {}, "
"considered as deleted.".
format(original_group_ref.id,
self.region_name),
extra=self.log_extra)
# Master Resource can be deleted only when all subcloud resources
# are deleted along with corresponding orch_job and orch_requests.
LOG.info("Keystone group {}:{} [{}] deleted"
.format(rsrc.id, group_subcloud_rsrc.id,
group_subcloud_rsrc.subcloud_resource_id),
extra=self.log_extra)
group_subcloud_rsrc.delete()
def post_projects(self, request, rsrc):
# Create this project on this subcloud
# The DB level resource creation process is, retrieve the resource
@ -881,7 +1118,7 @@ class IdentitySyncThread(SyncThread):
role_subcloud_rsrc.delete()
def post_project_role_assignments(self, request, rsrc):
# Assign this role to user on project on this subcloud
# Assign this role to user/group on project on this subcloud
# Project role assignments creation is still using keystone APIs since
# the APIs can be used to sync them.
resource_tags = rsrc.master_id.split('_')
@ -892,7 +1129,8 @@ class IdentitySyncThread(SyncThread):
raise exceptions.SyncRequestFailed
project_id = resource_tags[0]
user_id = resource_tags[1]
# actor_id can be either user_id or group_id
actor_id = resource_tags[1]
role_id = resource_tags[2]
# Ensure that we have already synced the project, user and role
@ -928,31 +1166,50 @@ class IdentitySyncThread(SyncThread):
sc_user = None
sc_user_list = self._get_all_users(sc_ks_client)
for user in sc_user_list:
if user.id == user_id:
if user.id == actor_id:
sc_user = user
break
if not sc_user:
LOG.error("Unable to assign role to user on project reference {}:"
"{}, cannot find equivalent Keystone User in subcloud."
.format(rsrc, user_id),
sc_group = None
sc_group_list = self._get_all_groups(sc_ks_client)
for group in sc_group_list:
if group.id == actor_id:
sc_group = group
break
if not sc_user and not sc_group:
LOG.error("Unable to assign role to user/group on project reference {}:"
"{}, cannot find equivalent Keystone User/Group in subcloud."
.format(rsrc, actor_id),
extra=self.log_extra)
raise exceptions.SyncRequestFailed
# Create role assignment
sc_ks_client.roles.grant(
sc_role,
user=sc_user,
project=sc_proj)
role_ref = sc_ks_client.role_assignments.list(
user=sc_user,
project=sc_proj,
role=sc_role)
if sc_user:
sc_ks_client.roles.grant(
sc_role,
user=sc_user,
project=sc_proj)
role_ref = sc_ks_client.role_assignments.list(
user=sc_user,
project=sc_proj,
role=sc_role)
elif sc_group:
sc_ks_client.roles.grant(
sc_role,
group=sc_group,
project=sc_proj)
role_ref = sc_ks_client.role_assignments.list(
group=sc_group,
project=sc_proj,
role=sc_role)
if role_ref:
LOG.info("Added Keystone role assignment: {}:{}"
.format(rsrc.id, role_ref), extra=self.log_extra)
# Persist the subcloud resource.
sc_rid = sc_proj.id + '_' + sc_user.id + '_' + sc_role.id
if sc_user:
sc_rid = sc_proj.id + '_' + sc_user.id + '_' + sc_role.id
elif sc_group:
sc_rid = sc_proj.id + '_' + sc_group.id + '_' + sc_role.id
subcloud_rsrc_id = self.persist_db_subcloud_resource(rsrc.id,
sc_rid)
LOG.info("Created Keystone role assignment {}:{} [{}]"
@ -986,33 +1243,55 @@ class IdentitySyncThread(SyncThread):
resource_tags = subcloud_rid.split('_')
if len(resource_tags) < 3:
LOG.error("Malformed subcloud resource tag {}, expected to be in "
"format: ProjectID_UserID_RoleID."
"format: ProjectID_UserID_RoleID or ProjectID_GroupID_RoleID."
.format(assignment_subcloud_rsrc), extra=self.log_extra)
assignment_subcloud_rsrc.delete()
return
project_id = resource_tags[0]
user_id = resource_tags[1]
actor_id = resource_tags[1]
role_id = resource_tags[2]
# Revoke role assignment
actor = None
try:
sc_ks_client = self.get_ks_client(self.region_name)
sc_ks_client.roles.revoke(
role_id,
user=user_id,
user=actor_id,
project=project_id)
actor = 'user'
except keystone_exceptions.NotFound:
LOG.info("Revoke role assignment: (role {}, user {}, project {})"
" not found in {}, considered as deleted.".
format(role_id, user_id, project_id,
format(role_id, actor_id, project_id,
self.region_name),
extra=self.log_extra)
try:
sc_ks_client = self.get_ks_client(self.region_name)
sc_ks_client.roles.revoke(
role_id,
group=actor_id,
project=project_id)
actor = 'group'
except keystone_exceptions.NotFound:
LOG.info("Revoke role assignment: (role {}, group {}, project {})"
" not found in {}, considered as deleted.".
format(role_id, actor_id, project_id,
self.region_name),
extra=self.log_extra)
role_ref = sc_ks_client.role_assignments.list(
user=user_id,
project=project_id,
role=role_id)
role_ref = None
if actor == 'user':
role_ref = sc_ks_client.role_assignments.list(
user=actor_id,
project=project_id,
role=role_id)
elif actor == 'group':
role_ref = sc_ks_client.role_assignments.list(
group=actor_id,
project=project_id,
role=role_id)
if not role_ref:
LOG.info("Deleted Keystone role assignment: {}:{}"
@ -1182,7 +1461,9 @@ class IdentitySyncThread(SyncThread):
# ---- Override common audit functions ----
def _get_resource_audit_handler(self, resource_type, client):
if resource_type == consts.RESOURCE_TYPE_IDENTITY_USERS:
return self._get_users_resource(client.identity_manager)
return self._get_users_resource(client.identity_user_manager)
if resource_type == consts.RESOURCE_TYPE_IDENTITY_GROUPS:
return self._get_groups_resource(client.identity_group_manager)
elif resource_type == consts.RESOURCE_TYPE_IDENTITY_ROLES:
return self._get_roles_resource(client.role_manager)
elif resource_type == consts.RESOURCE_TYPE_IDENTITY_PROJECTS:
@ -1211,6 +1492,14 @@ class IdentitySyncThread(SyncThread):
users = users + domain_users
return users
def _get_all_groups(self, client):
domains = client.domains.list()
groups = []
for domain in domains:
domain_groups = client.groups.list(domain=domain)
groups = groups + domain_groups
return groups
def _get_users_resource(self, client):
try:
services = []
@ -1251,6 +1540,33 @@ class IdentitySyncThread(SyncThread):
# None will force skip of audit
return None
def _get_groups_resource(self, client):
try:
# get groups from DB API
if hasattr(client, 'list_groups'):
groups = client.list_groups()
# get groups from keystone API
else:
groups = client.groups.list()
# Filter out admin or services projects
filtered_list = self.filtered_audit_resources[
consts.RESOURCE_TYPE_IDENTITY_GROUPS]
filtered_groups = [group for group in groups if
all(group.name != filtered for
filtered in filtered_list)]
return filtered_groups
except (keystone_exceptions.connection.ConnectTimeout,
keystone_exceptions.ConnectFailure,
dbsync_exceptions.ConnectTimeout,
dbsync_exceptions.ConnectFailure) as e:
LOG.info("Group Audit: subcloud {} is not reachable [{}]"
.format(self.region_name,
str(e)), extra=self.log_extra)
# None will force skip of audit
return None
def _get_roles_resource(self, client):
try:
# get roles from DB API
@ -1316,22 +1632,28 @@ class IdentitySyncThread(SyncThread):
roles = self._get_roles_resource(client)
projects = self._get_projects_resource(client)
users = self._get_users_resource(client)
groups = self._get_groups_resource(client)
for assignment in assignments:
if 'project' not in assignment.scope:
# this is a domain scoped role, we don't care
# about syncing or auditing them for now
continue
role_id = assignment.role['id']
user_id = assignment.user['id']
actor_id = assignment.user['id'] if hasattr(assignment, 'user') else assignment.group['id']
project_id = assignment.scope['project']['id']
assignment_dict = {}
for user in users:
if user.id == user_id:
assignment_dict['user'] = user
if user.id == actor_id:
assignment_dict['actor'] = user
break
else:
continue
for group in groups:
if group.id == actor_id:
assignment_dict['actor'] = group
break
else:
continue
for role in roles:
if role.id == role_id:
@ -1350,7 +1672,7 @@ class IdentitySyncThread(SyncThread):
# The id of a Role Assigment is:
# projectID_userID_roleID
assignment_dict['id'] = "{}_{}_{}".format(
project_id, user_id, role_id)
project_id, actor_id, role_id)
# Build an opaque object wrapper for this RoleAssignment
refactored_assignment = namedtuple(
@ -1411,15 +1733,15 @@ class IdentitySyncThread(SyncThread):
# None will force skip of audit
return None
def _same_identity_resource(self, m, sc):
def _same_identity_user_resource(self, m, sc):
LOG.debug("master={}, subcloud={}".format(m, sc),
extra=self.log_extra)
# For user the comparison is DB records by DB records.
# The user DB records are from multiple tables, including user,
# local_user, and password tables. If any of them are not matched,
# it is considered not a same identity resource.
# Note that the user id is compared, since user id is to be synced
# to subcloud too.
# it is considered as a different identity resource.
# Note that the user id is compared, since user id has to be synced
# to the subcloud too.
same_user = (m.id == sc.id and
m.domain_id == sc.domain_id and
m.default_project_id == sc.default_project_id and
@ -1451,7 +1773,31 @@ class IdentitySyncThread(SyncThread):
result = True
return result
def _has_same_identity_ids(self, m, sc):
def _same_identity_group_resource(self, m, sc):
LOG.debug("master={}, subcloud={}".format(m, sc),
extra=self.log_extra)
# For group the comparison is DB records by DB records.
# The group DB records are from two tables - group and
# user_group_membership tables. If any of them are not matched,
# it is considered as different identity resource.
# Note that the group id is compared, since group id has to be synced
# to the subcloud too.
same_group = (m.id == sc.id and
m.domain_id == sc.domain_id and
m.description == sc.description and
m.name == sc.name and
m.extra == sc.extra)
if not same_group:
return False
same_local_user_ids = (m.local_user_ids ==
sc.local_user_ids)
if not same_local_user_ids:
return False
return True
def _has_same_identity_user_ids(self, m, sc):
# If (user name + domain name) or use id is the same,
# the resources are considered to be the same resource.
# Any difference in other attributes will trigger an update (PUT)
@ -1459,6 +1805,14 @@ class IdentitySyncThread(SyncThread):
return ((m.local_user.name == sc.local_user.name and
m.domain_id == sc.domain_id) or m.id == sc.id)
def _has_same_identity_group_ids(self, m, sc):
# If (group name + domain name) or group id is the same,
# then the resources are considered to be the same.
# Any difference in other attributes will trigger an update (PUT)
# to that resource in subcloud.
return ((m.name == sc.name and m.domain_id == sc.domain_id)
or m.id == sc.id)
def _same_project_resource(self, m, sc):
LOG.debug("master={}, subcloud={}".format(m, sc),
extra=self.log_extra)
@ -1510,7 +1864,7 @@ class IdentitySyncThread(SyncThread):
LOG.debug("same_assignment master={}, subcloud={}".format(m, sc),
extra=self.log_extra)
# For an assignment to be the same, all 3 of its role, project and
# user information must match up.
# actor (user/group) information must match up.
# Compare by names here is fine, since this comparison gets called
# only if the mapped subcloud assignment is found by id in subcloud
# resources just retrieved. In another word, the ids are guaranteed
@ -1518,8 +1872,8 @@ class IdentitySyncThread(SyncThread):
# audit_find_missing(). same_resource() in audit_find_missing() is
# actually redundant for assignment but it's the generic algorithm
# for all types of resources.
return((m.user.name == sc.user.name and
m.user.domain_id == sc.user.domain_id) and
return((m.actor.name == sc.actor.name and
m.actor.domain_id == sc.actor.domain_id) and
(m.role.name == sc.role.name and
m.role.domain_id == sc.role.domain_id) and
(m.project.name == sc.project.name and
@ -1558,7 +1912,7 @@ class IdentitySyncThread(SyncThread):
def get_master_resources(self, resource_type):
# Retrieve master resources from DB or through Keystone.
# users, projects, roles, and token revocation events use
# users, groups, projects, roles, and token revocation events use
# dbsync client, other resources use keystone client.
if self.is_resource_handled_by_dbs_client(resource_type):
try:
@ -1642,7 +1996,10 @@ class IdentitySyncThread(SyncThread):
def same_resource(self, resource_type, m_resource, sc_resource):
if (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_USERS):
return self._same_identity_resource(m_resource, sc_resource)
return self._same_identity_user_resource(m_resource, sc_resource)
elif (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_GROUPS):
return self._same_identity_group_resource(m_resource, sc_resource)
elif (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_PROJECTS):
return self._same_project_resource(m_resource, sc_resource)
@ -1662,7 +2019,10 @@ class IdentitySyncThread(SyncThread):
def has_same_ids(self, resource_type, m_resource, sc_resource):
if (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_USERS):
return self._has_same_identity_ids(m_resource, sc_resource)
return self._has_same_identity_user_ids(m_resource, sc_resource)
elif (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_GROUPS):
return self._has_same_identity_group_ids(m_resource, sc_resource)
elif (resource_type ==
consts.RESOURCE_TYPE_IDENTITY_PROJECTS):
return self._has_same_project_ids(m_resource, sc_resource)
@ -1790,6 +2150,7 @@ class IdentitySyncThread(SyncThread):
def is_resource_handled_by_dbs_client(resource_type):
if resource_type in [
consts.RESOURCE_TYPE_IDENTITY_USERS,
consts.RESOURCE_TYPE_IDENTITY_GROUPS,
consts.RESOURCE_TYPE_IDENTITY_PROJECTS,
consts.RESOURCE_TYPE_IDENTITY_ROLES,
consts.RESOURCE_TYPE_IDENTITY_TOKEN_REVOKE_EVENTS,