Skip to content

Commit 739ecb8

Browse files
authored
Merge pull request #2843 from bcgov/DEP-262-add-super-admin-role-db
DEP-262: add SUPER_ADMIN role and associated functionality to db; remove staff_user tenant_id column
2 parents 4c7ac72 + 6a62c00 commit 739ecb8

37 files changed

Lines changed: 750 additions & 121 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/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/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')

0 commit comments

Comments
 (0)