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

Use delegated key when generating SAS token in API #2460

Merged
merged 14 commits into from
Aug 16, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ENHANCEMENTS:
* 'CreationTime' field was added to Airlock requests ([#2432](https://github.com/microsoft/AzureTRE/issues/2432))
* Bundles mirror Terraform plugins when built ([#2446](https://github.com/microsoft/AzureTRE/pull/2446))
* 'Get all Airlock requests' endpoint supports filtering ([#2433](https://github.com/microsoft/AzureTRE/pull/2433)).
* API uses user delagation key when generating SAS token for airlock requests. ([#2390](https://github.com/microsoft/AzureTRE/issues/2390))

BUG FIXES:

Expand Down
2 changes: 1 addition & 1 deletion airlock_processor/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.2"
__version__ = "0.4.3"
2 changes: 1 addition & 1 deletion airlock_processor/shared_code/blob_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def copy_data(source_account_name: str, destination_account_name: str, request_i
def get_credential() -> DefaultAzureCredential:
managed_identity = os.environ.get("MANAGED_IDENTITY_CLIENT_ID")
if managed_identity:
logging.info("using the Airlock processor's managed identity to get storage management client")
logging.info("using the Airlock processor's managed identity to get credentials.")
return DefaultAzureCredential(managed_identity_client_id=os.environ["MANAGED_IDENTITY_CLIENT_ID"],
exclude_shared_token_cache_credential=True) if managed_identity else DefaultAzureCredential()

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.12"
__version__ = "0.4.13"
11 changes: 5 additions & 6 deletions api_app/api/routes/airlock.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,12 @@
from services.authentication import get_current_workspace_owner_or_researcher_user_or_airlock_manager, get_current_workspace_owner_or_researcher_user, get_current_airlock_manager_user

from .airlock_resource_helpers import save_airlock_review, save_and_publish_event_airlock_request, \
update_status_and_publish_event_airlock_request, RequestAccountDetails, enrich_requests_with_allowed_actions, get_airlock_requests_by_user_and_workspace
update_status_and_publish_event_airlock_request, enrich_requests_with_allowed_actions, get_airlock_requests_by_user_and_workspace

from services.airlock import get_storage_management_client, validate_user_allowed_to_access_storage_account, \
get_account_and_rg_by_request, get_airlock_request_container_sas_token, validate_request_status
from services.airlock import validate_user_allowed_to_access_storage_account, \
get_account_by_request, get_airlock_request_container_sas_token, validate_request_status

airlock_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)])
storage_client = get_storage_management_client()


# airlock
Expand Down Expand Up @@ -102,6 +101,6 @@ async def get_airlock_container_link(workspace=Depends(get_deployed_workspace_by
user=Depends(get_current_workspace_owner_or_researcher_user)) -> AirlockRequestTokenInResponse:
validate_user_allowed_to_access_storage_account(user, airlock_request)
validate_request_status(airlock_request)
request_account_details: RequestAccountDetails = get_account_and_rg_by_request(airlock_request, workspace)
container_url = get_airlock_request_container_sas_token(storage_client, request_account_details, airlock_request)
account_name: str = get_account_by_request(airlock_request, workspace)
container_url = get_airlock_request_container_sas_token(account_name, airlock_request)
return AirlockRequestTokenInResponse(containerUrl=container_url)
9 changes: 0 additions & 9 deletions api_app/api/routes/airlock_resource_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,6 @@
from services.authentication import get_access_service


class RequestAccountDetails:
account_name: str
account_rg: str

def __init__(self, account_name, account_rg):
self.account_name = account_name
self.account_rg = account_rg


async def save_and_publish_event_airlock_request(airlock_request: AirlockRequest, airlock_request_repo: AirlockRequestRepository, user: User, workspace: Workspace):

# First check we have some email addresses so we can notify people.
Expand Down
79 changes: 33 additions & 46 deletions api_app/services/airlock.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import logging
from datetime import datetime, timedelta

from azure.mgmt.storage import StorageManagementClient
from azure.storage.blob import generate_container_sas, ContainerSasPermissions
from azure.storage.blob import generate_container_sas, ContainerSasPermissions, BlobServiceClient
from fastapi import HTTPException
from starlette import status

from api.routes.airlock_resource_helpers import RequestAccountDetails
from core import config
from azure.identity import DefaultAzureCredential
from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus
Expand All @@ -15,53 +13,43 @@
from resources import strings, constants


def get_storage_management_client():
token_credential = DefaultAzureCredential(managed_identity_client_id=config.MANAGED_IDENTITY_CLIENT_ID,
exclude_shared_token_cache_credential=True)
return StorageManagementClient(credential=token_credential, subscription_id=config.SUBSCRIPTION_ID)
def get_credential() -> DefaultAzureCredential:
managed_identity = config.MANAGED_IDENTITY_CLIENT_ID
if managed_identity:
logging.info("Using managed identity credentials.")
return DefaultAzureCredential(managed_identity_client_id=config.MANAGED_IDENTITY_CLIENT_ID,
exclude_shared_token_cache_credential=True) if managed_identity else DefaultAzureCredential()


def get_account_and_rg_by_request(airlock_request: AirlockRequest, workspace: Workspace) -> RequestAccountDetails:
def get_account_by_request(airlock_request: AirlockRequest, workspace: Workspace) -> str:
tre_id = config.TRE_ID
short_workspace_id = workspace.id[-4:]
if airlock_request.requestType == constants.IMPORT_TYPE:
if airlock_request.status == AirlockRequestStatus.Draft:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_IMPORT_EXTERNAL.format(tre_id),
constants.CORE_RESOURCE_GROUP_NAME.format(tre_id))
return constants.STORAGE_ACCOUNT_NAME_IMPORT_EXTERNAL.format(tre_id)
elif airlock_request.status == AirlockRequestStatus.Submitted:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_IMPORT_INPROGRESS.format(tre_id),
constants.CORE_RESOURCE_GROUP_NAME.format(tre_id))
return constants.STORAGE_ACCOUNT_NAME_IMPORT_INPROGRESS.format(tre_id)
elif airlock_request.status == AirlockRequestStatus.InReview:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_IMPORT_INPROGRESS.format(tre_id),
constants.CORE_RESOURCE_GROUP_NAME.format(tre_id))
return constants.STORAGE_ACCOUNT_NAME_IMPORT_INPROGRESS.format(tre_id)
elif airlock_request.status == AirlockRequestStatus.Approved:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_IMPORT_APPROVED.format(short_workspace_id),
constants.WORKSPACE_RESOURCE_GROUP_NAME.format(tre_id, short_workspace_id))
return constants.STORAGE_ACCOUNT_NAME_IMPORT_APPROVED.format(short_workspace_id)
elif airlock_request.status == AirlockRequestStatus.Rejected:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_IMPORT_REJECTED.format(tre_id),
constants.CORE_RESOURCE_GROUP_NAME.format(tre_id))
return constants.STORAGE_ACCOUNT_NAME_IMPORT_REJECTED.format(tre_id)
elif airlock_request.status == AirlockRequestStatus.Blocked:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_IMPORT_BLOCKED.format(tre_id),
constants.CORE_RESOURCE_GROUP_NAME.format(tre_id))
return constants.STORAGE_ACCOUNT_NAME_IMPORT_BLOCKED.format(tre_id)
else:
if airlock_request.status == AirlockRequestStatus.Draft:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_EXPORT_INTERNAL.format(short_workspace_id),
constants.WORKSPACE_RESOURCE_GROUP_NAME.format(tre_id, short_workspace_id))
return constants.STORAGE_ACCOUNT_NAME_EXPORT_INTERNAL.format(short_workspace_id)
elif airlock_request.status in AirlockRequestStatus.Submitted:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_EXPORT_INPROGRESS.format(short_workspace_id),
constants.WORKSPACE_RESOURCE_GROUP_NAME.format(tre_id, short_workspace_id))
return constants.STORAGE_ACCOUNT_NAME_EXPORT_INPROGRESS.format(short_workspace_id)
elif airlock_request.status == AirlockRequestStatus.InReview:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_EXPORT_INPROGRESS.format(short_workspace_id),
constants.WORKSPACE_RESOURCE_GROUP_NAME.format(tre_id, short_workspace_id))
return constants.STORAGE_ACCOUNT_NAME_EXPORT_INPROGRESS.format(short_workspace_id)
elif airlock_request.status == AirlockRequestStatus.Approved:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_EXPORT_APPROVED.format(tre_id),
constants.CORE_RESOURCE_GROUP_NAME.format(tre_id))
return constants.STORAGE_ACCOUNT_NAME_EXPORT_APPROVED.format(tre_id)
elif airlock_request.status == AirlockRequestStatus.Rejected:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_EXPORT_REJECTED.format(short_workspace_id),
constants.WORKSPACE_RESOURCE_GROUP_NAME.format(tre_id, short_workspace_id))
return constants.STORAGE_ACCOUNT_NAME_EXPORT_REJECTED.format(short_workspace_id)
elif airlock_request.status == AirlockRequestStatus.Blocked:
return RequestAccountDetails(constants.STORAGE_ACCOUNT_NAME_EXPORT_BLOCKED.format(short_workspace_id),
constants.WORKSPACE_RESOURCE_GROUP_NAME.format(tre_id, short_workspace_id))
return constants.STORAGE_ACCOUNT_NAME_EXPORT_BLOCKED.format(short_workspace_id)


