Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restrict resource templates to specific roles #2623

Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d3d8a91
Add an optional field requiredRoles to resource template
tanya-borisova Sep 16, 2022
683e018
Add an extra test
tanya-borisova Sep 16, 2022
b6973bd
Return 403 Forbidden when trying to create a resource from template w…
tanya-borisova Sep 16, 2022
3dfe164
Filter templates to only the ones the user can use
tanya-borisova Sep 20, 2022
818b3b6
Fix tests; make the requiredRoles field optional
tanya-borisova Sep 20, 2022
d6b30ce
Fix sample templates
tanya-borisova Sep 20, 2022
4418524
Add a parameter to determine if list only returns templates user is a…
tanya-borisova Sep 20, 2022
876150f
Big rename: requiredRoles -> authorizedRoles
tanya-borisova Sep 20, 2022
45f2664
Fixes + missing changelog item
tanya-borisova Sep 20, 2022
3c6e79c
Merge branch 'main' into tborisova/2600-add-ability-to-restrict-resou…
tanya-borisova Sep 20, 2022
220c894
Add changelog item for this PR
tanya-borisova Sep 20, 2022
a6ad1f9
Merge branch 'tborisova/2600-add-ability-to-restrict-resource-templat…
tanya-borisova Sep 20, 2022
5ee3370
Merge branch 'main' into tborisova/2600-add-ability-to-restrict-resou…
tanya-borisova Sep 21, 2022
db0bc6a
fixes; make sure authorizedRoles is optional
tanya-borisova Sep 21, 2022
743a025
Merge branch 'tborisova/2600-add-ability-to-restrict-resource-templat…
tanya-borisova Sep 21, 2022
2e459d3
bump again
tanya-borisova Sep 21, 2022
49a9e49
Merge branch 'main' into tborisova/2600-add-ability-to-restrict-resou…
tanya-borisova Sep 21, 2022
9be5d1d
Add GET endpoints for workspace service templates and user resource t…
tanya-borisova Sep 22, 2022
65539fe
Merge branch 'tborisova/2600-add-ability-to-restrict-resource-templat…
tanya-borisova Sep 22, 2022
bde5917
Merge branch 'main' into tborisova/2600-add-ability-to-restrict-resou…
tanya-borisova Sep 22, 2022
738ffa0
fix tests
tanya-borisova Sep 22, 2022
5005fe0
Merge branch 'main' into tborisova/2600-add-ability-to-restrict-resou…
tanya-borisova Sep 22, 2022
825c076
Merge branch 'tborisova/2600-add-ability-to-restrict-resource-templat…
tanya-borisova Sep 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
**BREAKING CHANGES & MIGRATIONS**:

