diff --git a/api_app/_version.py b/api_app/_version.py index 76f24586d4..5963297e70 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.21.1" +__version__ = "0.22.0" diff --git a/api_app/api/routes/api.py b/api_app/api/routes/api.py index 6dabc88275..1be335e133 100644 --- a/api_app/api/routes/api.py +++ b/api_app/api/routes/api.py @@ -8,7 +8,7 @@ from api.helpers import get_repository from db.repositories.workspaces import WorkspaceRepository from api.routes import health, ping, workspaces, workspace_templates, workspace_service_templates, user_resource_templates, \ - shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests + shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests, workspace_users from core import config from resources import strings @@ -51,6 +51,10 @@ core_router.include_router(costs.costs_workspace_router, tags=["costs"]) core_router.include_router(requests.router, tags=["requests"]) +if config.USER_MANAGEMENT_ENABLED: + core_router.include_router(workspace_users.workspaces_users_admin_router, tags=["users"]) +core_router.include_router(workspace_users.workspaces_users_shared_router, tags=["users"]) + core_swagger_router = APIRouter() swagger_disabled_router = APIRouter() diff --git a/api_app/api/routes/workspace_users.py b/api_app/api/routes/workspace_users.py new file mode 100644 index 0000000000..b90a6b07ea --- /dev/null +++ b/api_app/api/routes/workspace_users.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, Response, status +from api.dependencies.workspaces import get_workspace_by_id_from_path +from models.schemas.workspace_users import UserRoleAssignmentRequest +from resources import strings +from services.authentication import get_access_service +from models.schemas.users import UsersInResponse, AssignableUsersInResponse, WorkspaceUserOperationResponse +from models.schemas.roles import RolesInResponse +from services.authentication import get_current_admin_user, get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin + +workspaces_users_admin_router = APIRouter(dependencies=[Depends(get_current_admin_user)]) +workspaces_users_shared_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin)]) + + +@workspaces_users_shared_router.get("/workspaces/{workspace_id}/users", response_model=UsersInResponse, name=strings.API_GET_WORKSPACE_USERS) +async def get_workspace_users(workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> UsersInResponse: + users = access_service.get_workspace_users(workspace) + return UsersInResponse(users=users) + + +@workspaces_users_admin_router.get("/workspaces/{workspace_id}/assignable-users", response_model=AssignableUsersInResponse, name=strings.API_GET_ASSIGNABLE_USERS) +async def get_assignable_users(filter: str = "", maxResultCount: int = 5, access_service=Depends(get_access_service)) -> AssignableUsersInResponse: + assignable_users = access_service.get_assignable_users(filter, maxResultCount) + return AssignableUsersInResponse(assignable_users=assignable_users) + + +@workspaces_users_admin_router.get("/workspaces/{workspace_id}/roles", response_model=RolesInResponse, name=strings.API_GET_WORKSPACE_ROLES) +async def get_workspace_roles(workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> RolesInResponse: + roles = access_service.get_workspace_roles(workspace) + return RolesInResponse(roles=roles) + + +@workspaces_users_admin_router.post("/workspaces/{workspace_id}/users/assign", status_code=status.HTTP_202_ACCEPTED, name=strings.API_ASSIGN_WORKSPACE_USER) +async def assign_workspace_user(response: Response, userRoleAssignmentRequest: UserRoleAssignmentRequest, workspace=Depends(get_workspace_by_id_from_path), access_service=Depends(get_access_service)) -> WorkspaceUserOperationResponse: + + for user_id in userRoleAssignmentRequest.user_ids: + access_service.assign_workspace_user( + user_id, + workspace, + userRoleAssignmentRequest.role_id + ) + + return WorkspaceUserOperationResponse(user_ids=userRoleAssignmentRequest.user_ids, role_id=userRoleAssignmentRequest.role_id) + + +@workspaces_users_admin_router.delete("/workspaces/{workspace_id}/users/assign", status_code=status.HTTP_202_ACCEPTED, name=strings.API_REMOVE_WORKSPACE_USER_ASSIGNMENT) +async def remove_workspace_user_assignment(user_id: str, + role_id: str, + workspace=Depends(get_workspace_by_id_from_path), + access_service=Depends(get_access_service)) -> WorkspaceUserOperationResponse: + + access_service.remove_workspace_role_user_assignment( + user_id, + role_id, + workspace + ) + + return WorkspaceUserOperationResponse(user_ids=[user_id], role_id=role_id) diff --git a/api_app/api/routes/workspaces.py b/api_app/api/routes/workspaces.py index e1d049c3f0..6eb39d42b9 100644 --- a/api_app/api/routes/workspaces.py +++ b/api_app/api/routes/workspaces.py @@ -21,7 +21,6 @@ from models.schemas.workspace_service import WorkspaceServiceInCreate, WorkspaceServicesInList, WorkspaceServiceInResponse from models.schemas.resource import ResourceHistoryInList, ResourcePatch from models.schemas.resource_template import ResourceTemplateInformationInList -from models.schemas.users import UsersInResponse from resources import strings from services.access_service import AuthConfigValidationError from services.authentication import get_current_admin_user, \ @@ -38,7 +37,6 @@ from models.domain.request_action import RequestAction from services.logging import logger - workspaces_core_router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)]) workspaces_shared_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin)]) workspace_services_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)]) @@ -188,13 +186,6 @@ async def invoke_action_on_workspace(response: Response, action: str, user=Depen return OperationInResponse(operation=operation) -@workspaces_shared_router.get("/workspaces/{workspace_id}/users", response_model=UsersInResponse, name=strings.API_GET_WORKSPACE_USERS) -async def get_workspace_users(workspace=Depends(get_workspace_by_id_from_path)) -> UsersInResponse: - access_service = get_access_service() - users = access_service.get_workspace_users(workspace) - return UsersInResponse(users=users) - - # workspace operations # This method only returns templates that the authenticated user is authorized to use @workspaces_shared_router.get("/workspaces/{workspace_id}/workspace-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_WORKSPACE_SERVICE_TEMPLATES_IN_WORKSPACE) diff --git a/api_app/core/config.py b/api_app/core/config.py index e34608f1c6..d2f1cf1fa4 100644 --- a/api_app/core/config.py +++ b/api_app/core/config.py @@ -36,7 +36,6 @@ SUBSCRIPTION_ID: str = config("SUBSCRIPTION_ID", default="") RESOURCE_GROUP_NAME: str = config("RESOURCE_GROUP_NAME", default="") - # Service bus configuration SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE: str = config("SERVICE_BUS_FULLY_QUALIFIED_NAMESPACE", default="") SERVICE_BUS_RESOURCE_REQUEST_QUEUE: str = config("SERVICE_BUS_RESOURCE_REQUEST_QUEUE", default="") @@ -72,3 +71,6 @@ ENABLE_AIRLOCK_EMAIL_CHECK: bool = config("ENABLE_AIRLOCK_EMAIL_CHECK", cast=bool, default=False) API_ROOT_SCOPE: str = f"api://{API_CLIENT_ID}/user_impersonation" + +# User Management +USER_MANAGEMENT_ENABLED: bool = config("USER_MANAGEMENT_ENABLED", cast=bool, default=False) diff --git a/api_app/models/domain/authentication.py b/api_app/models/domain/authentication.py index 3011440c7f..99513ef361 100644 --- a/api_app/models/domain/authentication.py +++ b/api_app/models/domain/authentication.py @@ -1,9 +1,7 @@ from collections import namedtuple from typing import List - from pydantic import BaseModel, Field - RoleAssignment = namedtuple("RoleAssignment", "resource_id, role_id") diff --git a/api_app/models/domain/workspace_users.py b/api_app/models/domain/workspace_users.py new file mode 100644 index 0000000000..73c08f6714 --- /dev/null +++ b/api_app/models/domain/workspace_users.py @@ -0,0 +1,32 @@ +from typing import List +from pydantic import BaseModel, Field +from enum import Enum + + +class AssignableUser(BaseModel): + id: str + displayName: str + userPrincipalName: str + + +class AssignmentType(Enum): + APP_ROLE = "ApplicationRole" + GROUP = "Group" + + +class Role(BaseModel): + id: str + displayName: str + + def __eq__(self, other): + return self.id == other.id + + def __hash__(self): + return hash(self.id) + + +class AssignedUser(BaseModel): + id: str + displayName: str + userPrincipalName: str + roles: List[Role] = Field([]) diff --git a/api_app/models/schemas/roles.py b/api_app/models/schemas/roles.py new file mode 100644 index 0000000000..03e4354b04 --- /dev/null +++ b/api_app/models/schemas/roles.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field +from typing import List +from models.domain.workspace_users import Role + + +class RolesInResponse(BaseModel): + roles: List[Role] = Field(..., title="Roles", description="List of roles in a workspace") diff --git a/api_app/models/schemas/users.py b/api_app/models/schemas/users.py index 56c8025e56..980684f814 100644 --- a/api_app/models/schemas/users.py +++ b/api_app/models/schemas/users.py @@ -1,11 +1,11 @@ from pydantic import BaseModel, Field from typing import List -from models.domain.authentication import User +from models.domain.workspace_users import AssignedUser, AssignableUser class UsersInResponse(BaseModel): - users: List[User] = Field(..., title="Users", description="List of users assigned to the workspace") + users: List[AssignedUser] = Field(..., title="Users", description="List of users assigned to the workspace") class Config: schema_extra = { @@ -26,3 +26,12 @@ class Config: ] } } + + +class AssignableUsersInResponse(BaseModel): + assignable_users: List[AssignableUser] = Field(..., title="Assignable Users", description="List of users assignable to a workspace") + + +class WorkspaceUserOperationResponse(BaseModel): + user_ids: List[str] = Field(..., title="User IDs", description="List of user IDs") + role_id: str = Field(..., title="Role ID", description="Role ID") diff --git a/api_app/models/schemas/workspace_users.py b/api_app/models/schemas/workspace_users.py new file mode 100644 index 0000000000..073df030f6 --- /dev/null +++ b/api_app/models/schemas/workspace_users.py @@ -0,0 +1,15 @@ +from typing import List +from pydantic import BaseModel, Field + + +class UserRoleAssignmentRequest(BaseModel): + role_id: str = Field(title="Role Id", description="Role to assign users to") + user_ids: List[str] = Field([], title="List of User Ids", description="List of User Ids to assign the role to") + + class Config: + schema_extra = { + "example": { + "role_id": "1234", + "user_ids": ["1", "2"] + } + } diff --git a/api_app/resources/strings.py b/api_app/resources/strings.py index 896b385f85..221c21a1cd 100644 --- a/api_app/resources/strings.py +++ b/api_app/resources/strings.py @@ -16,6 +16,10 @@ API_INVOKE_ACTION_ON_WORKSPACE = "Invoke action on a workspace" API_GET_WORKSPACE_USERS = "Get all users for a workspace" +API_GET_ASSIGNABLE_USERS = "Get all users assignable to a workspace" +API_GET_WORKSPACE_ROLES = "Get all the roles belonging to a workspace" +API_ASSIGN_WORKSPACE_USER = "Assign a user to a workspace role" +API_REMOVE_WORKSPACE_USER_ASSIGNMENT = "Remove a user from a workspace role" API_GET_ALL_WORKSPACE_SERVICES = "Get all workspace services for workspace" API_GET_WORKSPACE_SERVICE_BY_ID = "Get workspace service by Id" @@ -256,3 +260,6 @@ # Value that a sensitive is replaced with in Cosmos REDACTED_SENSITIVE_VALUE = "REDACTED" + +# User Management +USER_MANAGEMENT_DISABLED = "User management is disabled" diff --git a/api_app/services/aad_authentication.py b/api_app/services/aad_authentication.py index d99e194fdf..34658b5ae5 100644 --- a/api_app/services/aad_authentication.py +++ b/api_app/services/aad_authentication.py @@ -8,10 +8,11 @@ from fastapi import Request, HTTPException, status from msal import ConfidentialClientApplication -from services.access_service import AccessService, AuthConfigValidationError +from services.access_service import AccessService, AuthConfigValidationError, UserRoleAssignmentError from core import config from db.errors import EntityDoesNotExist from models.domain.authentication import User, RoleAssignment +from models.domain.workspace_users import AssignedUser, AssignmentType, AssignableUser, Role from models.domain.workspace import Workspace, WorkspaceRole from resources import strings from db.repositories.workspaces import WorkspaceRepository @@ -23,6 +24,8 @@ MICROSOFT_GRAPH_URL = config.MICROSOFT_GRAPH_URL.strip("/") +GRAPH_REQUEST_TIMEOUT = 10 +USER_MANAGEMENT_MINIMUM_BASE_TEMPLATE_VERSION = "2.1.0" class PrincipalType(Enum): @@ -37,7 +40,7 @@ class AzureADAuthorization(AccessService): require_one_of_roles = None aad_instance = config.AAD_AUTHORITY_URL - TRE_CORE_ROLES = ['TREAdmin', 'TREUser'] + TRE_CORE_ROLES = ['TREAdmin', 'TREUser', 'TREAirlockAutomation'] WORKSPACE_ROLES_DICT = {'WorkspaceOwner': 'app_role_id_workspace_owner', 'WorkspaceResearcher': 'app_role_id_workspace_researcher', 'AirlockManager': 'app_role_id_workspace_airlock_manager'} def __init__(self, auto_error: bool = True, require_one_of_roles: Optional[list] = None): @@ -227,21 +230,20 @@ def _get_batch_endpoint() -> str: @staticmethod def _get_users_endpoint(user_object_id) -> str: - return "/users/" + user_object_id + "?$select=displayName,mail,id" + return "/users/" + user_object_id + "?$select=displayName,mail,id,userPrincipalName" @staticmethod def _get_group_members_endpoint(group_object_id) -> str: - return "/groups/" + group_object_id + "/transitiveMembers?$select=displayName,mail,id" + return "/groups/" + group_object_id + "/transitiveMembers?$select=displayName,mail,id,userPrincipalName" def _get_app_sp_graph_data(self, client_id: str) -> dict: - msgraph_token = self._get_msgraph_token() sp_endpoint = self._get_service_principal_endpoint(client_id) - graph_data = requests.get(sp_endpoint, headers=self._get_auth_header(msgraph_token)).json() + graph_data = self._ms_graph_query(sp_endpoint, "GET") return graph_data - def _get_user_role_assignments(self, client_id, msgraph_token): + def _get_user_role_assignments(self, client_id): sp_roles_endpoint = self._get_service_principal_assigned_roles_endpoint(client_id) - return requests.get(sp_roles_endpoint, headers=self._get_auth_header(msgraph_token)).json() + return self._ms_graph_query(sp_roles_endpoint, "GET") def _get_user_details(self, roles_graph_data, msgraph_token): batch_endpoint = self._get_batch_endpoint() @@ -262,14 +264,14 @@ def _get_user_details(self, roles_graph_data, msgraph_token): return users_graph_data - def _get_roles_for_principal(self, user_id, roles_graph_data, app_id_to_role_name): + def _get_roles_for_principal(self, user_id, roles_graph_data, app_id_to_role_name) -> List[Role]: roles = [] for role_assignment in roles_graph_data["value"]: if role_assignment["principalId"] == user_id: - roles.append(app_id_to_role_name[role_assignment["appRoleId"]]) + roles.append(Role(id=role_assignment["appRoleId"], displayName=app_id_to_role_name[role_assignment["appRoleId"]])) return roles - def _get_users_inc_groups_from_response(self, users_graph_data, roles_graph_data, app_id_to_role_name) -> List[User]: + def _get_users_inc_groups_from_response(self, users_graph_data, roles_graph_data, app_id_to_role_name) -> List[AssignedUser]: users = [] for user_data in users_graph_data["responses"]: if "users" in user_data["body"]["@odata.context"]: @@ -278,10 +280,15 @@ def _get_users_inc_groups_from_response(self, users_graph_data, roles_graph_data user_name = user_data["body"]["displayName"] if "users" in user_data["body"]["@odata.context"]: - user_email = user_data["body"]["mail"] + user_principal_name = user_data["body"]["userPrincipalName"] # if user with id does not already exist in users + user_roles = self._get_roles_for_principal(user_id, roles_graph_data, app_id_to_role_name) + if not any(user.id == user_id for user in users): - users.append(User(id=user_id, name=user_name, email=user_email, roles=self._get_roles_for_principal(user_id, roles_graph_data, app_id_to_role_name))) + users.append(AssignedUser(id=user_id, displayName=user_name, userPrincipalName=user_principal_name, roles=user_roles)) + else: + user = next((user for user in users if user.id == user_id), None) + user.roles = list(set(user.roles + user_roles)) # Handle group endpoint response elif "directoryObjects" in user_data["body"]["@odata.context"]: @@ -289,33 +296,155 @@ def _get_users_inc_groups_from_response(self, users_graph_data, roles_graph_data for group_member in user_data["body"]["value"]: user_id = group_member["id"] user_name = group_member["displayName"] - user_email = group_member["mail"] + user_principal_name = group_member["userPrincipalName"] + + group_roles = self._get_roles_for_principal(group_id, roles_graph_data, app_id_to_role_name) if not any(user.id == user_id for user in users): - users.append(User(id=user_id, name=user_name, email=user_email, roles=self._get_roles_for_principal(group_id, roles_graph_data, app_id_to_role_name))) + users.append(AssignedUser(id=user_id, displayName=user_name, userPrincipalName=user_principal_name, roles=group_roles)) + else: + user = next((user for user in users if user.id == user_id), None) + user.roles = list(set(user.roles + group_roles)) return users - def get_workspace_users(self, workspace: Workspace) -> List[User]: + def get_workspace_users(self, workspace: Workspace) -> List[AssignedUser]: msgraph_token = self._get_msgraph_token() sp_graph_data = self._get_app_sp_graph_data(workspace.properties["client_id"]) - app_id_to_role_name = {app_role["id"]: app_role["value"] for app_role in sp_graph_data["value"][0]["appRoles"]} - roles_graph_data = self._get_user_role_assignments(workspace.properties["sp_id"], msgraph_token) + app_id_to_role_name = {app_role["id"]: (app_role["displayName"]) for app_role in sp_graph_data["value"][0]["appRoles"]} + roles_graph_data = self._get_user_role_assignments(workspace.properties["sp_id"]) users_graph_data = self._get_user_details(roles_graph_data, msgraph_token) users_inc_groups = self._get_users_inc_groups_from_response(users_graph_data, roles_graph_data, app_id_to_role_name) return users_inc_groups - def get_workspace_user_emails_by_role_assignment(self, workspace: Workspace): - users = self.get_workspace_users(workspace) - workspace_role_assignments_details = {} - for user in users: - if user.email: - for role in user.roles: - if role not in workspace_role_assignments_details: - workspace_role_assignments_details[role] = [] - workspace_role_assignments_details[role].append(user.email) - return workspace_role_assignments_details + def get_assignable_users(self, filter: str = "", maxResultCount: int = 5) -> List[AssignableUser]: + users_endpoint = f"{MICROSOFT_GRAPH_URL}/v1.0/users?$filter=startswith(displayName,'{filter}')&$top={maxResultCount}" + graph_data = self._ms_graph_query(users_endpoint, "GET") + result = [] + + for user_data in graph_data["value"]: + result.append( + AssignableUser(id=user_data["id"], displayName=user_data["displayName"], userPrincipalName=user_data["userPrincipalName"]) + ) + + return result + + def get_workspace_roles(self, workspace: Workspace) -> List[Role]: + app_roles_endpoint = f"{MICROSOFT_GRAPH_URL}/v1.0/servicePrincipals/{workspace.properties['sp_id']}/appRoles" + graph_data = self._ms_graph_query(app_roles_endpoint, "GET") + + roles = [] + + roleAssignmentType = AssignmentType.APP_ROLE + if self._is_workspace_role_group_in_use(workspace): + roleAssignmentType = AssignmentType.GROUP + + for role in graph_data["value"]: + roles.append(Role(id=role["id"], + displayName=role["displayName"], + type=roleAssignmentType)) + + return roles + + def assign_workspace_user(self, user_id: str, workspace: Workspace, role_id: str) -> None: + # User already has the role, do nothing + if self._is_user_in_role(user_id, role_id): + return + if compare_versions(workspace.templateVersion, USER_MANAGEMENT_MINIMUM_BASE_TEMPLATE_VERSION) < 0: + logger.error(f"Unable to assign user {user_id} to group with role {role_id}, Workspace needs to be version 2.1.0 or greater") + raise UserRoleAssignmentError(f"Unable to assign user {user_id} to group with role {role_id}, Workspace needs to be version 2.1.0 or greater") + if not self._is_workspace_role_group_in_use(workspace): + logger.error(f"Unable to assign user {user_id} to group with role {role_id}, Entra ID groups are not in use on this workspace") + raise UserRoleAssignmentError(f"Unable to assign user {user_id} to group with role {role_id}, Entra ID groups are not in use on this workspace") + return self._assign_workspace_user_to_application_group(user_id, workspace, role_id) + + def _is_user_in_role(self, user_id: str, role_id: str) -> bool: + user_app_role_query = f"{MICROSOFT_GRAPH_URL}/v1.0/users/{user_id}/appRoleAssignments" + user_app_roles = self._ms_graph_query(user_app_role_query, "GET") + return any(r for r in user_app_roles["value"] if r["appRoleId"] == role_id) + + def _is_workspace_role_group_in_use(self, workspace: Workspace) -> bool: + aad_groups_in_user = workspace.properties["create_aad_groups"] + return aad_groups_in_user + + def _get_workspace_group_name(self, workspace: Workspace, role_id: str) -> tuple: + tre_id = workspace.properties["tre_id"] + workspace_id = workspace.properties["workspace_id"] + group_name = "" + app_role_id_suffix = "" + if workspace.properties["app_role_id_workspace_researcher"] == role_id: + group_name = "Workspace Researchers" + app_role_id_suffix = "workspace_researcher" + elif workspace.properties["app_role_id_workspace_owner"] == role_id: + group_name = "Workspace Owners" + app_role_id_suffix = "workspace_owner" + elif workspace.properties["app_role_id_workspace_airlock_manager"] == role_id: + group_name = "Airlock Managers" + app_role_id_suffix = "workspace_airlock_manager" + else: + raise UserRoleAssignmentError(f"Unknown role: {role_id}") + + return (f"{tre_id}-ws-{workspace_id} {group_name}", f"app_role_id_{app_role_id_suffix}") + + def _assign_workspace_user_to_application_group(self, user_id: str, workspace: Workspace, role_id: str): + roles_graph_data = self._get_user_role_assignments(workspace.properties["sp_id"]) + group_details = self._get_workspace_group_name(workspace, role_id) + group_name = group_details[0] + workspace_app_role_field = group_details[1] + + for group in [item for item in roles_graph_data["value"] if item["principalType"] == PrincipalType.Group.value]: + if group.get("principalDisplayName") == group_name and group.get("appRoleId") == workspace.properties[workspace_app_role_field]: + self._add_user_to_group(user_id, group["principalId"]) + return + + raise UserRoleAssignmentError(f"Unable to assign user to group with role: {role_id}") + + def _remove_workspace_user_from_application_group(self, user_id: str, workspace: Workspace, role_id: str): + roles_graph_data = self._get_user_role_assignments(workspace.properties["sp_id"]) + group_details = self._get_workspace_group_name(workspace, role_id) + group_name = group_details[0] + workspace_app_role_field = group_details[1] + + for group in [item for item in roles_graph_data["value"] if item["principalType"] == PrincipalType.Group.value]: + if group.get("principalDisplayName") == group_name and group.get("appRoleId") == workspace.properties[workspace_app_role_field]: + self._remove_user_from_group(user_id, group["principalId"]) + return + raise UserRoleAssignmentError(f"Unable to assign user to group with role: {role_id}") + + def _add_user_to_group(self, user_id: str, group_id: str): + url = f"{MICROSOFT_GRAPH_URL}/v1.0/groups/{group_id}/members/$ref" + body = { + "@odata.id": f"{MICROSOFT_GRAPH_URL}/v1.0/users/{user_id}" + } + + response = self._ms_graph_query(url, "POST", json=body) + return response + + def _remove_user_from_group(self, user_id: str, group_id: str): + url = f"{MICROSOFT_GRAPH_URL}/v1.0/groups/{group_id}/members/{user_id}/$ref" + + response = self._ms_graph_query(url, "DELETE") + return response + + def _get_role_assignment_for_user(self, user_id: str, role_id: str) -> dict: + user_role_assignments = self._get_role_assignment_graph_data_for_user(user_id) + for role in user_role_assignments["value"]: + if role["appRoleId"] == role_id: + return role + + def remove_workspace_role_user_assignment(self, + user_id: str, + role_id: str, + workspace: Workspace + ) -> None: + if compare_versions(workspace.templateVersion, USER_MANAGEMENT_MINIMUM_BASE_TEMPLATE_VERSION) < 0: + logger.error(f"Unable to remove user {user_id} from group with role {role_id}, Workspace needs to be version 2.1.0 or greater") + raise UserRoleAssignmentError(f"Unable to remove user {user_id} from group with role {role_id}, Workspace needs to be version 2.1.0 or greater") + if not self._is_workspace_role_group_in_use(workspace): + logger.error(f"Unable to remove user {user_id} from group with role {role_id}, Entra ID groups are not in use on this workspace") + raise UserRoleAssignmentError(f"Unable to remove user {user_id} from group with role {role_id}, Entra ID groups are not in use on this workspace") + return self._remove_workspace_user_from_application_group(user_id, workspace, role_id) def _get_batch_users_by_role_assignments_body(self, roles_graph_data): request_body = {"requests": []} @@ -365,9 +494,9 @@ def _ms_graph_query(self, url: str, http_method: str, json=None) -> dict: break logger.debug(f"Making request to: {url}") if json: - response = requests.request(method=http_method, url=url, json=json, headers=auth_headers) + response = requests.request(method=http_method, url=url, json=json, headers=auth_headers, timeout=GRAPH_REQUEST_TIMEOUT) else: - response = requests.request(method=http_method, url=url, headers=auth_headers) + response = requests.request(method=http_method, url=url, headers=auth_headers, timeout=GRAPH_REQUEST_TIMEOUT) url = "" if response.status_code == 200: json_response = response.json() @@ -460,6 +589,31 @@ def get_workspace_role(self, user: User, workspace: Workspace, user_role_assignm return WorkspaceRole.NoRole +def compare_versions(v1: str, v2: str) -> int: + """ + Compare two version strings in the format major.minor.build. + + Returns: + -1 if v1 < v2, + 0 if v1 == v2, + 1 if v1 > v2. + """ + parts1 = [int(x) for x in v1.split('.')] + parts2 = [int(x) for x in v2.split('.')] + + # Extend the shorter list with zeros + length = max(len(parts1), len(parts2)) + parts1.extend([0] * (length - len(parts1))) + parts2.extend([0] * (length - len(parts2))) + + for a, b in zip(parts1, parts2): + if a < b: + return -1 + elif a > b: + return 1 + return 0 + + def merge_dict(d1, d2): dd = defaultdict(list) diff --git a/api_app/services/access_service.py b/api_app/services/access_service.py index 5c19126177..d38d26ce15 100644 --- a/api_app/services/access_service.py +++ b/api_app/services/access_service.py @@ -10,6 +10,10 @@ class AuthConfigValidationError(Exception): """Raised when the input auth information is invalid""" +class UserRoleAssignmentError(Exception): + """Raised when a user role assignment fails""" + + class AccessService(OAuth2AuthorizationCodeBearer): @abstractmethod def extract_workspace_auth_information(self, data: dict) -> dict: diff --git a/api_app/tests_ma/test_api/conftest.py b/api_app/tests_ma/test_api/conftest.py index e781cf854e..b386ce32bd 100644 --- a/api_app/tests_ma/test_api/conftest.py +++ b/api_app/tests_ma/test_api/conftest.py @@ -22,6 +22,12 @@ def no_auth_token(): yield +@pytest.fixture(autouse=True, scope="session") +def patch_user_management_enabled(): + with patch("core.config.USER_MANAGEMENT_ENABLED", new=True): + yield + + def create_test_user() -> User: return User( id="user-guid-here", diff --git a/api_app/tests_ma/test_api/test_routes/test_workspace_users.py b/api_app/tests_ma/test_api/test_routes/test_workspace_users.py new file mode 100644 index 0000000000..e4f1f050a0 --- /dev/null +++ b/api_app/tests_ma/test_api/test_routes/test_workspace_users.py @@ -0,0 +1,221 @@ +import pytest +from mock import patch + +from fastapi import status + +from models.domain.workspace_users import AssignmentType, Role +from tests_ma.test_api.test_routes.test_resource_helpers import FAKE_CREATE_TIMESTAMP +from tests_ma.test_api.conftest import create_admin_user +from services.authentication import get_current_admin_user, \ + get_current_tre_user_or_tre_admin, \ + get_current_workspace_owner_or_researcher_user_or_airlock_manager, \ + get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin + +from models.domain.workspace import Workspace +from resources import strings + +pytestmark = pytest.mark.asyncio + + +WORKSPACE_ID = '933ad738-7265-4b5f-9eae-a1a62928772e' +SERVICE_ID = 'abcad738-7265-4b5f-9eae-a1a62928772e' +USER_RESOURCE_ID = 'a33ad738-7265-4b5f-9eae-a1a62928772a' +CLIENT_ID = 'f0acf127-a672-a672-a672-a15e5bf9f127' +OPERATION_ID = '11111111-7265-4b5f-9eae-a1a62928772f' + + +def sample_workspace(workspace_id=WORKSPACE_ID, auth_info: dict = {}) -> Workspace: + workspace = Workspace( + id=workspace_id, + templateName="tre-workspace-base", + templateVersion="0.1.0", + etag="", + properties={ + "client_id": "12345", + "scope_id": "test_scope_id", + "sp_id": "test_sp_id" + }, + resourcePath=f'/workspaces/{workspace_id}', + updatedWhen=FAKE_CREATE_TIMESTAMP, + user=create_admin_user() + ) + if auth_info: + workspace.properties = {**auth_info} + return workspace + + +class TestWorkspaceUserRoutesWithTreAdmin: + @pytest.fixture(autouse=True, scope='class') + def _prepare(self, app, admin_user): + with patch('services.aad_authentication.AzureADAuthorization._get_user_from_token', return_value=admin_user()): + app.dependency_overrides[get_current_workspace_owner_or_researcher_user_or_airlock_manager_or_tre_admin] = admin_user + app.dependency_overrides[get_current_tre_user_or_tre_admin] = admin_user + app.dependency_overrides[get_current_workspace_owner_or_researcher_user_or_airlock_manager] = admin_user + app.dependency_overrides[get_current_admin_user] = admin_user + yield + app.dependency_overrides = {} + + @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + async def test_get_workspace_users_returns_users(self, _, auth_class, app, client): + with patch(f"services.{auth_class}.get_workspace_users") as get_workspace_users_mock: + + users = [ + { + "id": "123", + "displayName": "John Doe", + "userPrincipalName": "john.doe@example.com", + "roles": [ + { + "id": "1", + "displayName": "WorkspaceOwner" + }, + { + "id": "2", + "displayName": "WorkspaceResearcher", + }] + }, + { + "id": "456", + "displayName": "Jane Smith", + "userPrincipalName": "jane.smith@example.com", + "roles": [ + { + "id": "2", + "displayName": "WorkspaceResearcher" + }] + } + ] + get_workspace_users_mock.return_value = users + + response = await client.get(app.url_path_for(strings.API_GET_WORKSPACE_USERS, workspace_id=WORKSPACE_ID)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["users"] == users + + @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + async def test_assign_workspace_user_assigns_single_workspace_user(self, get_workspace_by_id_mock, auth_class, app, client): + with patch(f"services.{auth_class}.assign_workspace_user") as assign_workspace_user_mock: + + role_id = "test_role_id" + + response = await client.post(app.url_path_for(strings.API_ASSIGN_WORKSPACE_USER, workspace_id=WORKSPACE_ID), json={ + "role_id": role_id, + "user_ids": ["user_1"] + }) + assert response.status_code == status.HTTP_202_ACCEPTED + + assign_workspace_user_mock.assert_called_once() + + @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + async def test_assign_workspace_user_assigns_multiple_workspace_user(self, get_workspace_by_id_mock, auth_class, app, client): + with patch(f"services.{auth_class}.assign_workspace_user") as assign_workspace_user_mock: + + role_id = "test_role_id" + + response = await client.post(app.url_path_for(strings.API_ASSIGN_WORKSPACE_USER, workspace_id=WORKSPACE_ID), json={ + "role_id": role_id, + "user_ids": ["user_1", "user_2"] + }) + assert response.status_code == status.HTTP_202_ACCEPTED + + assert assign_workspace_user_mock.call_count == 2 + + @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + async def test_remove_workspace_user_assignment_removes_workspace_user_assignment(self, get_workspace_by_id_mock, auth_class, app, client): + with patch(f"services.{auth_class}.remove_workspace_role_user_assignment") as remove_workspace_role_user_assignment_mock: + + user = { + "id": "123", + "displayName": "John Doe", + "userPrincipalName": "john.doe@example.com", + "roles": [ + { + "id": "1", + "displayName": "WorkspaceOwner" + }, + { + "id": "2", + "displayName": "WorkspaceResearcher" + }] + } + + role_id = "test_role_id" + + response = await client.delete(app.url_path_for(strings.API_ASSIGN_WORKSPACE_USER, workspace_id=WORKSPACE_ID), params={"user_id": user["id"], "role_id": role_id, "assignmentType": "ApplicationRole"}) + assert response.status_code == status.HTTP_202_ACCEPTED + + remove_workspace_role_user_assignment_mock.assert_called_once() + + @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + async def test_get_assignable_users_returns_assignable_users(self, get_workspace_by_id_mock, auth_class, app, client): + with patch(f"services.{auth_class}.get_assignable_users") as get_assignable_users_mock: + assignable_users = [ + { + "id": "1", + "displayName": "John Doe", + "userPrincipalName": "john.doe@example.com", + "roles": [ + { + "id": "1", + "displayName": "WorkspaceOwner", + "type": "ApplicationRole" + }, + { + "id": "2", + "displayName": "WorkspaceResearcher", + "type": "ApplicationRole" + }] + }, + { + "id": "1", + "displayName": "Jane Smith", + "userPrincipalName": "jane.smith@example.com", + "roles": [ + { + "id": "2", + "displayName": "WorkspaceResearcher", + "type": "ApplicationRole" + }] + } + ] + + get_assignable_users_mock.return_value = assignable_users + + response = await client.get(app.url_path_for(strings.API_GET_ASSIGNABLE_USERS, workspace_id=WORKSPACE_ID)) + + get_assignable_users_mock.assert_called_once() + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + async def test_get_workspace_roles_returns_workspace_roles(self, get_workspace_by_id_mock, auth_class, app, client): + with patch(f"services.{auth_class}.get_workspace_roles") as get_workspace_roles_mock: + workspace_roles = [ + Role( + id="1", + displayName="AirlockManager", + type=AssignmentType.APP_ROLE + ), + Role( + id="2", + displayName="WorkspaceResearcher", + type=AssignmentType.APP_ROLE + ), + Role( + id="3", + displayName="WorkspaceOwner", + type=AssignmentType.APP_ROLE + ) + ] + + get_workspace_roles_mock.return_value = workspace_roles + + response = await client.get(app.url_path_for(strings.API_GET_WORKSPACE_ROLES, workspace_id=WORKSPACE_ID)) + + get_workspace_roles_mock.assert_called_once() + assert response.status_code == status.HTTP_200_OK diff --git a/api_app/tests_ma/test_api/test_routes/test_workspaces.py b/api_app/tests_ma/test_api/test_routes/test_workspaces.py index 58e069d852..f46d1c62d7 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspaces.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspaces.py @@ -88,7 +88,8 @@ def sample_workspace(workspace_id=WORKSPACE_ID, auth_info: dict = {}) -> Workspa etag="", properties={ "client_id": "12345", - "scope_id": "test_scope_id" + "scope_id": "test_scope_id", + "sp_id": "test_sp_id" }, resourcePath=f'/workspaces/{workspace_id}', updatedWhen=FAKE_CREATE_TIMESTAMP, @@ -1649,31 +1650,3 @@ async def test_delete_user_resource_returns_resource_id(self, __, ___, get_user_ assert response.status_code == status.HTTP_200_OK assert response.json()["operation"]["resourceId"] == user_resource.id - - @pytest.mark.parametrize("auth_class", ["aad_authentication.AzureADAuthorization"]) - @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) - async def test_get_workspace_users_returns_users(self, _, auth_class, app, client): - with patch(f"services.{auth_class}.get_workspace_users") as get_workspace_users_mock: - - users = [ - { - "id": "123", - "name": "John Doe", - "email": "john.doe@example.com", - "roles": ["WorkspaceOwner", "WorkspaceResearcher"], - 'roleAssignments': [] - }, - { - "id": "456", - "name": "Jane Smith", - "email": "jane.smith@example.com", - "roles": ["WorkspaceResearcher"], - 'roleAssignments': [] - } - ] - get_workspace_users_mock.return_value = users - - response = await client.get(app.url_path_for(strings.API_GET_WORKSPACE_USERS, workspace_id=WORKSPACE_ID)) - - assert response.status_code == status.HTTP_200_OK - assert response.json()["users"] == users diff --git a/api_app/tests_ma/test_services/test_aad_access_service.py b/api_app/tests_ma/test_services/test_aad_access_service.py index ef90d1f885..0dbc323809 100644 --- a/api_app/tests_ma/test_services/test_aad_access_service.py +++ b/api_app/tests_ma/test_services/test_aad_access_service.py @@ -2,9 +2,10 @@ from mock import call, patch from models.domain.authentication import User, RoleAssignment +from models.domain.workspace_users import AssignmentType, Role from models.domain.workspace import Workspace, WorkspaceRole -from services.aad_authentication import AzureADAuthorization -from services.access_service import AuthConfigValidationError +from services.aad_authentication import AzureADAuthorization, compare_versions +from services.access_service import AuthConfigValidationError, UserRoleAssignmentError MOCK_MICROSOFT_GRAPH_URL = "https://graph.microsoft.com" @@ -86,6 +87,63 @@ def get_app_sp_graph_data_mock(): } +@pytest.fixture +def workspace_with_groups(): + return Workspace( + id="ws1", + etag="", + templateName="test-template", + templateVersion="2.1.0", + resourcePath="", + properties={ + "create_aad_groups": True, + "tre_id": "TRE-001", + "workspace_id": "ws1", + "client_id": "app-client-id", + "sp_id": "sp123", + "app_role_id_workspace_owner": "owner-role-id", + "app_role_id_workspace_researcher": "researcher-role-id", + "app_role_id_workspace_airlock_manager": "airlock-role-id", + } + ) + + +@pytest.fixture +def workspace_without_groups(): + return Workspace( + id="ws2", + etag="", + templateName="test-template", + templateVersion="2.1.0", + resourcePath="", + properties={ + "create_aad_groups": False, + "tre_id": "TRE-002", + "workspace_id": "ws2", + "client_id": "app-client-id", + "sp_id": "sp456", + "app_role_id_workspace_owner": "owner-role-id", + "app_role_id_workspace_researcher": "researcher-role-id", + "app_role_id_workspace_airlock_manager": "airlock-role-id", + } + ) + + +@pytest.fixture +def role_owner(): + return Role(id="owner-role-id", displayName="WorkspaceOwner", type=AssignmentType.APP_ROLE) + + +@pytest.fixture +def user_without_role(): + return User(id="user1", name="Test User", email="test@example.com", roles=[]) + + +@pytest.fixture +def user_with_role(): + return User(id="user2", name="Test User 2", email="test2@example.com", roles=["WorkspaceOwner"]) + + def test_extract_workspace__raises_error_if_client_id_not_available(): access_service = AzureADAuthorization() with pytest.raises(AuthConfigValidationError): @@ -354,161 +412,6 @@ def test_raises_auth_config_error_if_auth_info_has_incorrect_roles(_): ) -@patch("services.aad_authentication.AzureADAuthorization._get_app_sp_graph_data") -@patch("services.aad_authentication.AzureADAuthorization._get_user_role_assignments") -@patch("services.aad_authentication.AzureADAuthorization._get_user_details") -@patch( - "services.aad_authentication.AzureADAuthorization._get_msgraph_token", - return_value="token", -) -def test_get_workspace_user_emails_by_role_assignment_with_single_user_returns_user_mail_and_role_assignment( - _, users, roles, app_sp_graph_data_mock, user_response, roles_response, get_app_sp_graph_data_mock -): - access_service = AzureADAuthorization() - - # Use fixtures - users.return_value = user_response - roles.return_value = roles_response - app_sp_graph_data_mock.return_value = get_app_sp_graph_data_mock - - # Act - role_assignment_details = access_service.get_workspace_user_emails_by_role_assignment( - Workspace( - id="id", - templateName="tre-workspace-base", - templateVersion="0.1.0", - etag="", - properties={ - "sp_id": "ab123", - "client_id": "ab124", - "app_role_id_workspace_owner": "1abc4", - "app_role_id_workspace_researcher": "ab125", - "app_role_id_workspace_airlock_manager": "ab130", - }, - ) - ) - - assert role_assignment_details["WorkspaceOwner"] == ["test_user1@email.com"] - - -@patch("services.aad_authentication.AzureADAuthorization._get_app_sp_graph_data") -@patch("services.aad_authentication.AzureADAuthorization._get_user_role_assignments") -@patch("services.aad_authentication.AzureADAuthorization._get_user_details") -@patch( - "services.aad_authentication.AzureADAuthorization._get_msgraph_token", - return_value="token", -) -def test_get_workspace_user_emails_by_role_assignment_with_single_user_with_no_mail_is_not_returned( - _, users, roles, app_sp_graph_data_mock, user_response, roles_response, get_app_sp_graph_data_mock -): - access_service = AzureADAuthorization() - - # Build user response - user_response_no_mail = user_response.copy() - user_response_no_mail["responses"][0]["body"]["mail"] = None - users.return_value = user_response_no_mail - - roles.return_value = roles_response - app_sp_graph_data_mock.return_value = get_app_sp_graph_data_mock - - # Act - role_assignment_details = access_service.get_workspace_user_emails_by_role_assignment( - Workspace( - id="id", - templateName="tre-workspace-base", - templateVersion="0.1.0", - etag="", - properties={ - "sp_id": "ab123", - "client_id": "ab124", - "app_role_id_workspace_owner": "1abc4", - "app_role_id_workspace_researcher": "ab125", - "app_role_id_workspace_airlock_manager": "ab130", - }, - ) - ) - - assert len(role_assignment_details) == 0 - - -@patch("services.aad_authentication.AzureADAuthorization._get_app_sp_graph_data") -@patch("services.aad_authentication.AzureADAuthorization._get_user_role_assignments") -@patch("services.aad_authentication.AzureADAuthorization._get_user_details") -@patch( - "services.aad_authentication.AzureADAuthorization._get_msgraph_token", - return_value="token", -) -def test_get_workspace_user_emails_by_role_assignment_with_only_groups_assigned_returns_group_members( - _, users_and_groups, roles, app_sp_graph_data_mock, group_response, roles_response, get_app_sp_graph_data_mock -): - access_service = AzureADAuthorization() - - users_and_groups.return_value = group_response - roles.return_value = roles_response - app_sp_graph_data_mock.return_value = get_app_sp_graph_data_mock - - # Act - role_assignment_details = access_service.get_workspace_user_emails_by_role_assignment( - Workspace( - id="id", - templateName="tre-workspace-base", - templateVersion="0.1.0", - etag="", - properties={ - "sp_id": "ab123", - "client_id": "ab124", - "app_role_id_workspace_owner": "1abc4", - "app_role_id_workspace_researcher": "ab125", - "app_role_id_workspace_airlock_manager": "ab130", - }, - ) - ) - - assert len(role_assignment_details) == 1 - assert "test_user3@email.com" in role_assignment_details["WorkspaceOwner"] - assert "test_user4@email.com" in role_assignment_details["WorkspaceOwner"] - - -@patch("services.aad_authentication.AzureADAuthorization._get_app_sp_graph_data") -@patch("services.aad_authentication.AzureADAuthorization._get_user_role_assignments") -@patch("services.aad_authentication.AzureADAuthorization._get_user_details") -@patch( - "services.aad_authentication.AzureADAuthorization._get_msgraph_token", - return_value="token", -) -def test_get_workspace_user_emails_by_role_assignment_with_groups_and_users_assigned_returned_as_expected( - _, users_and_groups, roles, app_sp_graph_data_mock, roles_response, get_app_sp_graph_data_mock, users_and_group_response -): - - access_service = AzureADAuthorization() - - roles.return_value = roles_response - app_sp_graph_data_mock.return_value = get_app_sp_graph_data_mock - users_and_groups.return_value = users_and_group_response - - # Act - role_assignment_details = access_service.get_workspace_user_emails_by_role_assignment( - Workspace( - id="id", - templateName="tre-workspace-base", - templateVersion="0.1.0", - etag="", - properties={ - "sp_id": "ab123", - "client_id": "ab123", - "app_role_id_workspace_owner": "ab124", - "app_role_id_workspace_researcher": "ab125", - "app_role_id_workspace_airlock_manager": "ab130", - }, - ) - ) - - assert len(role_assignment_details) == 1 - assert "test_user1@email.com" in role_assignment_details["WorkspaceOwner"] - assert "test_user3@email.com" in role_assignment_details["WorkspaceOwner"] - assert "test_user4@email.com" in role_assignment_details["WorkspaceOwner"] - - @patch("services.aad_authentication.AzureADAuthorization._get_auth_header") @patch("services.aad_authentication.AzureADAuthorization._get_batch_users_by_role_assignments_body") @patch("requests.post") @@ -569,6 +472,23 @@ def test_get_user_details_with_batch_of_more_than_20_requests(mock_graph_post, m mock_graph_post.assert_has_calls(calls, any_order=True) +@patch("services.aad_authentication.AzureADAuthorization._get_role_assignment_graph_data_for_user") +def test_get_role_assignment_for_user(mock_get_role_assignment_data_for_user): + mock_user_data = { + "value": [ + {"appRoleId": "123", "principalId": "123", "principalType": "User"}, + {"appRoleId": "456", "principalId": "456", "principalType": "User"}, + ] + } + + mock_get_role_assignment_data_for_user.return_value = mock_user_data + access_service = AzureADAuthorization() + role = access_service._get_role_assignment_for_user("abc", "123") + + mock_get_role_assignment_data_for_user.assert_called_once() + assert role == mock_user_data["value"][0] + + def get_mock_batch_response(user_principals, group_principals): response_body = {"responses": []} for user_principal in user_principals: @@ -587,7 +507,7 @@ def get_mock_user_response(principal_id, mail, name): "id": "1", "status": 200, "headers": headers, - "body": {"@odata.context": user_odata, "mail": mail, "id": principal_id, "displayName": name}, + "body": {"@odata.context": user_odata, "userPrincipalName": mail, "id": principal_id, "displayName": name}, } return user_response_body @@ -600,7 +520,7 @@ def get_mock_group_response(group): group_members_body.append( { "@odata.type": "#microsoft.graph.user", - "mail": member.mail, + "userPrincipalName": member.mail, "id": member.principal_id, "displayName": member.display_name, } @@ -627,3 +547,139 @@ def get_mock_role_response(principal_roles): } ) return response + + +@patch("services.aad_authentication.AzureADAuthorization._is_user_in_role", return_value=True) +@patch("services.aad_authentication.AzureADAuthorization._is_workspace_role_group_in_use") +@patch("services.aad_authentication.AzureADAuthorization._assign_workspace_user_to_application_group") +def test_assign_workspace_user_already_has_role(workspace_role_in_use_mock, + assign_user_to_group_mock, + workspace_without_groups, role_owner, + user_with_role): + access_service = AzureADAuthorization() + access_service.assign_workspace_user(user_with_role.id, workspace_without_groups, role_owner.id) + + assert workspace_role_in_use_mock.call_count == 0 + assert assign_user_to_group_mock.call_count == 0 + + +@patch("services.aad_authentication.AzureADAuthorization._is_user_in_role", return_value=False) +@patch("services.aad_authentication.AzureADAuthorization._is_workspace_role_group_in_use", return_value=False) +@patch("services.aad_authentication.AzureADAuthorization._assign_workspace_user_to_application_group") +def test_assign_workspace_user_if_no_groups_raises_error(assign_user_to_group_mock, + workspace_without_groups, role_owner, + user_with_role): + + access_service = AzureADAuthorization() + + with pytest.raises(UserRoleAssignmentError): + access_service.assign_workspace_user(user_with_role.id, workspace_without_groups, role_owner.id) + + +@patch("services.aad_authentication.AzureADAuthorization._is_user_in_role", return_value=False) +@patch("services.aad_authentication.AzureADAuthorization._is_workspace_role_group_in_use", return_value=True) +@patch("services.aad_authentication.AzureADAuthorization._assign_workspace_user_to_application_group") +def test_assign_workspace_user_if_groups(_, __, assign_user_to_group_mock, + workspace_without_groups, role_owner, + user_with_role): + + access_service = AzureADAuthorization() + + access_service.assign_workspace_user(user_with_role.id, workspace_without_groups, role_owner.id) + + assert assign_user_to_group_mock.call_count == 1 + + +@patch("services.aad_authentication.AzureADAuthorization._is_workspace_role_group_in_use", return_value=False) +@patch("services.aad_authentication.AzureADAuthorization._get_role_assignment_for_user") +def test_remove_workspace_user_if_no_groups_raises_error(_, get_role_assignment_mock, + workspace_without_groups, + role_owner, + user_with_role): + + access_service = AzureADAuthorization() + get_role_assignment_mock.return_value = [] + + with pytest.raises(UserRoleAssignmentError): + access_service.remove_workspace_role_user_assignment(user_with_role.id, role_owner.id, workspace_without_groups) + + +@patch("services.aad_authentication.AzureADAuthorization._remove_workspace_user_from_application_group") +@patch("services.aad_authentication.AzureADAuthorization._get_role_assignment_for_user") +@patch("services.aad_authentication.AzureADAuthorization._is_workspace_role_group_in_use", return_value=True) +def test_remove_workspace_user_if_groups(_, get_role_assignment_mock, + remove_user_to_group_mock, + workspace_without_groups, + role_owner, + user_with_role): + + access_service = AzureADAuthorization() + get_role_assignment_mock.return_value = [] + + access_service.remove_workspace_role_user_assignment(user_with_role.id, role_owner.id, workspace_without_groups) + + assert remove_user_to_group_mock.call_count == 1 + + +@patch("services.aad_authentication.AzureADAuthorization._ms_graph_query") +def test_get_assignable_users_returns_users(ms_graph_query_mock): + access_service = AzureADAuthorization() + + # Mock the response of the get request + request_get_mock_response = { + "value": [ + { + "id": "123", + "displayName": "User 1", + "userPrincipalName": "User1@test.com" + } + ] + } + ms_graph_query_mock.return_value = request_get_mock_response + users = access_service.get_assignable_users() + + assert len(users) == 1 + assert users[0].displayName == "User 1" + assert users[0].userPrincipalName == "User1@test.com" + + +@patch("services.aad_authentication.AzureADAuthorization._get_msgraph_token", return_value="token") +@patch("services.aad_authentication.AzureADAuthorization._ms_graph_query") +@patch("services.aad_authentication.AzureADAuthorization._get_auth_header") +def test_get_workspace_roles_returns_roles(_, ms_graph_query_mock, mock_headers, workspace_without_groups): + access_service = AzureADAuthorization() + + # mock the response of _get_auth_header + headers = {"Authorization": "Bearer token"} + mock_headers.return_value = headers + headers["Content-type"] = "application/json" + + # Mock the response of the get request + request_get_mock_response = { + "value": [ + Role(id=1, displayName="Airlock Manager", type=AssignmentType.APP_ROLE).dict(), + Role(id=2, displayName="Workspace Researcher", type=AssignmentType.APP_ROLE).dict(), + Role(id=3, displayName="Workspace Owner", type=AssignmentType.APP_ROLE).dict(), + ] + } + ms_graph_query_mock.return_value = request_get_mock_response + roles = access_service.get_workspace_roles(workspace_without_groups) + + assert len(roles) == 3 + assert roles[0].id == "1" + assert roles[0].displayName == "Airlock Manager" + + +def test_compare_versions_equal(): + result = compare_versions("1.0.0", "1.0.0") + assert result == 0 + + +def test_compare_versions_greater_than(): + result = compare_versions("1.1.0", "1.0.0") + assert result > 0 + + +def test_compare_versions_less_than(): + result = compare_versions("1.0.0", "1.1.0") + assert result < 0 diff --git a/config.sample.yaml b/config.sample.yaml index 009f017920..956cac48dc 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -50,6 +50,9 @@ tre: firewall_sku: Standard app_gateway_sku: Standard_v2 + # Set to true if TreAdmins should be able to assign and de-assign users to workspaces via the UI + user_management_enabled: true + # Uncomment to deploy to a custom domain # custom_domain: __CHANGE_ME__ authentication: diff --git a/core/terraform/api-webapp.tf b/core/terraform/api-webapp.tf index b35cc0ba7c..0a6ad6b87d 100644 --- a/core/terraform/api-webapp.tf +++ b/core/terraform/api-webapp.tf @@ -65,6 +65,7 @@ resource "azurerm_linux_web_app" "api" { LOGGING_LEVEL = var.logging_level OTEL_RESOURCE_ATTRIBUTES = "service.name=api,service.version=${local.version}" OTEL_EXPERIMENTAL_RESOURCE_DETECTORS = "azure_app_service" + USER_MANAGEMENT_ENABLED = "True" } identity { diff --git a/core/terraform/main.tf b/core/terraform/main.tf index 8354cc481d..b47fb4d68d 100644 --- a/core/terraform/main.tf +++ b/core/terraform/main.tf @@ -162,6 +162,7 @@ module "resource_processor_vmss_porter" { tre_id = var.tre_id location = var.location resource_group_name = azurerm_resource_group.core.name + core_api_client_id = var.api_client_id acr_id = data.azurerm_container_registry.mgmt_acr.id app_insights_connection_string = module.azure_monitor.app_insights_connection_string resource_processor_subnet_id = module.network.resource_processor_subnet_id diff --git a/core/terraform/resource_processor/vmss_porter/cloud-config.yaml b/core/terraform/resource_processor/vmss_porter/cloud-config.yaml index a405dcc3ff..2d93e0ca5a 100644 --- a/core/terraform/resource_processor/vmss_porter/cloud-config.yaml +++ b/core/terraform/resource_processor/vmss_porter/cloud-config.yaml @@ -48,6 +48,7 @@ write_files: AZURE_SUBSCRIPTION_ID=${arm_subscription_id} ARM_CLIENT_ID=${vmss_msi_id} AZURE_TENANT_ID=${arm_tenant_id} + CORE_API_CLIENT_ID=${core_api_client_id} ARM_USE_MSI=true APPLICATIONINSIGHTS_CONNECTION_STRING=${app_insights_connection_string} NUMBER_PROCESSES=${resource_processor_number_processes_per_instance} @@ -76,7 +77,7 @@ write_files: echo "Free space too low, pruning..." docker system prune -f fi - permissions: '0755' + permissions: "0755" runcmd: # Those are useful live debug commands. Check the docs for details: diff --git a/core/terraform/resource_processor/vmss_porter/data.tf b/core/terraform/resource_processor/vmss_porter/data.tf index 53d58f452d..e668864cd1 100644 --- a/core/terraform/resource_processor/vmss_porter/data.tf +++ b/core/terraform/resource_processor/vmss_porter/data.tf @@ -19,6 +19,7 @@ data "template_file" "cloudconfig" { vmss_msi_id = azurerm_user_assigned_identity.vmss_msi.client_id arm_subscription_id = data.azurerm_subscription.current.subscription_id arm_tenant_id = data.azurerm_client_config.current.tenant_id + core_api_client_id = var.core_api_client_id resource_processor_vmss_porter_image_repository = var.resource_processor_vmss_porter_image_repository resource_processor_vmss_porter_image_tag = local.version app_insights_connection_string = var.app_insights_connection_string diff --git a/core/terraform/resource_processor/vmss_porter/variables.tf b/core/terraform/resource_processor/vmss_porter/variables.tf index e26b49d60d..6309395fae 100644 --- a/core/terraform/resource_processor/vmss_porter/variables.tf +++ b/core/terraform/resource_processor/vmss_porter/variables.tf @@ -10,6 +10,9 @@ variable "acr_id" { variable "resource_group_name" { type = string } +variable "core_api_client_id" { + type = string +} variable "resource_processor_subnet_id" { type = string } diff --git a/core/version.txt b/core/version.txt index 8e377d6b3f..8e2394f4ef 100644 --- a/core/version.txt +++ b/core/version.txt @@ -1 +1 @@ -__version__ = "0.12.5" +__version__ = "0.12.6" diff --git a/devops/scripts/build_deploy_ui.sh b/devops/scripts/build_deploy_ui.sh index 9154f97150..fc8d61bd06 100755 --- a/devops/scripts/build_deploy_ui.sh +++ b/devops/scripts/build_deploy_ui.sh @@ -20,7 +20,8 @@ jq --arg rootClientId "${SWAGGER_UI_CLIENT_ID}" \ --arg treId "${TRE_ID}" \ --arg version "${ui_version}" \ --arg activeDirectoryUri "${activeDirectoryUri}" \ - '.rootClientId = $rootClientId | .rootTenantId = $rootTenantId | .treApplicationId = $treApplicationId | .treUrl = $treUrl | .treId = $treId | .version = $version | .activeDirectoryUri = $activeDirectoryUri' ./src/config.source.json > ./src/config.json + --arg userManagementEnabled "${USER_MANAGEMENT_ENABLED}" \ + '.rootClientId = $rootClientId | .rootTenantId = $rootTenantId | .treApplicationId = $treApplicationId | .treUrl = $treUrl | .treId = $treId | .version = $version | .activeDirectoryUri = $activeDirectoryUri | .userManagementEnabled = $userManagementEnabled' ./src/config.source.json > ./src/config.json # build and deploy the app yarn install diff --git a/docs/tre-admins/environment-variables.md b/docs/tre-admins/environment-variables.md index 04395b9ec9..3fb6708471 100644 --- a/docs/tre-admins/environment-variables.md +++ b/docs/tre-admins/environment-variables.md @@ -45,6 +45,8 @@ | `APP_GATEWAY_SKU` | Optional. The SKU of the Application Gateway. Default value is `Standard_v2`. Allowed values [`Standard_v2`, `WAF_v2`] | | `CUSTOM_DOMAIN` | Optional. Custom domain name to access the Azure TRE portal. See [Custom domain name](custom-domain.md). | | `ENABLE_CMK_ENCRYPTION` | If set to `true`, customer-managed key encryption will be enabled for all supported resources. | +| `USER_MANAGEMENT_ENABLED` | If set to `true`, TRE Admins will be able to assign and de-assign users to workspaces via the UI (Requires Entra ID groups to be enabled on the workspace and the workspace template version to be 2.1.0 or greater). | + ## For authentication in `/config.yaml` | Variable | Description | diff --git a/resource_processor/_version.py b/resource_processor/_version.py index 76da4a9882..8e1395bd35 100644 --- a/resource_processor/_version.py +++ b/resource_processor/_version.py @@ -1 +1 @@ -__version__ = "0.12.2" +__version__ = "0.12.3" diff --git a/resource_processor/shared/config.py b/resource_processor/shared/config.py index ddc7b94d92..29c628f838 100644 --- a/resource_processor/shared/config.py +++ b/resource_processor/shared/config.py @@ -27,6 +27,7 @@ def get_config() -> dict: config["firewall_sku"] = os.environ.get("FIREWALL_SKU", "") config["enable_cmk_encryption"] = os.environ.get("ENABLE_CMK_ENCRYPTION", "false") config["key_store_id"] = os.environ.get("KEY_STORE_ID", None) + config["core_api_client_id"] = os.environ.get("CORE_API_CLIENT_ID", None) try: config["number_processes_int"] = int(config["number_processes"]) diff --git a/templates/workspaces/base/porter.yaml b/templates/workspaces/base/porter.yaml index 55976e1b09..083064511c 100644 --- a/templates/workspaces/base/porter.yaml +++ b/templates/workspaces/base/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-base -version: 2.0.0 +version: 2.1.0 description: "A base Azure TRE workspace" dockerfile: Dockerfile.tmpl registry: azuretre @@ -66,8 +66,11 @@ parameters: description: "Whether this bundle should register the workspace in AAD" - name: create_aad_groups type: boolean - default: false - description: "Whether this bundle should create AAD groups for the workspace app roles" + default: true + description: "Whether this bundle should create AAD groups for the workspace app roles (required for User Management)" + - name: core_api_client_id + type: string + description: "The client id of the core API" - name: workspace_owner_object_id type: string description: "The object id of the user that will be granted WorkspaceOwner after it is created." @@ -158,6 +161,21 @@ outputs: applyTo: - install - upgrade + - name: workspace_owners_group_id + type: string + applyTo: + - install + - upgrade + - name: workspace_researchers_group_id + type: string + applyTo: + - install + - upgrade + - name: workspace_airlock_managers_group_id + type: string + applyTo: + - install + - upgrade mixins: - exec @@ -178,6 +196,7 @@ install: enable_local_debugging: ${ bundle.parameters.enable_local_debugging } register_aad_application: ${ bundle.parameters.register_aad_application } create_aad_groups: ${ bundle.parameters.create_aad_groups } + core_api_client_id: ${ bundle.parameters.core_api_client_id } auth_client_id: ${ bundle.credentials.auth_client_id } auth_client_secret: ${ bundle.credentials.auth_client_secret } auth_tenant_id: ${ bundle.credentials.auth_tenant_id } @@ -210,6 +229,9 @@ install: - name: client_id - name: scope_id - name: sp_id + - name: workspace_owners_group_id + - name: workspace_researchers_group_id + - name: workspace_airlock_managers_group_id upgrade: - terraform: @@ -223,6 +245,7 @@ upgrade: enable_local_debugging: ${ bundle.parameters.enable_local_debugging } register_aad_application: ${ bundle.parameters.register_aad_application } create_aad_groups: ${ bundle.parameters.create_aad_groups } + core_api_client_id: ${ bundle.parameters.core_api_client_id } auth_client_id: ${ bundle.credentials.auth_client_id } auth_client_secret: ${ bundle.credentials.auth_client_secret } auth_tenant_id: ${ bundle.credentials.auth_tenant_id } @@ -255,6 +278,9 @@ upgrade: - name: client_id - name: scope_id - name: sp_id + - name: workspace_owners_group_id + - name: workspace_researchers_group_id + - name: workspace_airlock_managers_group_id - az: description: "Set Azure Cloud Environment" arguments: @@ -268,17 +294,17 @@ upgrade: - login flags: service-principal: "" - username: '${ bundle.credentials.auth_client_id }' - password: '${ bundle.credentials.auth_client_secret }' - tenant: '${ bundle.credentials.auth_tenant_id }' + username: "${ bundle.credentials.auth_client_id }" + password: "${ bundle.credentials.auth_client_secret }" + tenant: "${ bundle.credentials.auth_tenant_id }" allow-no-subscriptions: "" - exec: description: "Update workspace app redirect urls" command: ./update_redirect_urls.sh flags: - workspace-api-client-id: '${ bundle.parameters.client_id }' - aad-redirect-uris-b64: '${ bundle.parameters.aad_redirect_uris }' - register-aad-application: '${ bundle.parameters.register_aad_application }' + workspace-api-client-id: "${ bundle.parameters.client_id }" + aad-redirect-uris-b64: "${ bundle.parameters.aad_redirect_uris }" + register-aad-application: "${ bundle.parameters.register_aad_application }" uninstall: - terraform: @@ -292,6 +318,7 @@ uninstall: enable_local_debugging: ${ bundle.parameters.enable_local_debugging } register_aad_application: ${ bundle.parameters.register_aad_application } create_aad_groups: ${ bundle.parameters.create_aad_groups } + core_api_client_id: ${ bundle.parameters.core_api_client_id } auth_client_id: ${ bundle.credentials.auth_client_id } auth_client_secret: ${ bundle.credentials.auth_client_secret } auth_tenant_id: ${ bundle.credentials.auth_tenant_id } diff --git a/templates/workspaces/base/template_schema.json b/templates/workspaces/base/template_schema.json index 24cec47f34..b47339b656 100644 --- a/templates/workspaces/base/template_schema.json +++ b/templates/workspaces/base/template_schema.json @@ -239,9 +239,9 @@ "properties": { "create_aad_groups": { "type": "boolean", - "title": "Create AAD Groups for each workspace role", + "title": "Create AAD Groups for each workspace role (Required for user management)", "description": "Create AAD Groups for the workspace roles. If this is set to true, the workspace will create new AAD Groups.", - "default": false, + "default": true, "updateable": true }, "aad_redirect_uris": { @@ -310,4 +310,4 @@ "*" ] } -} +} \ No newline at end of file diff --git a/templates/workspaces/base/terraform/.terraform.lock.hcl b/templates/workspaces/base/terraform/.terraform.lock.hcl index 8a229681d9..c13066d033 100644 --- a/templates/workspaces/base/terraform/.terraform.lock.hcl +++ b/templates/workspaces/base/terraform/.terraform.lock.hcl @@ -6,7 +6,6 @@ provider "registry.terraform.io/azure/azapi" { constraints = ">= 1.15.0, 1.15.0" hashes = [ "h1:Y7ruMuPh8UJRTRl4rm+cdpGtmURx2taqiuqfYaH3o48=", - "h1:gIOgxVmFSxHrR+XOzgUEA+ybOmp8kxZlZH3eYeB/eFI=", "zh:0627a8bc77254debc25dc0c7b62e055138217c97b03221e593c3c56dc7550671", "zh:2fe045f07070ef75d0bec4b0595a74c14394daa838ddb964e2fd23cc98c40c34", "zh:343009f39c957883b2c06145a5954e524c70f93585f943f1ea3d28ef6995d0d0", @@ -23,22 +22,22 @@ provider "registry.terraform.io/azure/azapi" { } provider "registry.terraform.io/hashicorp/azuread" { - version = "2.20.0" - constraints = ">= 2.20.0, 2.20.0" + version = "3.1.0" + constraints = ">= 3.1.0" hashes = [ - "h1:qKo6WfRyml6w4qcnqDoeTmlWCL/kzng4qOB/5/XAW9g=", - "zh:0262b33661825b54edc0c539415ebdc942ecb3e2cf90af75f7ef134a1f901816", - "zh:0b569b6427e0a1f6c38ad19dd50f036bf65d5b64751e8a083fb36df76337faba", + "h1:UmSL7MD8ULg/WlRgwisD5lHsjcg9l8AO7AeO0XN96dU=", + "zh:01b796cf12e93cc811cb15c8465605e75de170802060f9e2fe114835968960dd", + "zh:12005fbffb84467ff1d4ce9317370834d1279743bc201d3db95f36315cdf8157", "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7", - "zh:4f3d017077eb9264ad4047ea0eda87ae7bc76da119f98361d10df27654b5b01c", - "zh:5566a523690f75f5fd4577f24a3194c719ebd22c011bf8619b86594a352afc71", - "zh:6101be64bf464d763585d144ee2cafae4aad74eb2f7f5264340addc9a9f227f7", - "zh:632627f20e48ce7e47f3be86a4d5869eb8412bf8083b5770decbb1e3cc335a1c", - "zh:63e7fbf0a34d7be50a4b83853600be6116a7c1600484d2e7ff2f15cc98abcf6f", - "zh:7909a7a074440e50be426f57e616e920745f8c38288537220f37c2d1ec719452", - "zh:e4f20c9887062a9ae1edcd208112d4d90c12afb7577f943220b54b83de8f10b7", - "zh:eb76ecf86977cd310f3311bc8f0015763c0a91594172a6b2d4ddb3d981d9c28e", - "zh:ffe05338f3e98fcbc5ffcf8b19dab8463849558d2ee6284afc91cdf9636c3330", + "zh:1daf7d4ade44e69593488c1f6571b4fbdaf01ec41538207de1f12609b3830907", + "zh:386965c0529ed083b94968c25441385378d8643a5748591b221e6d6d3cea4dbc", + "zh:46ede0628c300c6d584135daa93733400b9ce968d8aebb3f925d904b3fcfa781", + "zh:7af453bf5217e1818ca5c2126edb8fe573c85f17a0557415a3bc7ae92a8652f5", + "zh:b6014600409715ca37aa85ddb066698f592b7d104f09c12a68d45c5b00404272", + "zh:bca84d10cd1e805e6d31a888eb6737a96aee14e1b5b919dee73d2a5a8ff85beb", + "zh:bd7d6e6c2a086bafdeeb33d5d4f919a8789ef3acf1a0baf2b8ea43996b96c213", + "zh:e5b7840b1b9d90c3f6be9a59400b7d0580376415a79aa740eba7f97bf35c25ef", + "zh:e94e114b205de36d60bc17a3758f9c4bfc6b01e63be81ae1d9699f9bf9650362", ] } diff --git a/templates/workspaces/base/terraform/aad/aad.tf b/templates/workspaces/base/terraform/aad/aad.tf index 031f32b5a0..ea9d3dedfb 100644 --- a/templates/workspaces/base/terraform/aad/aad.tf +++ b/templates/workspaces/base/terraform/aad/aad.tf @@ -8,7 +8,7 @@ resource "random_uuid" "app_role_workspace_airlock_manager_id" {} resource "azuread_application" "workspace" { display_name = var.workspace_resource_name_suffix identifier_uris = ["api://${var.workspace_resource_name_suffix}"] - owners = [data.azuread_client_config.current.object_id] + owners = [data.azuread_client_config.current.object_id, data.azuread_service_principal.core_api.object_id] api { mapped_claims_enabled = true @@ -92,9 +92,9 @@ resource "azuread_application" "workspace" { } resource "azuread_service_principal" "workspace" { - application_id = azuread_application.workspace.application_id + client_id = azuread_application.workspace.client_id app_role_assignment_required = false - owners = [data.azuread_client_config.current.object_id, var.workspace_owner_object_id] + owners = [data.azuread_client_config.current.object_id, var.workspace_owner_object_id, data.azuread_service_principal.core_api.object_id] feature_tags { enterprise = true @@ -102,12 +102,12 @@ resource "azuread_service_principal" "workspace" { } resource "azuread_service_principal_password" "workspace" { - service_principal_id = azuread_service_principal.workspace.object_id + service_principal_id = azuread_service_principal.workspace.id } resource "azurerm_key_vault_secret" "client_id" { name = "workspace-client-id" - value = azuread_application.workspace.application_id + value = azuread_application.workspace.client_id key_vault_id = var.key_vault_id tags = var.tre_workspace_tags @@ -132,47 +132,47 @@ resource "azuread_app_role_assignment" "workspace_owner" { resource "azuread_group" "workspace_owners" { count = var.create_aad_groups ? 1 : 0 display_name = "${var.workspace_resource_name_suffix} Workspace Owners" - owners = [var.workspace_owner_object_id] + owners = [var.workspace_owner_object_id, data.azuread_service_principal.core_api.object_id] security_enabled = true } resource "azuread_group" "workspace_researchers" { count = var.create_aad_groups ? 1 : 0 display_name = "${var.workspace_resource_name_suffix} Workspace Researchers" - owners = [var.workspace_owner_object_id] + owners = [var.workspace_owner_object_id, data.azuread_service_principal.core_api.object_id] security_enabled = true } resource "azuread_group" "workspace_airlock_managers" { count = var.create_aad_groups ? 1 : 0 display_name = "${var.workspace_resource_name_suffix} Airlock Managers" - owners = [var.workspace_owner_object_id] + owners = [var.workspace_owner_object_id, data.azuread_service_principal.core_api.object_id] security_enabled = true } resource "azuread_group_member" "workspace_owner" { count = var.create_aad_groups ? 1 : 0 - group_object_id = azuread_group.workspace_owners[count.index].id + group_object_id = azuread_group.workspace_owners[count.index].object_id member_object_id = var.workspace_owner_object_id } resource "azuread_app_role_assignment" "workspace_owners_group" { count = var.create_aad_groups ? 1 : 0 app_role_id = azuread_service_principal.workspace.app_role_ids["WorkspaceOwner"] - principal_object_id = azuread_group.workspace_owners[count.index].id + principal_object_id = azuread_group.workspace_owners[count.index].object_id resource_object_id = azuread_service_principal.workspace.object_id } resource "azuread_app_role_assignment" "workspace_researchers_group" { count = var.create_aad_groups ? 1 : 0 app_role_id = azuread_service_principal.workspace.app_role_ids["WorkspaceResearcher"] - principal_object_id = azuread_group.workspace_researchers[count.index].id + principal_object_id = azuread_group.workspace_researchers[count.index].object_id resource_object_id = azuread_service_principal.workspace.object_id } resource "azuread_app_role_assignment" "workspace_airlock_managers_group" { count = var.create_aad_groups ? 1 : 0 app_role_id = azuread_service_principal.workspace.app_role_ids["AirlockManager"] - principal_object_id = azuread_group.workspace_airlock_managers[count.index].id + principal_object_id = azuread_group.workspace_airlock_managers[count.index].object_id resource_object_id = azuread_service_principal.workspace.object_id } diff --git a/templates/workspaces/base/terraform/aad/data.tf b/templates/workspaces/base/terraform/aad/data.tf new file mode 100644 index 0000000000..3366a7cca5 --- /dev/null +++ b/templates/workspaces/base/terraform/aad/data.tf @@ -0,0 +1,3 @@ +data "azuread_service_principal" "core_api" { + client_id = var.core_api_client_id +} diff --git a/templates/workspaces/base/terraform/aad/outputs.tf b/templates/workspaces/base/terraform/aad/outputs.tf index d233607652..4d00df5349 100644 --- a/templates/workspaces/base/terraform/aad/outputs.tf +++ b/templates/workspaces/base/terraform/aad/outputs.tf @@ -11,7 +11,7 @@ output "app_role_workspace_airlock_manager_id" { } output "client_id" { - value = azuread_application.workspace.application_id + value = azuread_application.workspace.client_id } output "scope_id" { @@ -21,3 +21,17 @@ output "scope_id" { output "sp_id" { value = azuread_service_principal.workspace.object_id } + +output "workspace_owners_group_id" { + value = var.create_aad_groups ? azuread_group.workspace_owners[0].object_id : "" +} + +output "workspace_researchers_group_id" { + value = var.create_aad_groups ? azuread_group.workspace_researchers[0].object_id : "" +} + +output "workspace_airlock_managers_group_id" { + value = var.create_aad_groups ? azuread_group.workspace_airlock_managers[0].object_id : "" +} + + diff --git a/templates/workspaces/base/terraform/aad/providers.tf b/templates/workspaces/base/terraform/aad/providers.tf index 4cf4c2b88a..16c094c413 100644 --- a/templates/workspaces/base/terraform/aad/providers.tf +++ b/templates/workspaces/base/terraform/aad/providers.tf @@ -7,7 +7,7 @@ terraform { } azuread = { source = "hashicorp/azuread" - version = ">= 2.20" + version = ">= 3.1.0" } random = { source = "hashicorp/random" diff --git a/templates/workspaces/base/terraform/aad/variables.tf b/templates/workspaces/base/terraform/aad/variables.tf index 7858625107..c887eac197 100644 --- a/templates/workspaces/base/terraform/aad/variables.tf +++ b/templates/workspaces/base/terraform/aad/variables.tf @@ -16,3 +16,6 @@ variable "aad_redirect_uris_b64" { variable "create_aad_groups" { type = string } +variable "core_api_client_id" { + type = string +} diff --git a/templates/workspaces/base/terraform/outputs.tf b/templates/workspaces/base/terraform/outputs.tf index 40fa8dcd69..933c033be8 100644 --- a/templates/workspaces/base/terraform/outputs.tf +++ b/templates/workspaces/base/terraform/outputs.tf @@ -29,3 +29,14 @@ output "scope_id" { value = var.register_aad_application ? module.aad[0].scope_id : var.scope_id } +output "workspace_owners_group_id" { + value = var.register_aad_application ? module.aad[0].workspace_owners_group_id : "" +} + +output "workspace_researchers_group_id" { + value = var.register_aad_application ? module.aad[0].workspace_researchers_group_id : "" +} + +output "workspace_airlock_managers_group_id" { + value = var.register_aad_application ? module.aad[0].workspace_airlock_managers_group_id : "" +} diff --git a/templates/workspaces/base/terraform/providers.tf b/templates/workspaces/base/terraform/providers.tf index e541b4a4c8..d37da4ff37 100644 --- a/templates/workspaces/base/terraform/providers.tf +++ b/templates/workspaces/base/terraform/providers.tf @@ -6,7 +6,7 @@ terraform { } azuread = { source = "hashicorp/azuread" - version = "=2.20.0" + version = ">= 3.1.0" } azapi = { source = "Azure/azapi" diff --git a/templates/workspaces/base/terraform/variables.tf b/templates/workspaces/base/terraform/variables.tf index b3e2812785..692f2ea3c8 100644 --- a/templates/workspaces/base/terraform/variables.tf +++ b/templates/workspaces/base/terraform/variables.tf @@ -53,6 +53,11 @@ variable "create_aad_groups" { description = "Create AAD groups automatically for the Workspace Application Roles." } +variable "core_api_client_id" { + type = string + description = "The client id of the core API application." +} + variable "enable_airlock" { type = bool description = "Controls the deployment of Airlock resources in the workspace." diff --git a/templates/workspaces/base/terraform/workspace.tf b/templates/workspaces/base/terraform/workspace.tf index 10fb74c6a7..c692875900 100644 --- a/templates/workspaces/base/terraform/workspace.tf +++ b/templates/workspaces/base/terraform/workspace.tf @@ -36,6 +36,7 @@ module "aad" { workspace_owner_object_id = var.workspace_owner_object_id aad_redirect_uris_b64 = var.aad_redirect_uris_b64 create_aad_groups = var.create_aad_groups + core_api_client_id = var.core_api_client_id depends_on = [ azurerm_role_assignment.keyvault_deployer_ws_role, diff --git a/ui/app/package.json b/ui/app/package.json index 9d8321c24a..12a065324b 100644 --- a/ui/app/package.json +++ b/ui/app/package.json @@ -1,6 +1,6 @@ { "name": "tre-ui", - "version": "0.7.0", + "version": "0.7.1", "private": true, "type": "module", "dependencies": { diff --git a/ui/app/src/components/workspaces/WorkspaceUsers.tsx b/ui/app/src/components/workspaces/WorkspaceUsers.tsx index 17fdfff86c..ed8f130f7c 100644 --- a/ui/app/src/components/workspaces/WorkspaceUsers.tsx +++ b/ui/app/src/components/workspaces/WorkspaceUsers.tsx @@ -1,24 +1,38 @@ -import * as React from "react"; -import { useState, useCallback, useEffect, useMemo, useContext } from "react"; -import { GroupedList, IGroup } from "@fluentui/react/lib/GroupedList"; -import { IColumn, DetailsRow } from "@fluentui/react/lib/DetailsList"; -import { SelectionMode } from "@fluentui/react/lib/Selection"; -import { Persona, PersonaSize } from "@fluentui/react/lib/Persona"; -import { HttpMethod, useAuthApiCall } from "../../hooks/useAuthApiCall"; -import { APIError } from "../../models/exceptions"; -import { WorkspaceContext } from "../../contexts/WorkspaceContext"; -import { ApiEndpoint } from "../../models/apiEndpoints"; -import { LoadingState } from "../../models/loadingState"; -import { ExceptionLayout } from "../shared/ExceptionLayout"; -import { User } from "../../models/user"; -import { Stack } from "@fluentui/react"; +import * as React from 'react'; +import { useState, useCallback, useEffect, useMemo, useContext } from 'react'; +import { GroupedList, IGroup } from '@fluentui/react/lib/GroupedList'; +import { IColumn, DetailsRow } from '@fluentui/react/lib/DetailsList'; +import { SelectionMode, Selection, SelectionZone } from '@fluentui/react/lib/Selection'; +import { Persona, PersonaSize } from '@fluentui/react/lib/Persona'; +import { HttpMethod, useAuthApiCall } from '../../hooks/useAuthApiCall'; +import { APIError } from '../../models/exceptions'; +import { WorkspaceContext } from '../../contexts/WorkspaceContext'; +import { ApiEndpoint } from '../../models/apiEndpoints'; +import { LoadingState } from '../../models/loadingState'; +import { ExceptionLayout } from '../shared/ExceptionLayout'; +import { User } from '../../models/user'; +import { AppRolesContext } from '../../contexts/AppRolesContext'; + +import { CommandBarButton, DefaultButton, Dialog, DialogFooter, getTheme, Spinner, SpinnerSize, Stack } from '@fluentui/react'; +import { useNavigate, Route, Routes } from 'react-router-dom'; +import { destructiveButtonStyles } from '../../styles'; +import { WorkSpaceUsersAssignNew } from './WorkspaceUsersAssignNew'; +import config from "../../config.json" interface IUser { id: string; - name: string; - email: string; - role: string; - roles: string[]; + user_id: string; + key: string; + displayName: string; + userPrincipalName: string; + role: IUserRole; + roles: IUserRole[]; +} + +interface IUserRole { + id: string; + displayName: string; + type: string; } export const WorkspaceUsers: React.FunctionComponent = () => { @@ -28,9 +42,42 @@ export const WorkspaceUsers: React.FunctionComponent = () => { loadingState: LoadingState.Loading, }); + const [selectedUserRole, setSelectedUserRole] = useState(undefined); + const [hideCancelDialog, setHideCancelDialog] = useState(true); + const [deassigning, setDeassigning] = useState(false); + const [deassignmentError, setDeassignmentError] = useState(false); + const [apiError, setApiError] = useState({} as APIError); + + const appRolesCtx = useContext(AppRolesContext); + const [isTreAdmin, setIsTreAdmin] = useState(false); + + useEffect(() => { + setIsTreAdmin(appRolesCtx.roles.includes('TREAdmin')); + }, [appRolesCtx.roles]); + + const navigate = useNavigate(); + const theme = getTheme(); const apiCall = useAuthApiCall(); - const { workspace, roles, workspaceApplicationIdURI } = - useContext(WorkspaceContext); + const { workspace, roles, workspaceApplicationIdURI } = useContext(WorkspaceContext); + + const [loadingUsers, setloadingUsers] = useState(false); + + const isTemplateVersionValid = (): Boolean => { + const templateVersion = workspace.templateVersion; + const templateElements = templateVersion.split('.'); + const major = parseInt(templateElements[0]); + const minor = parseInt(templateElements[1]); + + // Base template version 2.1.0 is the minimum required + return (major > 2 || (major === 2 && minor >= 1)); + } + + const allowUserManagement = useMemo(() => { + return isTreAdmin + && isTemplateVersionValid() + && config.userManagementEnabled + && (workspace.properties['create_aad_groups'] === 'true' || workspace.properties['create_aad_groups'] === true); + }, [isTreAdmin, workspace, isTemplateVersionValid]); const getUsers = useCallback(async () => { setState((prevState) => ({ @@ -39,6 +86,7 @@ export const WorkspaceUsers: React.FunctionComponent = () => { loadingState: LoadingState.Loading, })); + setloadingUsers(true); try { const scopeId = roles.length > 0 ? workspaceApplicationIdURI : ""; const result = await apiCall( @@ -49,16 +97,17 @@ export const WorkspaceUsers: React.FunctionComponent = () => { const users = result.users .flatMap((user: any) => - user.roles.map((role: string) => ({ - id: user.id, - name: user.name, - email: user.email, + user.roles.map((role: any) => ({ + id: `${user.id}__${role.id}`, + user_id: user.id, + displayName: user.displayName, + userPrincipalName: user.userPrincipalName, role: role, roles: user.roles, })), ) - .sort((a: { role: string }, b: { role: string }) => - a.role.localeCompare(b.role), + .sort((a: { role: any }, b: { role: any }) => + a.role.id.localeCompare(b.role.id), ); setState({ users, apiError: undefined, loadingState: LoadingState.Ok }); @@ -66,67 +115,107 @@ export const WorkspaceUsers: React.FunctionComponent = () => { err.userMessage = "Error retrieving users"; setState({ users: [], apiError: err, loadingState: LoadingState.Error }); } + setloadingUsers(false); }, [apiCall, workspace.id, roles.length, workspaceApplicationIdURI]); + const addedAssignment = async () => { + navigate(-1); + await getUsers(); + } + useEffect(() => { getUsers(); }, [getUsers]); - const groupedUsers = useMemo(() => { - const groups: { [key: string]: IUser[] } = {}; - state.users.forEach((user) => { - if (!groups[user.role]) { - groups[user.role] = []; + // De-assign user from role + const deassignUser = useCallback(async () => { + try { + setDeassigning(true); + + await apiCall(`${ApiEndpoint.Workspaces}/${workspace.id}/${ApiEndpoint.Users}/assign?user_id=${selectedUserRole?.user_id}&role_id=${selectedUserRole?.role.id}`, + HttpMethod.Delete, ""); + + await getUsers(); + + + setSelectedUserRole(undefined); + setHideCancelDialog(true); + setDeassigning(false); + + } catch (err: any) { + err.userMessage = 'Error deassigning user'; + setApiError(err); + setDeassignmentError(true); + setDeassigning(false); + } + }, [apiCall, selectedUserRole, workspace.id, getUsers]); + + const groups: IGroup[] = useMemo(() => { + const groupMap: any = {}; + const groups: any = []; + let currentIndex = 0; + + state.users.forEach(user => { + if (!groupMap[user.role.id]) { + groupMap[user.role.id] = { + count: 0, + key: user.role.id, + name: user.role.displayName, + startIndex: currentIndex, + level: 0 + }; + + groups.push(groupMap[user.role.id]); } - groups[user.role].push(user); + + groupMap[user.role.id].count += 1; + currentIndex += 1; }); + return groups; }, [state.users]); - const groups: IGroup[] = useMemo(() => { - return Object.keys(groupedUsers).map((role, index) => ({ - key: role, - name: role, - startIndex: index, - count: groupedUsers[role].length, - })); - }, [groupedUsers]); + const selection = useMemo(() => { + const s = new Selection({ + onSelectionChanged: () => { + setSelectedUserRole(s.getSelection()[0] as IUser); + } + }); + s.setItems(state.users, true); + return s; + }, [state.users]); - const columns: IColumn[] = [ + const columns: IColumn[] = useMemo(() => [ { key: "name", name: "Name", fieldName: "name", minWidth: 150, - onRender: (item: User) => ( + onRender: (item: IUser) => ( - ), - }, - ]; - - const onRenderCell = ( - nestingDepth?: number, - item?: IUser, - itemIndex?: number, - group?: IGroup, - ): React.ReactNode => { - return item && typeof itemIndex === "number" && itemIndex > -1 ? ( + ) + } + ], []); + + const onRenderCell = React.useCallback( + (nestingDepth?: number, item?: User, itemIndex?: number, group?: IGroup): React.ReactNode => ( - ) : null; - }; + ), + [columns, selection, isTreAdmin], + ); return ( <> @@ -134,19 +223,78 @@ export const WorkspaceUsers: React.FunctionComponent = () => {

