Skip to content

Commit

Permalink
[To Main] DESENG-557 - Add Superuser Role (#2482)
Browse files Browse the repository at this point in the history
* [To Feature] Backend changes for DESENG-557 (#2478)

* [To Feature] Frontend changes for DESENG-557 (#2479)

* [To Feature] Unit tests for DESENG-557 (#2480)

* Update changelog

* Update CI testing to support Python 3.12
  • Loading branch information
NatSquared authored May 2, 2024
1 parent 3f8e58a commit 645728a
Show file tree
Hide file tree
Showing 20 changed files with 191 additions and 119 deletions.
17 changes: 10 additions & 7 deletions .github/workflows/met-api-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
- "met-api/**"
push:
branches:
- main
- main

defaults:
run:
Expand Down Expand Up @@ -65,15 +65,14 @@ jobs:
JWT_OIDC_TEST_CLIENT_SECRET: "1111111111"
JWT_OIDC_TEST_JWKS_CACHE_TIMEOUT: "6000"


KEYCLOAK_ADMIN_CLIENTID: "met-admin"
KEYCLOAK_ADMIN_SECRET: "2222222222"
KEYCLOAK_AUTH_AUDIENCE: "met-web"
KEYCLOAK_AUTH_CLIENT_SECRET: "1111111111"
KEYCLOAK_BASE_URL: "http://localhost:8081/auth"
KEYCLOAK_REALMNAME: "demo"
USE_KEYCLOAK_DOCKER: "YES"

KEYCLOAK_TEST_ADMIN_CLIENTID: "met-admin"
KEYCLOAK_TEST_ADMIN_SECRET: "2222222222"
KEYCLOAK_TEST_AUTH_AUDIENCE: "met-web"
Expand All @@ -82,7 +81,7 @@ jobs:
KEYCLOAK_TEST_REALMNAME: "demo"
USE_TEST_KEYCLOAK_DOCKER: "YES"
SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@localhost:5432/postgres"

runs-on: ubuntu-20.04

services:
Expand All @@ -97,6 +96,10 @@ jobs:
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

strategy:
matrix:
python-version: [3.12]

steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -116,14 +119,14 @@ jobs:
run: |
echo "CODECOV_BRANCH=PR_${{github.head_ref}}" >> $GITHUB_ENV
if: github.event_name == 'pull_request'

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
flags: metapi
name: codecov-met-api
fail_ci_if_error: true
verbose: true
fail_ci_if_error: true
verbose: true
override_branch: ${{env.CODECOV_BRANCH}}
token: ${{ secrets.CODECOV_TOKEN }}

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
- Upgrading the version of python to 3.12 for dagster user code
- Upgrading the dependencies to support the version of python.
- Fixing a bug on survey etl service.
- **Feature** Add Super Admin role [🎟️ DESENG-557](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-557)
- Added a new role, `SUPER_ADMIN`, to MET.
- The `SUPER_ADMIN` role has the highest level of access in the MET application.
- The `SUPER_ADMIN` role can perform all actions in MET, including actions in other tenants.
- The role can be only be assigned through the single sign-on (SSO) service.
- This overrides any other permissions set, e.g, a Viewer user who gets the super_admin role
will have all the permissions of a normal super admin.
- Change the React app's admin role to `SUPER_ADMIN` (previously `CREATE_TENANT`).
- Force all UI elements to be enabled for the `SUPER_ADMIN` role.
- Added unit tests for the new role, and updated an existing test related to cross-tenant access.

## April 29, 2024

Expand Down
2 changes: 1 addition & 1 deletion met-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ build-req: clean ## Upgrade requirements
pip install -Ur requirements/repo-libraries.txt

install: clean ## Install python virtual environment
test -f venv/bin/activate || python3 -m venv $(CURRENT_ABS_DIR)/venv ;\
test -f venv/bin/activate || python3.12 -m venv $(CURRENT_ABS_DIR)/venv ;\
. venv/bin/activate ;\
pip install --upgrade pip ;\
pip install -Ur requirements.txt
Expand Down
23 changes: 13 additions & 10 deletions met-api/src/met_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,23 +153,26 @@ def get_roles(token_info) -> list:
app_context.logger.warning('Role info from keycloak is not a list!')
break

keycloak_forwarded_roles = [Role.CREATE_TENANT.value]
# Any roles from keycloak that should be available in the API.
# For now, we only want to know if a user is a super admin;
# everything else is handled in-app by the UserGroupMembershipService...
keycloak_forwarded_roles = [Role.SUPER_ADMIN.value]
# ... so any extraneous roles are discarded
user_roles = list(set(roles_from_token).intersection(keycloak_forwarded_roles))

# Retrieve user by external ID from token info
user = StaffUserService.get_user_by_external_id(token_info['sub'])

if user:
# Retrieve user roles within a tenant using UserGroupMembershipService
additional_user_roles, _ = UserGroupMembershipService.get_user_roles_within_tenant(
token_info['sub'], g.tenant_id)
if additional_user_roles:
# Add additional user roles to user_roles list
user_roles.extend(additional_user_roles)
app_context.logger.warning('Unable to find a role for the user within the tenant.')
# Retrieve user roles within a tenant using UserGroupMembershipService
additional_user_roles, _ = UserGroupMembershipService.get_user_roles_within_tenant(
token_info['sub'], g.tenant_id)
if user and additional_user_roles:
# Add additional user roles to user_roles list
user_roles.extend(additional_user_roles)
else:
app_context.logger.warning('Unable to find an active user within the tenant.')

if not user_roles:
app_context.logger.warning('Unable to find a role for the user within the tenant.')
return user_roles

app_context.config['JWT_ROLE_CALLBACK'] = get_roles
Expand Down
7 changes: 6 additions & 1 deletion met-api/src/met_api/models/staff_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from sqlalchemy.sql.operators import ilike_op

from met_api.utils.enums import UserStatus
from met_api.utils.roles import Role
from met_api.utils.token_info import TokenInfo

from .base_model import BaseModel
from .db import db
Expand Down Expand Up @@ -40,7 +42,10 @@ class StaffUser(BaseModel):
def get_all_paginated(cls, pagination_options: PaginationOptions, search_text='', include_inactive=False):
"""Fetch list of users by access type."""
query = cls.query
query = cls._add_tenant_filter(query)
# Don't filter out users from other tenants if the user is a super admin; show everything
if Role.SUPER_ADMIN.value not in TokenInfo.get_user_roles():
query = cls._add_tenant_filter(query)

if pagination_options.sort_key:
sort = asc(text(pagination_options.sort_key)) if pagination_options.sort_order == 'asc' \
else desc(text(pagination_options.sort_key))
Expand Down
15 changes: 8 additions & 7 deletions met-api/src/met_api/resources/engagement_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource, fields
from marshmallow import ValidationError
from met_api.auth import auth, auth_methods
from met_api.auth import auth_methods
from met_api.services import authorization
from met_api.services.engagement_service import EngagementService
from met_api.services.engagement_metadata_service import EngagementMetadataService
from met_api.utils.roles import Role
from met_api.utils.tenant_validator import require_role
from met_api.utils.util import allowedorigins, cors_preflight

EDIT_ENGAGEMENT_ROLES = [Role.EDIT_ENGAGEMENT.value]
Expand Down Expand Up @@ -74,7 +75,7 @@ class EngagementMetadata(Resource):
@cross_origin(origins=allowedorigins())
@API.doc(security='apikey')
@API.marshal_list_with(metadata_return_model)
@auth.has_one_of_roles(VIEW_ENGAGEMENT_ROLES)
@require_role(VIEW_ENGAGEMENT_ROLES)
def get(engagement_id):
"""Fetch engagement metadata entries by engagement id."""
return metadata_service.get_by_engagement(engagement_id)
Expand All @@ -85,7 +86,7 @@ def get(engagement_id):
@API.expect(metadata_create_model)
# type: ignore
@API.marshal_with(metadata_return_model, code=HTTPStatus.CREATED)
@auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES)
@require_role(EDIT_ENGAGEMENT_ROLES)
def post(engagement_id: int):
"""Create a new metadata entry for an engagement."""
authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES,
Expand All @@ -106,7 +107,7 @@ def post(engagement_id: int):
@API.doc(security='apikey')
@API.expect(metadata_bulk_update_model, validate=True)
@API.marshal_list_with(metadata_return_model)
@auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES)
@require_role(EDIT_ENGAGEMENT_ROLES)
def patch(engagement_id):
"""Update the values of existing metadata entries for an engagement."""
authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES,
Expand All @@ -130,7 +131,7 @@ class EngagementMetadataById(Resource):

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.has_one_of_roles(VIEW_ENGAGEMENT_ROLES)
@require_role(VIEW_ENGAGEMENT_ROLES)
def get(engagement_id, metadata_id):
"""Fetch an engagement metadata entry by id."""
authorization.check_auth(one_of_roles=VIEW_ENGAGEMENT_ROLES,
Expand All @@ -149,7 +150,7 @@ def get(engagement_id, metadata_id):

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES)
@require_role(EDIT_ENGAGEMENT_ROLES)
@API.expect(metadata_update_model)
def patch(engagement_id, metadata_id):
"""Update the values of an existing metadata entry for an engagement."""
Expand All @@ -174,7 +175,7 @@ def patch(engagement_id, metadata_id):

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES)
@require_role(EDIT_ENGAGEMENT_ROLES)
def delete(engagement_id, metadata_id):
"""Delete an existing metadata entry for an engagement."""
try:
Expand Down
19 changes: 9 additions & 10 deletions met-api/src/met_api/resources/staff_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from http import HTTPStatus

from flask import g, jsonify, request
from flask import current_app, g, jsonify, request
from flask_cors import cross_origin
from flask_restx import Namespace, Resource

Expand All @@ -27,18 +27,18 @@
from met_api.services.membership_service import MembershipService
from met_api.services.staff_user_membership_service import StaffUserMembershipService
from met_api.services.staff_user_service import StaffUserService
from met_api.services.user_group_membership_service import UserGroupMembershipService
from met_api.utils.roles import Role
from met_api.utils.tenant_validator import require_role
from met_api.utils.token_info import TokenInfo
from met_api.utils.util import allowedorigins, cors_preflight


API = Namespace('user', description='Endpoints for User Management')
"""Custom exception messages
"""


@cors_preflight('PUT')
@cors_preflight('GET, PUT')
@API.route('/')
class StaffUsers(Resource):
"""User controller class."""
Expand All @@ -51,17 +51,16 @@ def put():
try:
user_data = TokenInfo.get_user_data()
user = StaffUserService().create_or_update_user(user_data)
user.roles, _ = UserGroupMembershipService.get_user_roles_within_tenant(
user.external_id, g.tenant_id)
user.roles = current_app.config['JWT_ROLE_CALLBACK'](g.jwt_oidc_token_info)
return StaffUserSchema().dump(user), HTTPStatus.OK
except KeyError as err:
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
return str(err), HTTPStatus.BAD_REQUEST
except ValueError as err:
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR
return str(err), HTTPStatus.BAD_REQUEST

@staticmethod
@cross_origin(origins=allowedorigins())
@require_role([Role.VIEW_USERS.value], skip_tenant_check_for_admin=True)
@require_role([Role.VIEW_USERS.value])
def get():
"""Return a set of users(staff only)."""
args = request.args
Expand All @@ -87,7 +86,7 @@ class StaffUser(Resource):

@staticmethod
@cross_origin(origins=allowedorigins())
@require_role([Role.VIEW_USERS.value], skip_tenant_check_for_admin=True)
@require_role([Role.VIEW_USERS.value])
def get(user_id):
"""Fetch a user by id."""
args = request.args
Expand Down Expand Up @@ -130,7 +129,7 @@ class UserRoles(Resource):

@staticmethod
@cross_origin(origins=allowedorigins())
@require_role([Role.CREATE_ADMIN_USER.value], skip_tenant_check_for_admin=True)
@require_role([Role.CREATE_ADMIN_USER.value])
def post(user_id):
"""Add user to composite roles."""
try:
Expand Down
38 changes: 20 additions & 18 deletions met-api/src/met_api/services/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
from met_api.models.engagement import Engagement as EngagementModel
from met_api.models.membership import Membership as MembershipModel
from met_api.models.staff_user import StaffUser as StaffUserModel
from met_api.services.user_group_membership_service import UserGroupMembershipService
from met_api.utils.enums import MembershipStatus
from met_api.utils.roles import Role
from met_api.utils.user_context import UserContext, user_context


UNAUTHORIZED_MSG = 'You are not authorized to perform this action!'


Expand All @@ -29,32 +30,33 @@ def check_auth(**kwargs):
abort(HTTPStatus.FORBIDDEN, 'User not found')

# Retrieve tenant specific user roles from met-db
user_roles, tenant_id = UserGroupMembershipService.get_user_roles_within_tenant(user_from_context.sub,
g.tenant_id)
user_roles = current_app.config['JWT_ROLE_CALLBACK'](user_from_context.token_info)

if not user_roles:
abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG)