* Firewall now blocks terraform/hasicorp domains ([#2590](https://github.com/microsoft/AzureTRE/pull/2590)). **Migration** is manual - update the templateVersion of `tre-shared-service-firewall` resource in Cosmos to `0.5.0`. Check the PR for more details.
* Add Airlock Manager Workspace ([#2505](https://github.com/microsoft/AzureTRE/pull/2505))
* Restrict resource templates to specific roles ([#2623](https://github.com/microsoft/AzureTRE/pull/2623/))

FEATURES:

Expand Down
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.34"
__version__ = "0.4.35"
6 changes: 3 additions & 3 deletions api_app/api/routes/shared_service_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
shared_service_templates_core_router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)])


@shared_service_templates_core_router.get("/shared-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_SHARED_SERVICE_TEMPLATES, dependencies=[Depends(get_current_tre_user_or_tre_admin)])
async def get_shared_service_templates(template_repo=Depends(get_repository(ResourceTemplateRepository))) -> ResourceTemplateInformationInList:
templates_infos = template_repo.get_templates_information(ResourceType.SharedService)
@shared_service_templates_core_router.get("/shared-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_SHARED_SERVICE_TEMPLATES)
async def get_shared_service_templates(authorized_only: bool = False, template_repo=Depends(get_repository(ResourceTemplateRepository)), user=Depends(get_current_tre_user_or_tre_admin)) -> ResourceTemplateInformationInList:
templates_infos = template_repo.get_templates_information(ResourceType.SharedService, user.roles if authorized_only else None)
return ResourceTemplateInformationInList(templates=templates_infos)


Expand Down
7 changes: 5 additions & 2 deletions api_app/api/routes/shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from jsonschema.exceptions import ValidationError

from db.repositories.operations import OperationRepository
from db.errors import DuplicateEntity
from db.errors import DuplicateEntity, UserNotAuthorizedToUseTemplate
from api.dependencies.database import get_repository
from api.dependencies.shared_services import get_shared_service_by_id_from_path, get_operation_by_id_from_path
from db.repositories.resource_templates import ResourceTemplateRepository
Expand Down Expand Up @@ -47,13 +47,16 @@ async def retrieve_shared_service_by_id(shared_service=Depends(get_shared_servic
@shared_services_router.post("/shared-services", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_CREATE_SHARED_SERVICE, dependencies=[Depends(get_current_admin_user)])
async def create_shared_service(response: Response, shared_service_input: SharedServiceInCreate, user=Depends(get_current_admin_user), shared_services_repo=Depends(get_repository(SharedServiceRepository)), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository))) -> OperationInResponse:
try:
shared_service, resource_template = shared_services_repo.create_shared_service_item(shared_service_input)
shared_service, resource_template = shared_services_repo.create_shared_service_item(shared_service_input, user.roles)
except (ValidationError, ValueError) as e:
logging.error(f"Failed create shared service model instance: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DuplicateEntity as e:
logging.error(f"Shared service already exists: {e}")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except UserNotAuthorizedToUseTemplate as e:
logging.error(f"User not authorized to use template: {e}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))

operation = await save_and_deploy_resource(
resource=shared_service,
Expand Down
6 changes: 3 additions & 3 deletions api_app/api/routes/user_resource_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
user_resource_templates_core_router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)])


@user_resource_templates_core_router.get("/workspace-service-templates/{service_template_name}/user-resource-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_USER_RESOURCE_TEMPLATES, dependencies=[Depends(get_current_tre_user_or_tre_admin)])
async def get_user_resource_templates_for_service_template(service_template_name: str, template_repo=Depends(get_repository(ResourceTemplateRepository))) -> ResourceTemplateInformationInList:
template_infos = template_repo.get_templates_information(ResourceType.UserResource, service_template_name)
@user_resource_templates_core_router.get("/workspace-service-templates/{service_template_name}/user-resource-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_USER_RESOURCE_TEMPLATES)
async def get_user_resource_templates_for_service_template(service_template_name: str, authorized_only: bool = False, template_repo=Depends(get_repository(ResourceTemplateRepository)), user=Depends(get_current_tre_user_or_tre_admin)) -> ResourceTemplateInformationInList:
template_infos = template_repo.get_templates_information(ResourceType.UserResource, user.roles if authorized_only else None, service_template_name)
return ResourceTemplateInformationInList(templates=template_infos)


Expand Down
6 changes: 3 additions & 3 deletions api_app/api/routes/workspace_service_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
workspace_service_templates_core_router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)])


@workspace_service_templates_core_router.get("/workspace-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_WORKSPACE_SERVICE_TEMPLATES, dependencies=[Depends(get_current_tre_user_or_tre_admin)])
async def get_workspace_service_templates(template_repo=Depends(get_repository(ResourceTemplateRepository))) -> ResourceTemplateInformationInList:
templates_infos = template_repo.get_templates_information(ResourceType.WorkspaceService)
@workspace_service_templates_core_router.get("/workspace-service-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_WORKSPACE_SERVICE_TEMPLATES)
async def get_workspace_service_templates(authorized_only: bool = False, template_repo=Depends(get_repository(ResourceTemplateRepository)), user=Depends(get_current_tre_user_or_tre_admin)) -> ResourceTemplateInformationInList:
templates_infos = template_repo.get_templates_information(ResourceType.WorkspaceService, user.roles if authorized_only else None)
return ResourceTemplateInformationInList(templates=templates_infos)


Expand Down
4 changes: 2 additions & 2 deletions api_app/api/routes/workspace_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@


@workspace_templates_admin_router.get("/workspace-templates", response_model=ResourceTemplateInformationInList, name=strings.API_GET_WORKSPACE_TEMPLATES)
async def get_workspace_templates(template_repo=Depends(get_repository(ResourceTemplateRepository))) -> ResourceTemplateInformationInList:
templates_infos = template_repo.get_templates_information(ResourceType.Workspace)
async def get_workspace_templates(authorized_only: bool = False, template_repo=Depends(get_repository(ResourceTemplateRepository)), user=Depends(get_current_admin_user)) -> ResourceTemplateInformationInList:
templates_infos = template_repo.get_templates_information(ResourceType.Workspace, user.roles if authorized_only else None)
return ResourceTemplateInformationInList(templates=templates_infos)


Expand Down
24 changes: 21 additions & 3 deletions api_app/api/routes/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from api.dependencies.database import get_repository
from api.dependencies.workspaces import get_operation_by_id_from_path, get_workspace_by_id_from_path, get_deployed_workspace_by_id_from_path, get_deployed_workspace_service_by_id_from_path, get_workspace_service_by_id_from_path, get_user_resource_by_id_from_path

from db.errors import UserNotAuthorizedToUseTemplate
from db.repositories.operations import OperationRepository
from db.repositories.resource_templates import ResourceTemplateRepository
from db.repositories.user_resources import UserResourceRepository
Expand Down Expand Up @@ -88,10 +89,13 @@ async def create_workspace(workspace_create: WorkspaceInCreate, response: Respon
try:
# TODO: This requires Directory.ReadAll ( Application.Read.All ) to be enabled in the Azure AD application to enable a users workspaces to be listed. This should be made optional.
auth_info = extract_auth_information(workspace_create.properties)
workspace, resource_template = workspace_repo.create_workspace_item(workspace_create, auth_info, user.id)
workspace, resource_template = workspace_repo.create_workspace_item(workspace_create, auth_info, user.id, user.roles)
except (ValidationError, ValueError) as e:
logging.error(f"Failed to create workspace model instance: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except UserNotAuthorizedToUseTemplate as e:
logging.error(f"User not authorized to use template: {e}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))

operation = await save_and_deploy_resource(
resource=workspace,
Expand Down Expand Up @@ -194,6 +198,9 @@ async def create_workspace_service(response: Response, workspace_service_input:
except (ValidationError, ValueError) as e:
logging.error(f"Failed create workspace service model instance: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except UserNotAuthorizedToUseTemplate as e:
logging.error(f"User not authorized to use template: {e}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))

operation = await save_and_deploy_resource(
resource=workspace_service,
Expand Down Expand Up @@ -306,13 +313,24 @@ async def retrieve_user_resource_by_id(user_resource=Depends(get_user_resource_b


@user_resources_workspace_router.post("/workspaces/{workspace_id}/workspace-services/{service_id}/user-resources", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_CREATE_USER_RESOURCE)
async def create_user_resource(response: Response, user_resource_create: UserResourceInCreate, user_resource_repo=Depends(get_repository(UserResourceRepository)), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), user=Depends(get_current_workspace_owner_or_researcher_user), workspace=Depends(get_deployed_workspace_by_id_from_path), workspace_service=Depends(get_deployed_workspace_service_by_id_from_path)) -> OperationInResponse:
async def create_user_resource(
response: Response,
user_resource_create: UserResourceInCreate,
user_resource_repo=Depends(get_repository(UserResourceRepository)),
resource_template_repo=Depends(get_repository(ResourceTemplateRepository)),
operations_repo=Depends(get_repository(OperationRepository)),
user=Depends(get_current_workspace_owner_or_researcher_user),
workspace=Depends(get_deployed_workspace_by_id_from_path),
workspace_service=Depends(get_deployed_workspace_service_by_id_from_path)) -> OperationInResponse:

try:
user_resource, resource_template = user_resource_repo.create_user_resource_item(user_resource_create, workspace.id, workspace_service.id, workspace_service.templateName, user.id)
user_resource, resource_template = user_resource_repo.create_user_resource_item(user_resource_create, workspace.id, workspace_service.id, workspace_service.templateName, user.id, user.roles)
except (ValidationError, ValueError) as e:
logging.error(f"Failed create user resource model instance: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except UserNotAuthorizedToUseTemplate as e:
logging.error(f"User not authorized to use template: {e}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))

operation = await save_and_deploy_resource(
resource=user_resource,
Expand Down
4 changes: 4 additions & 0 deletions api_app/db/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ class ResourceIsNotDeployed(Exception):

class InvalidInput(Exception):
"""Raised when invalid input is received when creating an entity."""


class UserNotAuthorizedToUseTemplate(Exception):
"""Raised when user attempts to use a template they aren't authorized to use"""
15 changes: 12 additions & 3 deletions api_app/db/repositories/resource_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,23 @@ def enrich_template(template: ResourceTemplate, is_update: bool = False) -> dict
else:
return enrich_user_resource_template(template, is_update=is_update)

def get_templates_information(self, resource_type: ResourceType, parent_service_name: str = "") -> List[ResourceTemplateInformation]:
def get_templates_information(self, resource_type: ResourceType, user_roles: List[str] = None, parent_service_name: str = "") -> List[ResourceTemplateInformation]:
"""
Returns name/title/description for all current resource_type templates

:param user_roles: If not none, only return templates that the user is authorized to use.
template.authorizedRoles should contain at least one of user_roles
"""
query = f'SELECT c.name, c.title, c.description FROM c WHERE c.resourceType = "{resource_type}" AND c.current = true'
query = f'SELECT c.name, c.title, c.description, c.authorizedRoles FROM c WHERE c.resourceType = "{resource_type}" AND c.current = true'
if resource_type == ResourceType.UserResource:
query += f' AND c.parentWorkspaceService = "{parent_service_name}"'
template_infos = self.query(query=query)
return [parse_obj_as(ResourceTemplateInformation, info) for info in template_infos]
templates = [parse_obj_as(ResourceTemplateInformation, info) for info in template_infos]

if not user_roles:
return templates
# User can view template if they have at least one of authorizedRoles
return [t for t in templates if not t.authorizedRoles or len(set(t.authorizedRoles).intersection(set(user_roles))) > 0]

def get_current_template(self, template_name: str, resource_type: ResourceType, parent_service_name: str = "") -> Union[ResourceTemplate, UserResourceTemplate]:
"""
Expand Down Expand Up @@ -97,6 +105,7 @@ def create_template(self, template_input: ResourceTemplateInCreate, resource_typ
"resourceType": resource_type,
"current": template_input.current,
"required": template_input.json_schema["required"],
"authorizedRoles": template_input.json_schema["authorizedRoles"] if "authorizedRoles" in template_input.json_schema else [],
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
"properties": template_input.json_schema["properties"],
"customActions": template_input.customActions
}
Expand Down
14 changes: 10 additions & 4 deletions api_app/db/repositories/resources.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import copy
from datetime import datetime
from typing import Tuple
from typing import Tuple, List

from azure.cosmos import CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
from core import config
from db.errors import EntityDoesNotExist
from db.errors import EntityDoesNotExist, UserNotAuthorizedToUseTemplate
from db.repositories.base import BaseRepository
from db.repositories.resource_templates import ResourceTemplateRepository
from jsonschema import validate
Expand Down Expand Up @@ -77,7 +77,7 @@ def get_resource_by_template_name(self, template_name: str) -> Resource:
raise EntityDoesNotExist
return parse_obj_as(Resource, resources[0])

def validate_input_against_template(self, template_name: str, resource_input, resource_type: ResourceType, parent_template_name: str = "") -> ResourceTemplate:
def validate_input_against_template(self, template_name: str, resource_input, resource_type: ResourceType, user_roles: List[str] = None, parent_template_name: str = "") -> ResourceTemplate:
try:
template = self._get_enriched_template(template_name, resource_type, parent_template_name)
except EntityDoesNotExist:
Expand All @@ -86,6 +86,12 @@ def validate_input_against_template(self, template_name: str, resource_input, re
else:
raise ValueError(f'The template "{template_name}" does not exist')

# If authorizedRoles is empty, template is available to all users
if "authorizedRoles" in template and template["authorizedRoles"]:
# If authorizedRoles is not empty, the user is required to have at least one of authorizedRoles
if len(set(template["authorizedRoles"]).intersection(set(user_roles))) == 0:
raise UserNotAuthorizedToUseTemplate(f"User not authorized to use template {template_name}")

self._validate_resource_parameters(resource_input.dict(), template)

return parse_obj_as(ResourceTemplate, template)
Expand Down Expand Up @@ -128,7 +134,7 @@ def validate_patch(self, resource_patch: ResourcePatch, resource_template_repo:
update_template["required"] = []
update_template["properties"] = {}
for prop_name, prop in enriched_template["properties"].items():
if("updateable" in prop.keys() and prop["updateable"] is True):
if ("updateable" in prop.keys() and prop["updateable"] is True):
update_template["properties"][prop_name] = prop

self._validate_resource_parameters(resource_patch.dict(), update_template)
Expand Down
4 changes: 2 additions & 2 deletions api_app/db/repositories/shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def get_deployed_shared_service_by_id(self, shared_service_id: str, operations_r
def get_shared_service_spec_params(self):
return self.get_resource_base_spec_params()

def create_shared_service_item(self, shared_service_input: SharedServiceTemplateInCreate) -> Tuple[SharedService, ResourceTemplate]:
def create_shared_service_item(self, shared_service_input: SharedServiceTemplateInCreate, user_roles: List[str]) -> Tuple[SharedService, ResourceTemplate]:
shared_service_id = str(uuid.uuid4())

existing_shared_services = self.query(self.operating_shared_service_with_template_name_query(shared_service_input.templateName))
Expand All @@ -68,7 +68,7 @@ def create_shared_service_item(self, shared_service_input: SharedServiceTemplate
raise InternalError(f"More than one active shared service exists with the same id {shared_service_id}")
raise DuplicateEntity

template = self.validate_input_against_template(shared_service_input.templateName, shared_service_input, ResourceType.SharedService)
template = self.validate_input_against_template(shared_service_input.templateName, shared_service_input, ResourceType.SharedService, user_roles)

resource_spec_parameters = {**shared_service_input.properties, **self.get_shared_service_spec_params()}

Expand Down
4 changes: 2 additions & 2 deletions api_app/db/repositories/user_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ def user_resources_query(workspace_id: str, service_id: str):
def active_user_resources_query(workspace_id: str, service_id: str):
return f'SELECT * FROM c WHERE {IS_NOT_DELETED_CLAUSE} AND c.resourceType = "{ResourceType.UserResource}" AND c.parentWorkspaceServiceId = "{service_id}" AND c.workspaceId = "{workspace_id}"'

def create_user_resource_item(self, user_resource_input: UserResourceInCreate, workspace_id: str, parent_workspace_service_id: str, parent_template_name: str, user_id: str) -> Tuple[UserResource, ResourceTemplate]:
def create_user_resource_item(self, user_resource_input: UserResourceInCreate, workspace_id: str, parent_workspace_service_id: str, parent_template_name: str, user_id: str, user_roles: List[str]) -> Tuple[UserResource, ResourceTemplate]:
full_user_resource_id = str(uuid.uuid4())

template = self.validate_input_against_template(user_resource_input.templateName, user_resource_input, ResourceType.UserResource, parent_template_name)
template = self.validate_input_against_template(user_resource_input.templateName, user_resource_input, ResourceType.UserResource, user_roles, parent_template_name)

# we don't want something in the input to overwrite the system parameters, so dict.update can't work.
resource_spec_parameters = {**user_resource_input.properties, **self.get_user_resource_spec_params()}
Expand Down
Loading