Skip to content

Commit 5dc354f

Browse files
committed
DEP-262: add SUPER_ADMIN role and associated functionality to db; remove staff_user tenant_id column
1 parent 4c7ac72 commit 5dc354f

32 files changed

Lines changed: 440 additions & 94 deletions

CHANGELOG.MD

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## May 4, 2026
2+
3+
- **Feature** Sync SUPER_ADMIN tenant membership and protect super admins in user management [🎟️ DEP-262](https://citz-gdx.atlassian.net/browse/DEP-262)
4+
- Added a migration to create the `SUPER_ADMIN` group, the `super_admin` role, and their mapping so super admins can be represented in tenant memberships.
5+
- Updated the API to add or remove `SUPER_ADMIN` group membership based on the user's Keycloak roles during login/request processing, including unit tests for assignment and removal.
6+
- Updated the user management UI to recognize Super Admin as a composite role and prevent administrators and super admins from being deactivated, while showing the correct permission messaging.
7+
- Removed the tenant_id column from staff_user table, as tenant membership is determined by group memberships rather than a direct column on the user model, making the column redundant.
8+
19
## April 30, 2026
210

311
- **Feature** Updated all S3 uploaded objects to dynamically generate document URLs from the bucket and object key, rather than storing the full URL in the database. [🎟️ DEP-261](https://citz-gdx.atlassian.net/browse/DEP-261)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Add super admin role/group mapping.
2+
3+
Revision ID: 9d2d16f5f3aa
4+
Revises: 8e50e47b7d32
5+
Create Date: 2026-04-30 12:00:00.000000
6+
"""
7+
8+
from alembic import op
9+
10+
# revision identifiers, used by Alembic.
11+
revision = '9d2d16f5f3aa'
12+
down_revision = '8e50e47b7d32'
13+
branch_labels = None
14+
depends_on = None
15+
16+
17+
def upgrade():
18+
op.execute(
19+
"""
20+
INSERT INTO user_group (created_date, updated_date, id, name, created_by, updated_by)
21+
SELECT NOW(), NOW(), COALESCE((SELECT MAX(id) + 1 FROM user_group), 1), 'SUPER_ADMIN', NULL, NULL
22+
WHERE NOT EXISTS (
23+
SELECT 1 FROM user_group WHERE name = 'SUPER_ADMIN'
24+
)
25+
"""
26+
)
27+
28+
op.execute(
29+
"""
30+
INSERT INTO user_role (created_date, updated_date, id, name, description, created_by, updated_by)
31+
SELECT NOW(), NOW(), COALESCE((SELECT MAX(id) + 1 FROM user_role), 1),
32+
'super_admin', 'Role for system-wide super administrator access', NULL, NULL
33+
WHERE NOT EXISTS (
34+
SELECT 1 FROM user_role WHERE name = 'super_admin'
35+
)
36+
"""
37+
)
38+
39+
op.execute(
40+
"""
41+
INSERT INTO group_role_mapping (created_date, updated_date, id, role_id, group_id, created_by, updated_by)
42+
SELECT NOW(), NOW(), COALESCE((SELECT MAX(id) + 1 FROM group_role_mapping), 1),
43+
r.id, g.id, NULL, NULL
44+
FROM user_group g
45+
JOIN user_role r ON r.name = 'super_admin'
46+
WHERE g.name = 'SUPER_ADMIN'
47+
AND NOT EXISTS (
48+
SELECT 1
49+
FROM group_role_mapping m
50+
WHERE m.group_id = g.id
51+
AND m.role_id = r.id
52+
)
53+
"""
54+
)
55+
56+
57+
def downgrade():
58+
op.execute(
59+
"""
60+
DELETE FROM user_group_membership
61+
WHERE group_id IN (
62+
SELECT id FROM user_group WHERE name = 'SUPER_ADMIN'
63+
)
64+
"""
65+
)
66+
67+
op.execute(
68+
"""
69+
DELETE FROM group_role_mapping
70+
WHERE group_id IN (
71+
SELECT id FROM user_group WHERE name = 'SUPER_ADMIN'
72+
)
73+
OR role_id IN (
74+
SELECT id FROM user_role WHERE name = 'super_admin'
75+
)
76+
"""
77+
)
78+
79+
op.execute("DELETE FROM user_role WHERE name = 'super_admin'")
80+
op.execute("DELETE FROM user_group WHERE name = 'SUPER_ADMIN'")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Remove redundant tenant_id from staff_users.
2+
3+
Revision ID: a6f15ee2c7b4
4+
Revises: 9d2d16f5f3aa
5+
Create Date: 2026-05-04 16:40:00.000000
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = 'a6f15ee2c7b4'
14+
down_revision = '9d2d16f5f3aa'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
_drop_matching_foreign_keys('staff_users', 'tenant_id', 'tenant')
21+
with op.batch_alter_table('staff_users', schema=None) as batch_op:
22+
batch_op.drop_column('tenant_id')
23+
24+
25+
def downgrade():
26+
with op.batch_alter_table('staff_users', schema=None) as batch_op:
27+
batch_op.add_column(sa.Column('tenant_id', sa.Integer(), nullable=True))
28+
29+
op.create_foreign_key(None, 'staff_users', 'tenant', ['tenant_id'], ['id'])
30+
31+
32+
def _drop_matching_foreign_keys(table_name, local_column, referred_table):
33+
"""Drop FK constraints for a specific local column and referenced table."""
34+
bind = op.get_bind()
35+
inspector = sa.inspect(bind)
36+
foreign_keys = inspector.get_foreign_keys(table_name)
37+
38+
for foreign_key in foreign_keys:
39+
name = foreign_key.get('name')
40+
constrained_columns = foreign_key.get('constrained_columns') or []
41+
current_referred_table = foreign_key.get('referred_table')
42+
43+
if (
44+
name
45+
and local_column in constrained_columns
46+
and current_referred_table == referred_table
47+
):
48+
op.drop_constraint(name, table_name, type_='foreignkey')

api/src/api/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ def get_roles(token_info) -> list:
205205
# ... so any extraneous roles are discarded
206206
user_roles = list(set(roles_from_token).intersection(keycloak_forwarded_roles))
207207

208+
StaffUserService.sync_super_admin_membership(
209+
token_info=token_info,
210+
token_roles=roles_from_token,
211+
tenant_id=getattr(g, 'tenant_id', None),
212+
)
213+
208214
# Retrieve user by external ID from token info
209215
user = StaffUserService.get_user_by_external_id(token_info['sub'])
210216

api/src/api/models/base_model.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
from sqlalchemy.ext.declarative import declared_attr
2020

2121
from .db import db
22-
from ..utils.token_info import TokenInfo
23-
2422
TENANT_ID = 'tenant_id'
2523

2624

@@ -48,7 +46,8 @@ def _get_current_user():
4846
4947
Used to populate the created_by and modified_by relationships on all models.
5048
"""
51-
return TokenInfo.get_id()
49+
token_info = getattr(g, 'jwt_oidc_token_info', None) or {}
50+
return token_info.get('sub')
5251

5352
@classmethod
5453
def find_by_id(cls, identifier: int):

api/src/api/models/staff_user.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Optional
88

99
from flask import g
10-
from sqlalchemy import Column, ForeignKey, String, asc, desc, func, or_
10+
from sqlalchemy import Column, ForeignKey, String, asc, desc, func
1111
from sqlalchemy.orm import column_property
1212
from sqlalchemy.sql import text
1313
from sqlalchemy.sql.operators import ilike_op
@@ -36,7 +36,6 @@ class StaffUser(BaseModel):
3636
contact_number = Column(db.String(50), nullable=True)
3737
external_id = Column(db.String(50), nullable=False, unique=True)
3838
status_id = db.Column(db.Integer, ForeignKey('user_status.id'), nullable=False, default=1)
39-
tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True)
4039

4140
@classmethod
4241
def get_all_paginated(cls, pagination_options: PaginationOptions, search_text='', include_inactive=False):
@@ -67,18 +66,12 @@ def get_all_paginated(cls, pagination_options: PaginationOptions, search_text=''
6766
@classmethod
6867
def _add_tenant_filter(cls, query):
6968
"""Add tenant filtering to the query based on user group membership."""
70-
has_tenant_id = hasattr(cls, TENANT_ID)
7169
has_g_tenant_id = hasattr(g, TENANT_ID) and g.tenant_id
72-
if has_tenant_id and has_g_tenant_id:
73-
return query.outerjoin(
70+
if has_g_tenant_id:
71+
return query.join(
7472
UserGroupMembership,
7573
(UserGroupMembership.staff_user_external_id == cls.external_id) &
7674
(UserGroupMembership.tenant_id == g.tenant_id)
77-
).filter(
78-
or_(
79-
UserGroupMembership.id != None, # noqa: E711 # pylint: disable=C0121
80-
cls.tenant_id == g.tenant_id, # new user, no role yet
81-
)
8275
)
8376
return query
8477

api/src/api/schemas/staff_user.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,4 @@ class Meta: # pylint: disable=too-few-public-methods
2525
created_date = fields.Str(data_key='created_date')
2626
updated_date = fields.Str(data_key='updated_date')
2727
roles = fields.List(fields.Str(data_key='roles'))
28-
tenant_id = fields.Str(data_key='tenant_id')
2928
status_id = fields.Int(data_key='status_id')

api/src/api/services/staff_user_service.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from api.utils import notification
1616
from api.utils.constants import CompositeRoles
1717
from api.utils.enums import CompositeRoleId, CompositeRoleNames, UserStatus
18+
from api.utils.roles import Role
1819
from api.utils.template import Template
1920

2021

@@ -48,20 +49,62 @@ def create_or_update_user(self, user: dict):
4849
if db_user is None:
4950
new_user = StaffUserModel.create_user(user)
5051
if len(user.get('roles', [])) == 0:
51-
self._send_access_request_email(new_user)
52+
self._send_access_request_email(new_user, getattr(g, 'tenant_id', None))
5253
return new_user
5354

5455
return StaffUserModel.update_user(db_user.id, user)
5556

57+
@classmethod
58+
def sync_super_admin_membership(cls, token_info: dict, token_roles, tenant_id=None):
59+
"""Sync SUPER_ADMIN tenant membership from keycloak roles at login/request time."""
60+
if getattr(g, 'syncing_super_admin_membership', False) or getattr(db.session, '_flushing', False):
61+
return
62+
63+
g.syncing_super_admin_membership = True
64+
if not token_info:
65+
g.syncing_super_admin_membership = False
66+
return
67+
68+
try:
69+
external_id = token_info.get('sub')
70+
if not external_id:
71+
return
72+
73+
has_super_admin_role = Role.SUPER_ADMIN.value in set(token_roles or [])
74+
75+
db_user = StaffUserModel.get_user_by_external_id(external_id, include_inactive=True)
76+
if db_user is None:
77+
user_data = {
78+
'external_id': external_id,
79+
'first_name': token_info.get('given_name'),
80+
'last_name': token_info.get('family_name'),
81+
'email_address': token_info.get('email'),
82+
'username': token_info.get('preferred_username'),
83+
'identity_provider': token_info.get('identity_provider', ''),
84+
'roles': set(token_roles or []),
85+
}
86+
required = ['external_id', 'first_name', 'last_name', 'email_address']
87+
if all(user_data.get(field) for field in required):
88+
cls().create_or_update_user(user_data)
89+
90+
if has_super_admin_role and tenant_id:
91+
UserGroupMembershipService.ensure_group_membership(external_id, tenant_id, 'SUPER_ADMIN')
92+
return
93+
94+
if not has_super_admin_role:
95+
UserGroupMembershipService.remove_group_memberships_by_group_name(external_id, 'SUPER_ADMIN')
96+
finally:
97+
g.syncing_super_admin_membership = False
98+
5699
@staticmethod
57-
def _send_access_request_email(user: StaffUserModel) -> None:
100+
def _send_access_request_email(user: StaffUserModel, tenant_id: Optional[int] = None) -> None:
58101
"""Send a new user email.Throws error if fails."""
59102
templates = current_app.config['EMAIL_TEMPLATES']
60103
to_email_address = templates['ACCESS_REQUEST']['DEST_EMAIL_ADDRESS']
61104
if to_email_address is None:
62105
return
63106
template_id = templates['ACCESS_REQUEST']['ID']
64-
subject, body, args = StaffUserService._render_email_template(user)
107+
subject, body, args = StaffUserService._render_email_template(user, tenant_id)
65108
try:
66109
notification.send_email(subject=subject,
67110
email=to_email_address,
@@ -75,13 +118,14 @@ def _send_access_request_email(user: StaffUserModel) -> None:
75118
status_code=HTTPStatus.INTERNAL_SERVER_ERROR) from exc
76119

77120
@staticmethod
78-
def _render_email_template(user: StaffUserModel):
121+
def _render_email_template(user: StaffUserModel, tenant_id: Optional[int] = None):
79122
template = Template.get_template('email_access_request.html')
80123
templates = current_app.config['EMAIL_TEMPLATES']
81124
paths = current_app.config['PATHS']
82125
subject = templates['ACCESS_REQUEST']['SUBJECT']
126+
tenant_id = tenant_id if tenant_id is not None else getattr(g, 'tenant_id', None)
83127
grant_access_url = notification.get_tenant_site_url(
84-
user.tenant_id, paths['USER_MANAGEMENT']
128+
tenant_id, paths['USER_MANAGEMENT']
85129
).replace('{id}', str(user.id))
86130
email_environment = templates['ENVIRONMENT']
87131
args = {

api/src/api/services/user_group_membership_service.py

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

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

@@ -52,3 +54,57 @@ def assign_composite_role_to_user(membership_data):
5254
def reassign_composite_role_to_user(membership_data):
5355
"""Update user_group_membership."""
5456
return UserGroupMembership.update_user_group_membership(membership_data)
57+
58+
@staticmethod
59+
def ensure_group_membership(external_id: str, tenant_id: int, group_name: str):
60+
"""Ensure the user has a membership for the given tenant and group name."""
61+
if not external_id or not tenant_id or not group_name:
62+
return None
63+
64+
group = UserGroup.query.filter(UserGroup.name == group_name).first()
65+
if not group:
66+
return None
67+
68+
membership = UserGroupMembership.query.filter(
69+
UserGroupMembership.staff_user_external_id == external_id,
70+
UserGroupMembership.tenant_id == tenant_id,
71+
).first()
72+
73+
if membership:
74+
if membership.group_id != group.id or not membership.is_active:
75+
membership.group_id = group.id
76+
membership.is_active = True
77+
db.session.commit()
78+
return membership
79+
80+
membership = UserGroupMembership(
81+
staff_user_external_id=external_id,
82+
group_id=group.id,
83+
tenant_id=tenant_id,
84+
is_active=True,
85+
)
86+
membership.save()
87+
return membership
88+
89+
@staticmethod
90+
def remove_group_memberships_by_group_name(external_id: str, group_name: str) -> int:
91+
"""Remove all memberships for a user that belong to the given group name."""
92+
if not external_id or not group_name:
93+
return 0
94+
95+
memberships = UserGroupMembership.query.join(
96+
UserGroup,
97+
UserGroupMembership.group_id == UserGroup.id,
98+
).filter(
99+
UserGroupMembership.staff_user_external_id == external_id,
100+
UserGroup.name == group_name,
101+
).all()
102+
103+
if not memberships:
104+
return 0
105+
106+
for membership in memberships:
107+
db.session.delete(membership)
108+
109+
db.session.commit()
110+
return len(memberships)

api/src/api/utils/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class CompositeRoles(Enum):
2020
"""Enumeration representing user roles."""
2121

2222
ADMIN = 'Administrator'
23+
SUPER_ADMIN = 'Super Admin'
2324
TEAM_MEMBER = 'Team Member'
2425
REVIEWER = 'Reviewer'
2526
VIEWER = 'Viewer'

0 commit comments

Comments
 (0)