Skip to content

Commit a2f5c11

Browse files
committed
Add tenant-aware user deletion, access request creation for users without roles
1 parent 12e6635 commit a2f5c11

7 files changed

Lines changed: 303 additions & 26 deletions

File tree

api/src/api/resources/staff_user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ def delete(user_id):
111111
"""Permanently delete an inactive user."""
112112
try:
113113
user_data = TokenInfo.get_user_data()
114-
StaffUserService.delete_deactivated_user(user_id, user_data.get('external_id'))
115-
return {'message': 'User deleted successfully.'}, HTTPStatus.OK
114+
response = StaffUserService.delete_deactivated_user(user_id, user_data.get('external_id'))
115+
return response, HTTPStatus.OK
116116
except BusinessException as err:
117117
return {'message': err.error}, err.status_code
118118

api/src/api/services/staff_user_service.py

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from api.models.membership import Membership
1010
from api.models.pagination_options import PaginationOptions
1111
from api.models.staff_user import StaffUser as StaffUserModel
12+
from api.models.tenant import Tenant as TenantModel
1213
from api.models.user_group_membership import UserGroupMembership
1314
from api.schemas.staff_user import StaffUserSchema
1415
from api.services.user_group_membership_service import UserGroupMembershipService
@@ -44,25 +45,32 @@ def create_or_update_user(self, user: dict):
4445
self.validate_fields(user)
4546

4647
external_id = user.get('external_id')
48+
tenant_id = getattr(g, 'tenant_id', None)
49+
has_no_tenant_roles = len(user.get('roles', [])) == 0
4750
db_user = StaffUserModel.get_user_by_external_id(external_id, include_inactive=True)
4851

4952
if db_user is None:
5053
new_user = StaffUserModel.create_user(user)
51-
if len(user.get('roles', [])) == 0:
54+
if has_no_tenant_roles and tenant_id:
55+
UserGroupMembershipService.ensure_access_request_membership(external_id, tenant_id)
56+
if has_no_tenant_roles:
5257
self._send_access_request_email(new_user, getattr(g, 'tenant_id', None))
5358
return new_user
5459

60+
if has_no_tenant_roles and tenant_id:
61+
UserGroupMembershipService.ensure_access_request_membership(external_id, tenant_id)
62+
5563
return StaffUserModel.update_user(db_user.id, user)
5664

5765
@classmethod
5866
def sync_super_admin_membership(cls, token_info: dict, token_roles, tenant_id=None):
5967
"""Sync SUPER_ADMIN tenant membership from keycloak roles at login/request time."""
60-
if getattr(g, '_syncing_super_admin_membership', False):
68+
if getattr(g, 'syncing_super_admin_membership', False):
6169
return
6270

63-
g._syncing_super_admin_membership = True
71+
g.syncing_super_admin_membership = True
6472
if not token_info:
65-
g._syncing_super_admin_membership = False
73+
g.syncing_super_admin_membership = False
6674
return
6775

