Skip to content

Commit 013194a

Browse files
lpichlerEvanCasey13
authored andcommitted
[RHCLOUD-37152] Add locks on Roles, CAR and groups in migrator (RedHatInsights#1441)
* Lock group and cross account objects in migrator * Use dual write handler for role in role migrator * Extend scope for transaction for groups in migration and lock groups * Add prepare prepare_for_update for role migration * Don't require to run this in maintenance mode * Extend scope for transaction in group principal removals * Remove unnecessary condition * Use primary keys for role and group queries
1 parent 4b6a2d5 commit 013194a

File tree

4 files changed

+143
-119
lines changed

4 files changed

+143
-119
lines changed

rbac/management/group/view.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,10 @@ class GroupViewSet(
185185

186186
def get_queryset(self):
187187
"""Obtain queryset for requesting user based on access."""
188-
add_principals_method = self.action == "principals" and self.request.method == "POST"
188+
principals_method = self.action == "principals" and (self.request.method != "GET")
189189
destroy_method = self.action == "destroy"
190190

191-
if add_principals_method or destroy_method:
191+
if principals_method or destroy_method:
192192
# In this case, the group must be locked to prevent principal changes during deletion.
193193
# If not locked, replication to relations may be out of sync due to phantom reads.
194194
# We have to modify the starting queryset to support locking because
@@ -937,19 +937,19 @@ def principals(self, request: Request, uuid: Optional[UUID] = None):
937937
page = self.paginate_queryset(resp.get("data"))
938938
response = self.get_paginated_response(page)
939939
else:
940-
group = self.get_object()
940+
with transaction.atomic():
941+
group = self.get_object()
941942

942-
self.protect_system_groups("remove principals")
943+
self.protect_system_groups("remove principals")
943944

944-
if not request.user.admin:
945-
self.protect_group_with_user_access_admin_role(group.roles_with_access(), "remove_principals")
945+
if not request.user.admin:
946+
self.protect_group_with_user_access_admin_role(group.roles_with_access(), "remove_principals")
946947

947-
if SERVICE_ACCOUNTS_KEY not in request.query_params and USERNAMES_KEY not in request.query_params:
948-
key = "detail"
949-
message = "Query parameter {} or {} is required.".format(SERVICE_ACCOUNTS_KEY, USERNAMES_KEY)
950-
raise serializers.ValidationError({key: _(message)})
948+
if SERVICE_ACCOUNTS_KEY not in request.query_params and USERNAMES_KEY not in request.query_params:
949+
key = "detail"
950+
message = "Query parameter {} or {} is required.".format(SERVICE_ACCOUNTS_KEY, USERNAMES_KEY)
951+
raise serializers.ValidationError({key: _(message)})
951952

952-
with transaction.atomic():
953953
service_accounts_to_remove = []
954954
# Remove the service accounts from the group.
955955
if SERVICE_ACCOUNTS_KEY in request.query_params:

rbac/management/role/relation_api_dual_write_handler.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from management.relation_replicator.relation_replicator import ReplicationEvent
3232
from management.relation_replicator.relation_replicator import ReplicationEventType
3333
from management.role.model import BindingMapping, Role
34-
from migration_tool.migrate import migrate_role
34+
from migration_tool.migrate_role import migrate_role
3535
from migration_tool.sharedSystemRolesReplicatedRoleBindings import v1_perm_to_v2_perm
3636
from migration_tool.utils import create_relationship
3737

rbac/migration_tool/migrate.py

+53-107
Original file line numberDiff line numberDiff line change
@@ -16,140 +16,80 @@
1616
"""
1717

1818
import logging
19-
from typing import Iterable
2019

21-
from django.conf import settings
2220
from django.db import transaction
23-
from kessel.relations.v1beta1 import common_pb2
2421
from management.group.relation_api_dual_write_group_handler import RelationApiDualWriteGroupHandler
25-
from management.models import Workspace
22+
from management.models import Group
2623
from management.principal.model import Principal
2724
from management.relation_replicator.logging_replicator import LoggingReplicator
2825
from management.relation_replicator.outbox_replicator import OutboxReplicator
2926
from management.relation_replicator.relation_replicator import (
30-
PartitionKey,
3127
RelationReplicator,
32-
ReplicationEvent,
3328
ReplicationEventType,
3429
)
3530
from management.relation_replicator.relations_api_replicator import RelationsApiReplicator
36-
from management.role.model import BindingMapping, Role
37-
from migration_tool.models import V2rolebinding
38-
from migration_tool.sharedSystemRolesReplicatedRoleBindings import v1_role_to_v2_bindings
39-
from migration_tool.utils import create_relationship
31+
from management.role.model import Role
32+
from management.role.relation_api_dual_write_handler import RelationApiDualWriteHandler
4033

4134
from api.cross_access.relation_api_dual_write_cross_access_handler import RelationApiDualWriteCrossAccessHandler
4235
from api.models import CrossAccountRequest, Tenant
4336

4437
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
4538

4639

47-
def get_kessel_relation_tuples(
48-
v2_role_bindings: Iterable[V2rolebinding],
49-
default_workspace: Workspace,
50-
) -> list[common_pb2.Relationship]:
51-
"""Generate a set of relationships and BindingMappings for the given set of v2 role bindings."""
52-
relationships: list[common_pb2.Relationship] = list()
53-
54-
for v2_role_binding in v2_role_bindings:
55-
relationships.extend(v2_role_binding.as_tuples())
56-
57-
bound_resource = v2_role_binding.resource
58-
59-
# Is this a workspace binding, but not to the root workspace?
60-
# If so, ensure this workspace is a child of the root workspace.
61-
# All other resource-resource or resource-workspace relations
62-
# which may be implied or necessary are intentionally ignored.
63-
# These should come from the apps that own the resource.
64-
if bound_resource.resource_type == ("rbac", "workspace") and not bound_resource.resource_id == str(
65-
default_workspace.id
66-
):
67-
# This is not strictly necessary here and the relation may be a duplicate.
68-
# Once we have more Workspace API / Inventory Group migration progress,
69-
# this block can and probably should be removed.
70-
# One of those APIs will add it themselves.
71-
relationships.append(
72-
create_relationship(
73-
bound_resource.resource_type,
74-
bound_resource.resource_id,
75-
("rbac", "workspace"),
76-
str(default_workspace.id),
77-
"parent",
78-
)
79-
)
80-
81-
return relationships
82-
83-
84-
def migrate_role(
85-
role: Role,
86-
default_workspace: Workspace,
87-
current_bindings: Iterable[BindingMapping] = [],
88-
) -> tuple[list[common_pb2.Relationship], list[BindingMapping]]:
89-
"""
90-
Migrate a role from v1 to v2, returning the tuples and mappings.
91-
92-
The mappings are returned so that we can reconstitute the corresponding tuples for a given role.
93-
This is needed so we can remove those tuples when the role changes if needed.
94-
"""
95-
v2_role_bindings = v1_role_to_v2_bindings(role, default_workspace, current_bindings)
96-
relationships = get_kessel_relation_tuples([m.get_role_binding() for m in v2_role_bindings], default_workspace)
97-
return relationships, v2_role_bindings
98-
99-
10040
def migrate_groups_for_tenant(tenant: Tenant, replicator: RelationReplicator):
10141
"""Generate user relationships and system role assignments for groups in a tenant."""
102-
groups = tenant.group_set.all()
42+
groups = tenant.group_set.only("pk").values("pk")
10343
for group in groups:
104-
principals: list[Principal] = []
105-
system_roles: list[Role] = []
106-
if not group.platform_default:
107-
principals = group.principals.all()
108-
if group.system is False and group.admin_default is False:
109-
system_roles = group.roles().public_tenant_only()
110-
if any(True for _ in system_roles) or any(True for _ in principals):
111-
# The migrator does not generally deal with concurrency control,
112-
# but we require an atomic block due to use of select_for_update in the dual write handler.
113-
with transaction.atomic():
44+
# The migrator deals with concurrency control.
45+
# We need an atomic block because the select_for_update is used in the dual write handler,
46+
# and the group must be locked to add principals to the groups.
47+
# NOTE: The lock on the group is not necessary when adding system roles to the group,
48+
# as the binding mappings are locked during this process to ensure concurrency control.
49+
# Start of transaction for group operations
50+
with transaction.atomic():
51+
# Requery the group with a lock
52+
group = Group.objects.select_for_update().get(pk=group["pk"])
53+
principals: list[Principal] = []
54+
system_roles: list[Role] = []
55+
if not group.platform_default:
56+
principals = group.principals.all()
57+
if group.system is False and group.admin_default is False:
58+
system_roles = group.roles().public_tenant_only()
59+
if any(True for _ in system_roles) or any(True for _ in principals):
11460
dual_write_handler = RelationApiDualWriteGroupHandler(
11561
group, ReplicationEventType.MIGRATE_TENANT_GROUPS, replicator=replicator
11662
)
63+
# this operation requires lock on group as well as in view,
64+
# more details in GroupViewSet#get_queryset method which is used to add principals.
11765
dual_write_handler.generate_relations_to_add_principals(principals)
66+
# lock on group is not required to add system role, only binding mappings which is included in
67+
# dual_write_handler
11868
dual_write_handler.generate_relations_to_add_roles(system_roles)
11969
dual_write_handler.replicate()
70+
# End of transaction for group operations, locks are released
12071

12172

12273
def migrate_roles_for_tenant(tenant, exclude_apps, replicator):
12374
"""Migrate all roles for a given tenant."""
124-
default_workspace = Workspace.objects.get(type=Workspace.Types.DEFAULT, tenant=tenant)
125-
126-
roles = tenant.role_set.all()
75+
roles = tenant.role_set.only("pk")
12776
if exclude_apps:
12877
roles = roles.exclude(access__permission__application__in=exclude_apps)
129-
130-
for role in roles:
131-
logger.info(f"Migrating role: {role.name} with UUID {role.uuid}.")
132-
133-
tuples, mappings = migrate_role(role, default_workspace)
134-
135-
# Conflicts are not ignored in order to prevent this from
136-
# accidentally running concurrently with dual-writes.
137-
# If migration should be rerun, then the bindings table should be dropped.
138-
# If changing this to allow updates,
139-
# always ensure writes are paused before running.
140-
# This must always be the case, but this should at least start failing you if you forget.
141-
BindingMapping.objects.bulk_create(mappings, ignore_conflicts=False)
142-
143-
replicator.replicate(
144-
ReplicationEvent(
145-
event_type=ReplicationEventType.MIGRATE_CUSTOM_ROLE,
146-
info={"role_uuid": str(role.uuid)},
147-
partition_key=PartitionKey.byEnvironment(),
148-
add=tuples,
78+
role_pks = roles.values_list("pk", flat=True)
79+
for role in role_pks:
80+
# The migrator deals with concurrency control and roles needs to be locked.
81+
with transaction.atomic():
82+
# Requery and lock role
83+
role = Role.objects.select_for_update().get(pk=role)
84+
logger.info(f"Migrating role: {role.name} with UUID {role.uuid}.")
85+
dual_write_handler = RelationApiDualWriteHandler(
86+
role, ReplicationEventType.MIGRATE_CUSTOM_ROLE, replicator
14987
)
150-
)
151-
88+
dual_write_handler.prepare_for_update()
89+
dual_write_handler.replicate_new_or_updated_role(role)
90+
# End of transaction, locks on role is released.
15291
logger.info(f"Migration completed for role: {role.name} with UUID {role.uuid}.")
92+
15393
logger.info(f"Migrated {roles.count()} roles for tenant: {tenant.org_id}")
15494

15595

@@ -171,32 +111,38 @@ def migrate_data_for_tenant(tenant: Tenant, exclude_apps: list, replicator: Rela
171111
logger.info("Finished relations of cross account requests.")
172112

173113

174-
# The migrator does not generally deal with concurrency control,
175-
# but we require an atomic block due to use of select_for_update in the dual write handler.
176114
def migrate_cross_account_requests(tenant: Tenant, replicator: RelationReplicator):
177115
"""Migrate approved account requests."""
178116
cross_account_requests = CrossAccountRequest.objects.filter(status="approved", target_org=tenant.org_id)
179117
for cross_account_request in cross_account_requests:
118+
# The migrator deals with concurrency control.
119+
# We need an atomic block because the select_for_update is used in the dual write handler,
120+
# and cross account request must be locked to add roles.
121+
# Start of transaction for approved cross account request and "add roles" operation
180122
with transaction.atomic():
123+
# Lock cross account request
124+
cross_account_request = CrossAccountRequest.objects.select_for_update().get(pk=cross_account_request.pk)
181125
cross_account_roles = cross_account_request.roles.all()
182126
if any(True for _ in cross_account_roles):
183127
dual_write_handler = RelationApiDualWriteCrossAccessHandler(
184128
cross_account_request, ReplicationEventType.MIGRATE_CROSS_ACCOUNT_REQUEST, replicator
185129
)
130+
# This operation requires lock on cross account request as is done
131+
# in CrossAccountRequestViewSet#get_queryset
132+
# This also locks binding mapping if exists for passed system roles.
186133
dual_write_handler.generate_relations_to_add_roles(cross_account_request.roles.all())
187134
dual_write_handler.replicate()
135+
# End of transaction for approved cross account request and its add role operation
136+
# Locks on cross account request and eventually on default workspace are released.
137+
# Default workspace is locked when related binding mapping did not exist yet
138+
# (Considering the position of this algorithm,the binding mappings for system roles should already exist,
139+
# as they are tied to the system roles.)
188140

189141

190142
def migrate_data(
191143
exclude_apps: list = [], orgs: list = [], write_relationships: str = "False", skip_roles: bool = False
192144
):
193145
"""Migrate all data for all tenants."""
194-
# Only run this in maintanence mode or
195-
# if we don't write relationships (testing out the migration and clean up the created bindingmappings)
196-
if not settings.READ_ONLY_API_MODE and write_relationships != "False":
197-
logger.fatal("Read-only API mode is required. READ_ONLY_API_MODE must be set to true.")
198-
return
199-
200146
count = 0
201147
tenants = Tenant.objects.filter(ready=True).exclude(tenant_name="public")
202148
replicator = _get_replicator(write_relationships)

rbac/migration_tool/migrate_role.py

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Copyright 2019 Red Hat, Inc.
3+
4+
This program is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU Affero General Public License as
6+
published by the Free Software Foundation, either version 3 of the
7+
License, or (at your option) any later version.
8+
9+
This program is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU Affero General Public License for more details.
13+
14+
You should have received a copy of the GNU Affero General Public License
15+
along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
"""
17+
18+
from typing import Iterable
19+
20+
from kessel.relations.v1beta1 import common_pb2
21+
from management.role.model import BindingMapping, Role
22+
from management.workspace.model import Workspace
23+
from migration_tool.models import V2rolebinding
24+
from migration_tool.sharedSystemRolesReplicatedRoleBindings import v1_role_to_v2_bindings
25+
from migration_tool.utils import create_relationship
26+
27+
28+
def get_kessel_relation_tuples(
29+
v2_role_bindings: Iterable[V2rolebinding],
30+
default_workspace: Workspace,
31+
) -> list[common_pb2.Relationship]:
32+
"""Generate a set of relationships and BindingMappings for the given set of v2 role bindings."""
33+
relationships: list[common_pb2.Relationship] = list()
34+
35+
for v2_role_binding in v2_role_bindings:
36+
relationships.extend(v2_role_binding.as_tuples())
37+
38+
bound_resource = v2_role_binding.resource
39+
40+
# Is this a workspace binding, but not to the root workspace?
41+
# If so, ensure this workspace is a child of the root workspace.
42+
# All other resource-resource or resource-workspace relations
43+
# which may be implied or necessary are intentionally ignored.
44+
# These should come from the apps that own the resource.
45+
if bound_resource.resource_type == ("rbac", "workspace") and not bound_resource.resource_id == str(
46+
default_workspace.id
47+
):
48+
# This is not strictly necessary here and the relation may be a duplicate.
49+
# Once we have more Workspace API / Inventory Group migration progress,
50+
# this block can and probably should be removed.
51+
# One of those APIs will add it themselves.
52+
relationships.append(
53+
create_relationship(
54+
bound_resource.resource_type,
55+
bound_resource.resource_id,
56+
("rbac", "workspace"),
57+
str(default_workspace.id),
58+
"parent",
59+
)
60+
)
61+
62+
return relationships
63+
64+
65+
def migrate_role(
66+
role: Role,
67+
default_workspace: Workspace,
68+
current_bindings: Iterable[BindingMapping] = [],
69+
) -> tuple[list[common_pb2.Relationship], list[BindingMapping]]:
70+
"""
71+
Migrate a role from v1 to v2, returning the tuples and mappings.
72+
73+
The mappings are returned so that we can reconstitute the corresponding tuples for a given role.
74+
This is needed so we can remove those tuples when the role changes if needed.
75+
"""
76+
v2_role_bindings = v1_role_to_v2_bindings(role, default_workspace, current_bindings)
77+
relationships = get_kessel_relation_tuples([m.get_role_binding() for m in v2_role_bindings], default_workspace)
78+
return relationships, v2_role_bindings

0 commit comments

Comments
 (0)