Users

+ {allowUserManagement && + + navigate('new')} + /> + { + selectedUserRole && + { + console.log('De-assign', selectedUserRole); + setHideCancelDialog(false); + }} + /> + } + + }
{state.apiError && } -
- +
+ {!loadingUsers && + + + }
+ { + loadingUsers && + + + + + } + + + + + } /> + ); }; diff --git a/ui/app/src/components/workspaces/WorkspaceUsersAssignNew.tsx b/ui/app/src/components/workspaces/WorkspaceUsersAssignNew.tsx new file mode 100644 index 0000000000..87ec887056 --- /dev/null +++ b/ui/app/src/components/workspaces/WorkspaceUsersAssignNew.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { Dropdown, IDropdownOption, Label, Panel, PanelType, PrimaryButton, Spinner, Stack } from "@fluentui/react"; +import { IPersonaProps } from '@fluentui/react/lib/Persona'; +import { + IBasePickerSuggestionsProps, + NormalPeoplePicker +} from '@fluentui/react/lib/Pickers'; +import { useCallback, useContext, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { WorkspaceContext } from "../../contexts/WorkspaceContext"; +import { HttpMethod, useAuthApiCall } from "../../hooks/useAuthApiCall"; +import { ApiEndpoint } from "../../models/apiEndpoints"; +import { APIError } from "../../models/exceptions"; +import { ExceptionLayout } from "../shared/ExceptionLayout"; + +interface WorkspaceUsersAssignProps { + onAssignUser: (request: any) => void; +} + +interface AssignableUser { + displayName: string; + userPrincipalName: string; + id: string; +} + +interface WorkspaceRole { + id: string; + displayName: string; +} + +const suggestionProps: IBasePickerSuggestionsProps = { + suggestionsHeaderText: 'Suggested Users', + noResultsFoundText: 'No results found', + loadingText: 'Loading', + showRemoveButtons: true, + suggestionsAvailableAlertText: 'People Picker Suggestions available', + suggestionsContainerAriaLabel: 'Suggested contacts', +}; + +export const WorkSpaceUsersAssignNew: React.FunctionComponent = (props: WorkspaceUsersAssignProps) => { + const workspaceCtx = useContext(WorkspaceContext); + const { workspace } = workspaceCtx; + + const navigate = useNavigate(); + const apiCall = useAuthApiCall(); + const picker = React.useRef(null); + + const onFilterChanged = async (filter: string): Promise => { + if (filter) { + return filterPersonasByText(filter); + } else { + return []; + } + }; + + const filterPersonasByText = async (filterText: string): Promise => { + try { + const scopeId = ""; + const response = await apiCall(`${ApiEndpoint.Workspaces}/${workspace.id}/${ApiEndpoint.AssignableUsers}?filter=${filterText}`, HttpMethod.Get, scopeId); + const assignableUsers = response.assignable_users; + + const options: IPersonaProps[] = assignableUsers.map((assignableUser: AssignableUser) => ({ + text: assignableUser.displayName, + secondaryText: assignableUser.userPrincipalName, + key: assignableUser.id + })); + + return options; + } + catch (err: any) { + err.userMessage = 'Error retrieving assignable users'; + } + return []; + }; + + const onChange = (items?: IPersonaProps[] | undefined): void => { + if (items && items.length > 0) { + setSelectedUsers(items.map(item => item.key as string)); + } + else { + setSelectedUsers(null); + } + }; + + const [roleOptions, setRoleOptions] = useState([]); + const [selectedUsers, setSelectedUsers] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + const [assigning, setAssigning] = useState(false); + const [hasAssignmentError, setHasAssignmentError] = useState(false); + const [assignmentError, setAssignmentError] = useState({} as APIError); + + const onRoleChange = (event: any, option: any) => { + setSelectedRole(option ? option.key : null); + }; + + const dismissPanel = useCallback(() => navigate('../'), [navigate]); + + const getWorkspaceRoles = useCallback(async () => { + try { + const scopeId = ""; + const response = await apiCall(`${ApiEndpoint.Workspaces}/${workspace.id}/${ApiEndpoint.Roles}`, HttpMethod.Get, scopeId); + + const options: IDropdownOption[] = response.roles.map((workspaceRole: WorkspaceRole) => ({ + key: workspaceRole.id, + text: workspaceRole.displayName + })); + + setRoleOptions(options); + } + catch (err: any) { + err.userMessage = 'Error retrieving assignable users'; + } + + }, [apiCall, workspace.id]); + + useEffect(() => { + getWorkspaceRoles(); + }, [getWorkspaceRoles]); + + const assign = useCallback(async () => { + setAssigning(true); + + const scopeId = ""; + try { + const response = await apiCall(`${ApiEndpoint.Workspaces}/${workspace.id}/${ApiEndpoint.Users}/assign`, HttpMethod.Post, scopeId, { + role_id: selectedRole, + user_ids: selectedUsers + }); + props.onAssignUser(response); + } + catch (err: any) { + err.userMessage = 'Error assigning workspace user'; + setHasAssignmentError(true); + setAssignmentError(err); + } + setAssigning(false); + + }, [selectedUsers, apiCall, workspace.id, selectedRole, props]); + + const renderFooter = useCallback(() => { + let footer = <> + footer = <> +
+ assign()} disabled={assigning || (!selectedUsers || !selectedRole)}>Assign +
+ + return footer; + }, [selectedUsers, selectedRole, assign, assigning]); + + return ( + + + + + + + + + + + { + assigning && + + + + + } + { + hasAssignmentError && + } + + + + ) +} + diff --git a/ui/app/src/config.source.json b/ui/app/src/config.source.json index 8edc752926..9697124145 100644 --- a/ui/app/src/config.source.json +++ b/ui/app/src/config.source.json @@ -1,11 +1,12 @@ { - "rootClientId": "", - "rootTenantId": "", - "treApplicationId": "api://", - "treUrl": "https://my-tre.northeurope.cloudapp.azure.com/api", - "pollingDelayMilliseconds": 10000, - "treId": "my-tre", - "debug": false, - "version": "0.0.0", - "activeDirectoryUri": "" + "rootClientId": "", + "rootTenantId": "", + "treApplicationId": "api://", + "treUrl": "https://my-tre.northeurope.cloudapp.azure.com/api", + "pollingDelayMilliseconds": 10000, + "treId": "my-tre", + "debug": false, + "version": "0.0.0", + "activeDirectoryUri": "", + "userManagementEnabled": false } diff --git a/ui/app/src/models/apiEndpoints.ts b/ui/app/src/models/apiEndpoints.ts index f83ef8ff40..2eff23a909 100644 --- a/ui/app/src/models/apiEndpoints.ts +++ b/ui/app/src/models/apiEndpoints.ts @@ -20,5 +20,7 @@ export enum ApiEndpoint { Costs = "costs", Metadata = ".metadata", Health = "health", - Users = "users", + Users = 'users', + AssignableUsers = 'assignable-users', + Roles = "roles" } diff --git a/ui/app/yarn.lock b/ui/app/yarn.lock index a5175d7f43..a0f9ee7c09 100644 --- a/ui/app/yarn.lock +++ b/ui/app/yarn.lock @@ -2774,7 +2774,7 @@ iconv-lite@0.6.3: resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" + safer-buffer ">= 2.1.2 < 3" ignore@^5.2.0, ignore@^5.3.1: version "5.3.2" @@ -2828,6 +2828,11 @@ ip@^2.0.1: resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105" integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ== +ipaddr.js@^2.0.1: + version "2.2.0" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz" + integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== + is-arguments@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b"