permitted_roles = set(kwargs.get('one_of_roles', []))
has_valid_roles = set(user_roles) & permitted_roles
if has_valid_roles:
if not skip_tenant_check:

user_tenant_id = tenant_id
_validate_tenant(kwargs.get('engagement_id'), user_tenant_id)
return
team_permitted_roles = {MembershipType.TEAM_MEMBER.name, MembershipType.REVIEWER.name} & permitted_roles
if Role.SUPER_ADMIN.value in user_roles:
return # Let Super Admins do anything they want :3

if team_permitted_roles:
# check if he is a member of particular engagement.

has_valid_team_access = _has_team_membership(kwargs, user_from_context, team_permitted_roles)
if has_valid_team_access:
required_roles = set(kwargs.get('one_of_roles', []))
has_valid_roles = set(user_roles) & required_roles
if has_valid_roles:
if skip_tenant_check:
return
if 'engagement_id' in kwargs:
_check_engagement_has_tenant(kwargs.get('engagement_id'), g.tenant_id)
return
membership_eligible_roles = {MembershipType.TEAM_MEMBER.name, MembershipType.REVIEWER.name
} & required_roles
# check if the user is a member of a passed engagement
if membership_eligible_roles and _has_team_membership(kwargs, user_from_context,
membership_eligible_roles):
return

abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG)


def _validate_tenant(eng_id, tenant_id):
def _check_engagement_has_tenant(eng_id, tenant_id):
"""Validate users tenant id with engagements tenant id."""
if not eng_id:
return
Expand Down
1 change: 1 addition & 0 deletions met-api/src/met_api/utils/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ class Role(Enum):
EXPORT_ALL_TO_CSV = 'export_all_to_csv'
EXPORT_INTERNAL_COMMENT_SHEET = 'export_internal_comment_sheet'
EXPORT_PROPONENT_COMMENT_SHEET = 'export_proponent_comment_sheet'
SUPER_ADMIN = 'super_admin'
Loading

0 comments on commit 645728a

Please sign in to comment.