def validate_user_allowed_to_access_storage_account(user: User, airlock_request: AirlockRequest):
Expand All @@ -88,31 +76,30 @@ def validate_request_status(airlock_request: AirlockRequest):
return


def get_storage_account_key(storage_client: StorageManagementClient, request_account_details: RequestAccountDetails):
return storage_client.storage_accounts.list_keys(request_account_details.account_rg,
request_account_details.account_name).keys[0].value


def get_required_permission(airlock_request: AirlockRequest) -> ContainerSasPermissions:
if airlock_request.status == AirlockRequestStatus.Draft:
return ContainerSasPermissions(read=True, write=True, list=True, delete=True)
else:
return ContainerSasPermissions(read=True, list=True)


def get_airlock_request_container_sas_token(storage_client: StorageManagementClient,
request_account_details: RequestAccountDetails,
def get_airlock_request_container_sas_token(account_name: str,
airlock_request: AirlockRequest):
account_key = get_storage_account_key(storage_client, request_account_details)
required_permission = get_required_permission(airlock_request)
blob_service_client = BlobServiceClient(account_url=get_account_url(account_name),
credential=get_credential())
expiry = datetime.utcnow() + timedelta(hours=config.AIRLOCK_SAS_TOKEN_EXPIRY_PERIOD_IN_HOURS)
udk = blob_service_client.get_user_delegation_key(datetime.utcnow(), expiry)
required_permission = get_required_permission(airlock_request)

# TODO: use user delegated key https://github.com/microsoft/AzureTRE/issues/2185
token = generate_container_sas(container_name=airlock_request.id,
account_name=request_account_details.account_name,
account_key=account_key,
account_name=account_name,
user_delegation_key=udk,
permission=required_permission,
expiry=expiry)

return "https://{}.blob.core.windows.net/{}?{}" \
.format(request_account_details.account_name, airlock_request.id, token)
.format(account_name, airlock_request.id, token)


def get_account_url(account_name: str) -> str:
return f"https://{account_name}.blob.core.windows.net/"
8 changes: 4 additions & 4 deletions templates/core/terraform/airlock/identity.tf
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ resource "azurerm_role_assignment" "airlock_blob_data_contributor" {

# This might be considered redundent since we give Virtual Machine Contributor
# at the subscription level, but best to be explicit.
resource "azurerm_role_assignment" "api_reader_data_access" {
count = length(local.api_sa_reader_data_access)
scope = local.api_sa_reader_data_access[count.index]
role_definition_name = "Reader and Data Access"
resource "azurerm_role_assignment" "api_sa_data_contributor" {
count = length(local.api_sa_data_contributor)
scope = local.api_sa_data_contributor[count.index]
role_definition_name = "Storage Blob Data Contributor"
principal_id = var.api_principal_id
}
2 changes: 1 addition & 1 deletion templates/core/terraform/airlock/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ locals {
azurerm_storage_account.sa_import_blocked.id
]

api_sa_reader_data_access = [
api_sa_data_contributor = [
azurerm_storage_account.sa_import_external.id,
azurerm_storage_account.sa_import_in_progress.id,
azurerm_storage_account.sa_export_approved.id
Expand Down
54 changes: 49 additions & 5 deletions templates/core/terraform/airlock/storage_accounts.tf
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ resource "azurerm_storage_account" "sa_import_external" {
lifecycle { ignore_changes = [tags] }
}

data "azurerm_private_dns_zone" "blobcore" {
name = "privatelink.blob.core.windows.net"
resource_group_name = var.resource_group_name
}

resource "azurerm_private_endpoint" "stg_import_external_pe" {
name = "stg-ex-import-blob-${var.tre_id}"
location = var.location
resource_group_name = var.resource_group_name
subnet_id = var.airlock_storage_subnet_id
tags = var.tre_core_tags

lifecycle { ignore_changes = [tags] }

private_dns_zone_group {
name = "private-dns-zone-group-stg-export-app"
private_dns_zone_ids = [data.azurerm_private_dns_zone.blobcore.id]
}

private_service_connection {
name = "psc-stgeximport-${var.tre_id}"
private_connection_resource_id = azurerm_storage_account.sa_import_external.id
is_manual_connection = false
subresource_names = ["Blob"]
}
}

# 'Approved' export
resource "azurerm_storage_account" "sa_export_approved" {
name = local.export_approved_storage_name
Expand All @@ -42,6 +69,28 @@ resource "azurerm_storage_account" "sa_export_approved" {
lifecycle { ignore_changes = [tags] }
}

resource "azurerm_private_endpoint" "stg_export_approved_pe" {
name = "stg-app-export-blob-${var.tre_id}"
location = var.location
resource_group_name = var.resource_group_name
subnet_id = var.airlock_storage_subnet_id
tags = var.tre_core_tags

lifecycle { ignore_changes = [tags] }

private_dns_zone_group {
name = "private-dns-zone-group-stg-export-app"
private_dns_zone_ids = [data.azurerm_private_dns_zone.blobcore.id]
}

private_service_connection {
name = "psc-stgappexport-${var.tre_id}"
private_connection_resource_id = azurerm_storage_account.sa_export_approved.id
is_manual_connection = false
subresource_names = ["Blob"]
}
}

# 'In-Progress' storage account
resource "azurerm_storage_account" "sa_import_in_progress" {
name = local.import_in_progress_storage_name
Expand All @@ -67,11 +116,6 @@ resource "azurerm_storage_account" "sa_import_in_progress" {
lifecycle { ignore_changes = [tags] }
}

data "azurerm_private_dns_zone" "blobcore" {
name = "privatelink.blob.core.windows.net"
resource_group_name = var.resource_group_name
}

resource "azurerm_private_endpoint" "stg_import_inprogress_pe" {
name = "stg-ip-import-blob-${var.tre_id}"
location = var.location
Expand Down
2 changes: 1 addition & 1 deletion templates/core/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.14"
__version__ = "0.4.15"
2 changes: 1 addition & 1 deletion templates/workspaces/base/porter.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: tre-workspace-base
version: 0.3.23
version: 0.3.24
description: "A base Azure TRE workspace"
dockerfile: Dockerfile.tmpl
registry: azuretre
Expand Down
2 changes: 1 addition & 1 deletion templates/workspaces/base/terraform/airlock/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ locals {
azurerm_storage_account.sa_export_blocked.id
]

api_reader_data_access = [
api_sa_data_contributor = [
azurerm_storage_account.sa_import_approved.id,
azurerm_storage_account.sa_export_internal.id,
azurerm_storage_account.sa_export_inprogress.id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,9 @@ resource "azurerm_role_assignment" "airlock_blob_data_contributor" {

# This might be considered redundent since we give Virtual Machine Contributor
# at the subscription level, but best to be explicit.
resource "azurerm_role_assignment" "api_reader_data_access" {
count = length(local.api_reader_data_access)
scope = local.api_reader_data_access[count.index]
role_definition_name = "Reader and Data Access"
resource "azurerm_role_assignment" "api_sa_data_contributor" {
count = length(local.api_sa_data_contributor)
scope = local.api_sa_data_contributor[count.index]
role_definition_name = "Storage Blob Data Contributor"
principal_id = data.azurerm_user_assigned_identity.api_id.principal_id
}