6876
try:
@@ -94,23 +102,41 @@ def sync_super_admin_membership(cls, token_info: dict, token_roles, tenant_id=No
94102
if not has_super_admin_role:
95103
UserGroupMembershipService.remove_group_memberships_by_group_name(external_id, 'SUPER_ADMIN')
96104
finally:
97-
g._syncing_super_admin_membership = False
105+
g.syncing_super_admin_membership = False
98106

99107
@staticmethod
100108
def _send_access_request_email(user: StaffUserModel, tenant_id: Optional[int] = None) -> None:
101109
"""Send a new user email.Throws error if fails."""
102110
templates = current_app.config['EMAIL_TEMPLATES']
103-
to_email_address = templates['ACCESS_REQUEST']['DEST_EMAIL_ADDRESS']
104-
if to_email_address is None:
105-
return
106111
template_id = templates['ACCESS_REQUEST']['ID']
107112
subject, body, args = StaffUserService._render_email_template(user, tenant_id)
113+
114+
resolved_tenant_id = tenant_id if tenant_id is not None else getattr(g, 'tenant_id', None)
115+
recipient_emails = []
116+
117+
global_contact_email = templates['ACCESS_REQUEST']['DEST_EMAIL_ADDRESS']
118+
if global_contact_email:
119+
recipient_emails.append(global_contact_email)
120+
121+
if resolved_tenant_id:
122+
tenant = TenantModel.find_by_id(resolved_tenant_id)
123+
if tenant and tenant.contact_email:
124+
recipient_emails.append(tenant.contact_email)
125+
126+
# Preserve order and avoid duplicate sends when contacts are the same.
127+
recipient_emails = list(dict.fromkeys(recipient_emails))
128+
if not recipient_emails:
129+
return
130+
108131
try:
109-
notification.send_email(subject=subject,
110-
email=to_email_address,
111-
html_body=body,
112-
args=args,
113-
template_id=template_id)
132+
for email in recipient_emails:
133+
notification.send_email(
134+
subject=subject,
135+
email=email,
136+
html_body=body,
137+
args=args,
138+
template_id=template_id,
139+
)
114140
except Exception as exc: # noqa: B902
115141
current_app.logger.error('<Notification for new user registration failed', exc)
116142
raise BusinessException(
@@ -153,9 +179,11 @@ def attach_roles(user_collection):
153179
composite_role = UserGroupMembershipService.get_user_group_within_tenant(user.get('external_id'),
154180
g.tenant_id)
155181
user['composite_roles'] = ''
182+
user['main_role'] = ''
156183
if composite_role:
157184
user['composite_roles'] = composite_role
158-
user['main_role'] = CompositeRoles[composite_role].value
185+
if composite_role in CompositeRoles.__members__:
186+
user['main_role'] = CompositeRoles[composite_role].value
159187

160188
@classmethod
161189
def find_users(
@@ -221,7 +249,12 @@ def validate_user(db_user: StaffUserModel):
221249

222250
@classmethod
223251
def delete_deactivated_user(cls, user_id, actor_external_id: Optional[str] = None):
224-
"""Permanently delete a user after required safeguards are satisfied."""
252+
"""Delete a deactivated user or only remove current tenant membership.
253+
254+
Behavior is tenant-aware:
255+
- If the user has memberships in other tenants, only remove membership in the current tenant.
256+
- If the user only belongs to the current tenant, fully delete the user.
257+
"""
225258
db_user = StaffUserModel.get_by_id(user_id, include_inactive=True)
226259
if db_user is None:
227260
raise BusinessException(error='User not found.', status_code=HTTPStatus.NOT_FOUND)
@@ -238,9 +271,48 @@ def delete_deactivated_user(cls, user_id, actor_external_id: Optional[str] = Non
238271
status_code=HTTPStatus.CONFLICT,
239272
)
240273

274+
current_tenant_id = getattr(g, 'tenant_id', None)
275+
if not current_tenant_id:
276+
raise BusinessException(
277+
error='Tenant context is required for this operation.',
278+
status_code=HTTPStatus.BAD_REQUEST,
279+
)
280+
281+
user_group_memberships = UserGroupMembership.query.filter(
282+
UserGroupMembership.staff_user_external_id == db_user.external_id,
283+
).all()
284+
285+
has_current_tenant_membership = any(
286+
membership.tenant_id == current_tenant_id for membership in user_group_memberships
287+
)
288+
if not has_current_tenant_membership:
289+
raise BusinessException(
290+
error='User does not have membership in the current tenant.',
291+
status_code=HTTPStatus.BAD_REQUEST,
292+
)
293+
294+
has_other_tenant_memberships = any(
295+
membership.tenant_id != current_tenant_id for membership in user_group_memberships
296+
)
297+
298+
if has_other_tenant_memberships:
299+
UserGroupMembership.query.filter(
300+
UserGroupMembership.staff_user_external_id == db_user.external_id,
301+
UserGroupMembership.tenant_id == current_tenant_id,
302+
).delete(synchronize_session=False)
303+
db.session.commit()
304+
return {
305+
'message': 'User removed from current tenant successfully.',
306+
'action': 'removed_current_tenant_membership',
307+
}
308+
241309
Membership.query.filter(Membership.user_id == db_user.id).delete(synchronize_session=False)
242310
UserGroupMembership.query.filter(
243311
UserGroupMembership.staff_user_external_id == db_user.external_id,
244312
).delete(synchronize_session=False)
245313
db.session.delete(db_user)
246314
db.session.commit()
315+
return {
316+
'message': 'User deleted successfully.',
317+
'action': 'deleted_user',
318+
}

api/src/api/services/user_group_membership_service.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""Service for user group membership management."""
22
from typing import List, Tuple
33

4+
from sqlalchemy import func
5+
46
from api.models.db import db
57
from api.models.user_group import UserGroup
68
from api.models.user_group_membership import UserGroupMembership
79
from api.models.user_role import UserRole
810

911

12+
ACCESS_REQUEST_GROUP_NAME = 'ACCESS_REQUEST'
13+
14+
1015
class UserGroupMembershipService:
1116
"""User group membership management service."""
1217

@@ -86,6 +91,39 @@ def ensure_group_membership(external_id: str, tenant_id: int, group_name: str):
8691
membership.save()
8792
return membership
8893

94+
@staticmethod
95+
def ensure_access_request_membership(external_id: str, tenant_id: int):
96+
"""Ensure a tenant-scoped access-request membership exists for a user.
97+
98+
The access-request group intentionally has no role mappings, so users
99+
become visible in tenant user management without receiving permissions.
100+
"""
101+
if not external_id or not tenant_id:
102+
return None
103+
104+
existing_membership = UserGroupMembership.query.filter(
105+
UserGroupMembership.staff_user_external_id == external_id,
106+
UserGroupMembership.tenant_id == tenant_id,
107+
).first()
108+
if existing_membership:
109+
return existing_membership
110+
111+
group = UserGroup.query.filter(UserGroup.name == ACCESS_REQUEST_GROUP_NAME).first()
112+
if not group:
113+
next_group_id = (db.session.query(func.max(UserGroup.id)).scalar() or 0) + 1
114+
group = UserGroup(name=ACCESS_REQUEST_GROUP_NAME)
115+
group.id = next_group_id
116+
group.save()
117+
118+
membership = UserGroupMembership(
119+
staff_user_external_id=external_id,
120+
group_id=group.id,
121+
tenant_id=tenant_id,
122+
is_active=True,
123+
)
124+
membership.save()
125+
return membership
126+
89127
@staticmethod
90128
def remove_group_memberships_by_group_name(external_id: str, group_name: str) -> int:
91129
"""Remove all memberships for a user that belong to the given group name."""

api/tests/unit/api/test_user.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,56 @@ def test_create_staff_user(client, jwt, session, side_effect, expected_status,
8181
assert rv.status_code == expected_status
8282

8383

84+
def test_no_role_login_creates_access_request_membership_for_new_user(client, jwt, session, notify_mock):
85+
"""Assert no-role login creates a tenant-scoped ACCESS_REQUEST membership for new users."""
86+
set_global_tenant(tenant_id=1)
87+
claims = copy.deepcopy(TestJwtClaims.staff_admin_role.value)
88+
claims['sub'] = 'b2f9b95e-3e6d-4b25-9b58-611201fce100'
89+
claims['email'] = 'new-requester@gov.bc.ca'
90+
claims['client_roles'] = []
91+
92+
headers = factory_auth_header(jwt=jwt, claims=claims)
93+
rv = client.put('/api/user/', headers=headers, content_type=ContentType.JSON.value)
94+
95+
assert rv.status_code == HTTPStatus.OK
96+
97+
access_request_group = UserGroup.query.filter_by(name='ACCESS_REQUEST').first()
98+
assert access_request_group is not None
99+
100+
membership = UserGroupMembership.get_group_by_user_and_tenant_id(claims['sub'], 1)
101+
assert membership is not None
102+
assert membership.group_id == access_request_group.id
103+
104+
105+
def test_no_role_login_creates_access_request_membership_for_existing_user_in_new_tenant(client, jwt, session):
106+
"""Assert no-role login adds ACCESS_REQUEST membership when an existing user enters a new tenant."""
107+
set_global_tenant(tenant_id=1)
108+
existing_user = factory_staff_user_model(external_id='6fdde698-238f-4d65-8a4d-b71715ea99ab')
109+
factory_user_group_membership_model(str(existing_user.external_id), tenant_id=1)
110+
111+
tenant_data = dict(TestTenantInfo.tenant2)
112+
tenant_data['short_name'] = 'GDXT'
113+
tenant_2 = factory_tenant_model(tenant_data)
114+
115+
claims = copy.deepcopy(TestJwtClaims.staff_admin_role.value)
116+
claims['sub'] = str(existing_user.external_id)
117+
claims['email'] = existing_user.email_address
118+
claims['client_roles'] = []
119+
120+
headers = factory_auth_header(jwt=jwt, claims=claims)
121+
headers[TENANT_ID_HEADER] = tenant_2.short_name
122+
rv = client.put('/api/user/', headers=headers, content_type=ContentType.JSON.value)
123+
124+
assert rv.status_code == HTTPStatus.OK
125+
126+
access_request_group = UserGroup.query.filter_by(name='ACCESS_REQUEST').first()
127+
assert access_request_group is not None
128+
129+
membership = UserGroupMembership.get_group_by_user_and_tenant_id(str(existing_user.external_id), tenant_2.id)
130+
assert membership is not None
131+
assert membership.group_id == access_request_group.id
132+
133+
84134
def test_super_admin_login_assigns_super_admin_group_in_tenant(client, jwt, session):
85135
"""Assert super admin users are persisted in tenant membership on login."""
86136
set_global_tenant()

0 commit comments

Comments
 (0)