From 46a791817c08f376873722a24840d7b139ca6ef9 Mon Sep 17 00:00:00 2001 From: Christian Freitas Date: Thu, 1 Dec 2022 16:23:31 -0500 Subject: [PATCH] WX-696 Enable getting SAS token from WSM (#6954) * WX-696 Enable getting SAS token from WSM * Wire container resource id from config * Move resource-container-id config path * First pass at config for WSM * Remove unused singleton config * Tests for new config * Fix config parsing * Modified b2c token to be provided each time * Remove singletonConfig arg from factory * Restore types to factory configs * Clean up comments and empty token default * Default to config b2c before searching environment * Fix token default on api client * Fix test * Refactor error handling for when there is no token * Remove token constructor arg for clientProvider * Move configs to global singleton config * Update filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemManager.scala * default -> override * Add override token to test * Update filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemManager.scala Co-authored-by: Adam Nichols * Parentheses * Reduce token timeout * Move AzureCredentials to separate file * Make AzureCredentials an object * WSM token cleanup * Config refactor (#6960) Co-authored-by: Janet Gainer-Dewar * Initial blob token documentation * Refine language in BlobSasTokenGenerator * Update comment and formatting Co-authored-by: Janet Gainer-Dewar Co-authored-by: Adam Nichols --- .../cloud/nio/impl/drs/DrsCredentials.scala | 2 +- .../filesystems/blob/AzureCredentials.scala | 52 +++++++ .../blob/BlobFileSystemConfig.scala | 75 ++++++++++ .../blob/BlobFileSystemManager.scala | 133 +++++++++++++----- .../blob/BlobPathBuilderFactory.scala | 58 +++++--- .../WorkspaceManagerApiClientProvider.scala | 22 ++- .../blob/BlobFileSystemConfigSpec.scala | 75 ++++++++++ .../blob/BlobPathBuilderFactorySpec.scala | 39 ++--- .../blob/BlobPathBuilderSpec.scala | 6 +- src/ci/resources/tes_application.conf | 24 ++-- 10 files changed, 368 insertions(+), 118 deletions(-) create mode 100644 filesystems/blob/src/main/scala/cromwell/filesystems/blob/AzureCredentials.scala create mode 100644 filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemConfig.scala create mode 100644 filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemConfigSpec.scala diff --git a/cloud-nio/cloud-nio-impl-drs/src/main/scala/cloud/nio/impl/drs/DrsCredentials.scala b/cloud-nio/cloud-nio-impl-drs/src/main/scala/cloud/nio/impl/drs/DrsCredentials.scala index 9fae8552fa7..2d3e972508a 100644 --- a/cloud-nio/cloud-nio-impl-drs/src/main/scala/cloud/nio/impl/drs/DrsCredentials.scala +++ b/cloud-nio/cloud-nio-impl-drs/src/main/scala/cloud/nio/impl/drs/DrsCredentials.scala @@ -80,7 +80,7 @@ case object GoogleAppDefaultTokenStrategy extends DrsCredentials { */ case class AzureDrsCredentials(identityClientId: Option[String]) extends DrsCredentials { - final val tokenAcquisitionTimeout = 30.seconds + final val tokenAcquisitionTimeout = 5.seconds val azureProfile = new AzureProfile(AzureEnvironment.AZURE) val tokenScope = "https://management.azure.com/.default" diff --git a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/AzureCredentials.scala b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/AzureCredentials.scala new file mode 100644 index 00000000000..ae84e39adbe --- /dev/null +++ b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/AzureCredentials.scala @@ -0,0 +1,52 @@ +package cromwell.filesystems.blob + +import cats.implicits.catsSyntaxValidatedId +import com.azure.core.credential.TokenRequestContext +import com.azure.core.management.AzureEnvironment +import com.azure.core.management.profile.AzureProfile +import com.azure.identity.DefaultAzureCredentialBuilder +import common.validation.ErrorOr.ErrorOr + +import scala.concurrent.duration._ +import scala.jdk.DurationConverters._ + +import scala.util.{Failure, Success, Try} + +/** + * Strategy for obtaining an access token in an environment with available Azure identity. + * If you need to disambiguate among multiple active user-assigned managed identities, pass + * in the client id of the identity that should be used. + */ +case object AzureCredentials { + + final val tokenAcquisitionTimeout = 5.seconds + + val azureProfile = new AzureProfile(AzureEnvironment.AZURE) + val tokenScope = "https://management.azure.com/.default" + + private def tokenRequestContext: TokenRequestContext = { + val trc = new TokenRequestContext() + trc.addScopes(tokenScope) + trc + } + + private def defaultCredentialBuilder: DefaultAzureCredentialBuilder = + new DefaultAzureCredentialBuilder() + .authorityHost(azureProfile.getEnvironment.getActiveDirectoryEndpoint) + + def getAccessToken(identityClientId: Option[String]): ErrorOr[String] = { + val credentials = identityClientId.foldLeft(defaultCredentialBuilder) { + (builder, clientId) => builder.managedIdentityClientId(clientId) + }.build() + + Try( + credentials + .getToken(tokenRequestContext) + .block(tokenAcquisitionTimeout.toJava) + ) match { + case Success(null) => "null token value attempting to obtain access token".invalidNel + case Success(token) => token.getToken.validNel + case Failure(error) => s"Failed to refresh access token: ${error.getMessage}".invalidNel + } + } +} diff --git a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemConfig.scala b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemConfig.scala new file mode 100644 index 00000000000..f68bf7f5176 --- /dev/null +++ b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemConfig.scala @@ -0,0 +1,75 @@ +package cromwell.filesystems.blob + +import cats.implicits.catsSyntaxValidatedId +import cats.syntax.apply._ +import com.typesafe.config.Config +import common.validation.Validation._ +import net.ceedubs.ficus.Ficus._ + +import java.util.UUID + +// WSM config is needed for accessing WSM-managed blob containers created in Terra workspaces. +// If the identity executing Cromwell has native access to the blob container, this can be ignored. +final case class WorkspaceManagerConfig(url: WorkspaceManagerURL, + workspaceId: WorkspaceId, + containerResourceId: ContainerResourceId, + overrideWsmAuthToken: Option[String]) // dev-only + +final case class BlobFileSystemConfig(endpointURL: EndpointURL, + blobContainerName: BlobContainerName, + subscriptionId: Option[SubscriptionId], + expiryBufferMinutes: Long, + workspaceManagerConfig: Option[WorkspaceManagerConfig]) + +object BlobFileSystemConfig { + + final val defaultExpiryBufferMinutes = 10L + + def apply(config: Config): BlobFileSystemConfig = { + val endpointURL = parseString(config, "endpoint").map(EndpointURL) + val blobContainer = parseString(config, "container").map(BlobContainerName) + val subscriptionId = parseUUIDOpt(config, "subscription").map(_.map(SubscriptionId)) + val expiryBufferMinutes = + parseLongOpt(config, "expiry-buffer-minutes") + .map(_.getOrElse(defaultExpiryBufferMinutes)) + + val wsmConfig = + if (config.hasPath("workspace-manager")) { + val wsmConf = config.getConfig("workspace-manager") + val wsmURL = parseString(wsmConf, "url").map(WorkspaceManagerURL) + val workspaceId = parseUUID(wsmConf, "workspace-id").map(WorkspaceId) + val containerResourceId = parseUUID(wsmConf, "container-resource-id").map(ContainerResourceId) + val overrideWsmAuthToken = parseStringOpt(wsmConf, "b2cToken") + + (wsmURL, workspaceId, containerResourceId, overrideWsmAuthToken) + .mapN(WorkspaceManagerConfig) + .map(Option(_)) + } + else None.validNel + + (endpointURL, blobContainer, subscriptionId, expiryBufferMinutes, wsmConfig) + .mapN(BlobFileSystemConfig.apply) + .unsafe("Couldn't parse blob filesystem config") + } + + private def parseString(config: Config, path: String) = + validate[String] { config.as[String](path) } + + private def parseStringOpt(config: Config, path: String) = + validate[Option[String]] { config.as[Option[String]](path) } + + private def parseUUID(config: Config, path: String) = + validate[UUID] { UUID.fromString(config.as[String](path)) } + + private def parseUUIDOpt(config: Config, path: String) = + validate[Option[UUID]] { config.as[Option[String]](path).map(UUID.fromString) } + + private def parseLongOpt(config: Config, path: String) = + validate[Option[Long]] { config.as[Option[Long]](path) } +} + +// Our filesystem setup magic can't use BlobFileSystemConfig.apply directly, so we need this +// wrapper class. +class BlobFileSystemConfigWrapper(val config: BlobFileSystemConfig) { + def this(config: Config) = this(BlobFileSystemConfig(config)) +} diff --git a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemManager.scala b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemManager.scala index 877cefdfab5..a184729c38b 100644 --- a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemManager.scala +++ b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobFileSystemManager.scala @@ -5,10 +5,13 @@ import com.azure.core.management.AzureEnvironment import com.azure.core.management.profile.AzureProfile import com.azure.identity.DefaultAzureCredentialBuilder import com.azure.resourcemanager.AzureResourceManager +import com.azure.resourcemanager.storage.models.StorageAccountKey import com.azure.storage.blob.nio.AzureFileSystem import com.azure.storage.blob.sas.{BlobContainerSasPermission, BlobServiceSasSignatureValues} import com.azure.storage.blob.{BlobContainerClient, BlobContainerClientBuilder} import com.azure.storage.common.StorageSharedKeyCredential +import com.typesafe.scalalogging.LazyLogging +import common.validation.Validation._ import java.net.URI import java.nio.file.{FileSystem, FileSystemNotFoundException, FileSystems} @@ -16,17 +19,18 @@ import java.time.temporal.ChronoUnit import java.time.{Duration, Instant, OffsetDateTime} import scala.jdk.CollectionConverters._ import scala.util.{Failure, Success, Try} -import com.azure.resourcemanager.storage.models.StorageAccountKey -import com.typesafe.scalalogging.LazyLogging - -import java.util.UUID case class FileSystemAPI() { def getFileSystem(uri: URI): Try[FileSystem] = Try(FileSystems.getFileSystem(uri)) def newFileSystem(uri: URI, config: Map[String, Object]): FileSystem = FileSystems.newFileSystem(uri, config.asJava) def closeFileSystem(uri: URI): Option[Unit] = getFileSystem(uri).toOption.map(_.close) } - +/** + * The BlobFileSystemManager is an object that is responsible for managing the open filesystem, + * and refreshing the SAS token that is used to access the blob container containing that filesystem. + * + * See BlobSasTokenGenerator for more information on how a SAS token is generated + */ object BlobFileSystemManager { def parseTokenExpiry(token: AzureSasCredential): Option[Instant] = for { expiryString <- token.getSignature.split("&").find(_.startsWith("se")).map(_.replaceFirst("se=","")).map(_.replace("%3A", ":")) @@ -45,7 +49,7 @@ case class BlobFileSystemManager( container: BlobContainerName, endpoint: EndpointURL, expiryBufferMinutes: Long, - blobTokenGenerator: BlobTokenGenerator, + blobTokenGenerator: BlobSasTokenGenerator, fileSystemAPI: FileSystemAPI = FileSystemAPI(), private val initialExpiration: Option[Instant] = None) extends LazyLogging { private var expiry: Option[Instant] = initialExpiration @@ -62,13 +66,13 @@ case class BlobFileSystemManager( // If no filesystem already exists, this will create a new connection, with the provided configs case _: FileSystemNotFoundException => logger.info(s"Creating new blob filesystem for URI $uri") - blobTokenGenerator.generateAccessToken.flatMap(generateFilesystem(uri, container, _)) + blobTokenGenerator.generateBlobSasToken.flatMap(generateFilesystem(uri, container, _)) } // If the token has expired, OR there is no token record, try to close the FS and regenerate case true => logger.info(s"Closing & regenerating token for existing blob filesystem at URI $uri") fileSystemAPI.closeFileSystem(uri) - blobTokenGenerator.generateAccessToken.flatMap(generateFilesystem(uri, container, _)) + blobTokenGenerator.generateBlobSasToken.flatMap(generateFilesystem(uri, container, _)) } } } @@ -81,44 +85,96 @@ case class BlobFileSystemManager( } -sealed trait BlobTokenGenerator {def generateAccessToken: Try[AzureSasCredential]} -object BlobTokenGenerator { - def createBlobTokenGenerator(container: BlobContainerName, endpoint: EndpointURL, subscription: Option[SubscriptionId]): BlobTokenGenerator = { - NativeBlobTokenGenerator(container, endpoint, subscription) +sealed trait BlobSasTokenGenerator { def generateBlobSasToken: Try[AzureSasCredential] } +object BlobSasTokenGenerator { + /** + * Native SAS token generator, uses the DefaultAzureCredentialBuilder in the local environment + * to produce a SAS token. + * + * @param container The BlobContainerName of the blob container to be accessed by the generated SAS token + * @param endpoint The EndpointURL containing the storage account of the blob container to be accessed by + * this SAS token + * @param subscription Optional subscription parameter to use for local authorization. + * If one is not provided the default subscription is used + * @return A NativeBlobTokenGenerator, able to produce a valid SAS token for accessing the provided blob + * container and endpoint locally + */ + def createBlobTokenGenerator(container: BlobContainerName, + endpoint: EndpointURL, + subscription: Option[SubscriptionId]): BlobSasTokenGenerator = { + NativeBlobSasTokenGenerator(container, endpoint, subscription) } - def createBlobTokenGenerator(container: BlobContainerName, endpoint: EndpointURL, workspaceId: WorkspaceId, workspaceManagerClient: WorkspaceManagerApiClientProvider): BlobTokenGenerator = { - WSMBlobTokenGenerator(container, endpoint, workspaceId, workspaceManagerClient) + + /** + * WSM-mediated SAS token generator, uses the DefaultAzureCredentialBuilder in the cloud environment + * to request a SAS token from the WSM to access the given blob container. If an overrideWsmAuthToken + * is provided this is used instead. + * + * @param container The BlobContainerName of the blob container to be accessed by the generated SAS token + * @param endpoint The EndpointURL containing the storage account of the blob container to be accessed by + * this SAS token + * @param workspaceId The WorkspaceId of the account to authenticate against + * @param containerResourceId The ContainterResourceId of the blob container as WSM knows it + * @param workspaceManagerClient The client for making requests against WSM + * @param overrideWsmAuthToken An optional WsmAuthToken used for authenticating against the WSM for a valid + * SAS token to access the given container and endpoint. This is a dev only option that is only intended + * for local testing of the WSM interface + * @return A WSMBlobTokenGenerator, able to produce a valid SAS token for accessing the provided blob + * container and endpoint that is managed by WSM + */ + def createBlobTokenGenerator(container: BlobContainerName, + endpoint: EndpointURL, + workspaceId: WorkspaceId, + containerResourceId: ContainerResourceId, + workspaceManagerClient: WorkspaceManagerApiClientProvider, + overrideWsmAuthToken: Option[String]): BlobSasTokenGenerator = { + WSMBlobSasTokenGenerator(container, endpoint, workspaceId, containerResourceId, workspaceManagerClient, overrideWsmAuthToken) } } -case class WSMBlobTokenGenerator( - container: BlobContainerName, - endpoint: EndpointURL, - workspaceId: WorkspaceId, - wsmClient: WorkspaceManagerApiClientProvider) extends BlobTokenGenerator { - - def generateAccessToken: Try[AzureSasCredential] = Try { - val token = wsmClient.getControlledAzureResourceApi.createAzureStorageContainerSasToken( - UUID.fromString(workspaceId.value), - UUID.fromString("00001111-2222-3333-aaaa-bbbbccccdddd"), - null, - null, - null, - null - ).getToken // TODO `null` items may be required, investigate in WX-696 - - new AzureSasCredential(token) // TODO Does `signature` actually mean token? save for WX-696 +case class WSMBlobSasTokenGenerator(container: BlobContainerName, + endpoint: EndpointURL, + workspaceId: WorkspaceId, + containerResourceId: ContainerResourceId, + wsmClientProvider: WorkspaceManagerApiClientProvider, + overrideWsmAuthToken: Option[String]) extends BlobSasTokenGenerator { + + /** + * Generate a BlobSasToken by using the available authorization information + * If an overrideWsmAuthToken is provided, use this in the wsmClient request + * Else try to use the environment azure identity to request the SAS token + * + * @return an AzureSasCredential for accessing a blob container + */ + def generateBlobSasToken: Try[AzureSasCredential] = { + val wsmAuthToken: Try[String] = overrideWsmAuthToken match { + case Some(t) => Success(t) + case None => AzureCredentials.getAccessToken(None).toTry + } + + for { + wsmAuth <- wsmAuthToken + wsmClient = wsmClientProvider.getControlledAzureResourceApi(wsmAuth) + sasToken <- Try( // Java library throws + wsmClient.createAzureStorageContainerSasToken( + workspaceId.value, + containerResourceId.value, + null, + null, + null, + null + ).getToken) + } yield new AzureSasCredential(sasToken) } } -case class NativeBlobTokenGenerator(container: BlobContainerName, endpoint: EndpointURL, subscription: Option[SubscriptionId] = None) extends BlobTokenGenerator { - +case class NativeBlobSasTokenGenerator(container: BlobContainerName, endpoint: EndpointURL, subscription: Option[SubscriptionId] = None) extends BlobSasTokenGenerator { private val azureProfile = new AzureProfile(AzureEnvironment.AZURE) private def azureCredentialBuilder = new DefaultAzureCredentialBuilder() .authorityHost(azureProfile.getEnvironment.getActiveDirectoryEndpoint) .build - private def authenticateWithSubscription(sub: SubscriptionId) = AzureResourceManager.authenticate(azureCredentialBuilder, azureProfile).withSubscription(sub.value) + private def authenticateWithSubscription(sub: SubscriptionId) = AzureResourceManager.authenticate(azureCredentialBuilder, azureProfile).withSubscription(sub.toString) private def authenticateWithDefaultSubscription = AzureResourceManager.authenticate(azureCredentialBuilder, azureProfile).withDefaultSubscription() private def azure = subscription.map(authenticateWithSubscription(_)).getOrElse(authenticateWithDefaultSubscription) @@ -137,8 +193,13 @@ case class NativeBlobTokenGenerator(container: BlobContainerName, endpoint: Endp .setListPermission(true) .setWritePermission(true) - - def generateAccessToken: Try[AzureSasCredential] = for { + /** + * Generate a BlobSasToken by using the local environment azure identity + * This will use a default subscription if one is not provided. + * + * @return an AzureSasCredential for accessing a blob container + */ + def generateBlobSasToken: Try[AzureSasCredential] = for { uri <- BlobPathBuilder.parseURI(endpoint.value) configuredAccount <- BlobPathBuilder.parseStorageAccount(uri) azureAccount <- findAzureStorageAccount(configuredAccount) diff --git a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilderFactory.scala b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilderFactory.scala index e0f7c992bb6..6e8f84eb0d4 100644 --- a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilderFactory.scala +++ b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilderFactory.scala @@ -5,37 +5,49 @@ import com.typesafe.config.Config import cromwell.core.WorkflowOptions import cromwell.core.path.PathBuilderFactory import cromwell.core.path.PathBuilderFactory.PriorityBlob -import net.ceedubs.ficus.Ficus._ +import java.util.UUID import scala.concurrent.{ExecutionContext, Future} -final case class BlobFileSystemConfig(config: Config) - -final case class SubscriptionId(value: String) {override def toString: String = value} +final case class SubscriptionId(value: UUID) {override def toString: String = value.toString} final case class BlobContainerName(value: String) {override def toString: String = value} final case class StorageAccountName(value: String) {override def toString: String = value} final case class EndpointURL(value: String) {override def toString: String = value} -final case class WorkspaceId(value: String) {override def toString: String = value} +final case class WorkspaceId(value: UUID) {override def toString: String = value.toString} +final case class ContainerResourceId(value: UUID) {override def toString: String = value.toString} final case class WorkspaceManagerURL(value: String) {override def toString: String = value} -final case class BlobPathBuilderFactory(globalConfig: Config, instanceConfig: Config, singletonConfig: BlobFileSystemConfig) extends PathBuilderFactory { - val subscription: Option[SubscriptionId] = instanceConfig.as[Option[String]]("subscription").map(SubscriptionId) - val container: BlobContainerName = BlobContainerName(instanceConfig.as[String]("container")) - val endpoint: EndpointURL = EndpointURL(instanceConfig.as[String]("endpoint")) - val workspaceId: Option[WorkspaceId] = instanceConfig.as[Option[String]]("workspace-id").map(WorkspaceId) - val expiryBufferMinutes: Long = instanceConfig.as[Option[Long]]("expiry-buffer-minutes").getOrElse(10) - val workspaceManagerURL: Option[WorkspaceManagerURL] = singletonConfig.config.as[Option[String]]("workspace-manager-url").map(WorkspaceManagerURL) - val b2cToken: Option[String] = instanceConfig.as[Option[String]]("b2cToken") - - val blobTokenGenerator: BlobTokenGenerator = (workspaceManagerURL, b2cToken, workspaceId) match { - case (Some(url), Some(token), Some(workspaceId)) => - val wsmClient: WorkspaceManagerApiClientProvider = new HttpWorkspaceManagerClientProvider(url, token) - // parameterizing client instead of URL to make injecting mock client possible - BlobTokenGenerator.createBlobTokenGenerator(container, endpoint, workspaceId, wsmClient) - case _ => - BlobTokenGenerator.createBlobTokenGenerator(container, endpoint, subscription) - } - val fsm: BlobFileSystemManager = BlobFileSystemManager(container, endpoint, expiryBufferMinutes, blobTokenGenerator) +final case class BlobPathBuilderFactory(globalConfig: Config, instanceConfig: Config, singletonConfig: BlobFileSystemConfigWrapper) extends PathBuilderFactory { + + private val config = singletonConfig.config + private val container = config.blobContainerName + private val endpoint = config.endpointURL + private val subscription = config.subscriptionId + private val expiryBufferMinutes = config.expiryBufferMinutes + + /** + * This generator is responsible for producing a valid SAS token for use in accessing a Azure blob storage container + * Two types of generators can be produced here: + * > Workspace Manager (WSM) mediated SAS token generator, used to create SAS tokens that allow access for + * blob containers mediated by the WSM, and is enabled when a WSM config is provided. This is what is intended for use inside Terra + * OR + * > Native SAS token generator, which obtains a valid SAS token from your local environment to reach blob containers + * your local azure identity has access to and is the default if a WSM config is not found. This is intended for use outside of Terra + * + * Both of these generators require an authentication token to authorize the generation of the SAS token. + * See BlobSasTokenGenerator for more information on how these generators work. + */ + val blobSasTokenGenerator: BlobSasTokenGenerator = config.workspaceManagerConfig.map { wsmConfig => + val wsmClient: WorkspaceManagerApiClientProvider = new HttpWorkspaceManagerClientProvider(wsmConfig.url) + // WSM-mediated mediated SAS token generator + // parameterizing client instead of URL to make injecting mock client possible + BlobSasTokenGenerator.createBlobTokenGenerator(container, endpoint, wsmConfig.workspaceId, wsmConfig.containerResourceId, wsmClient, wsmConfig.overrideWsmAuthToken) + }.getOrElse( + // Native SAS token generator + BlobSasTokenGenerator.createBlobTokenGenerator(container, endpoint, subscription) + ) + + val fsm: BlobFileSystemManager = BlobFileSystemManager(container, endpoint, expiryBufferMinutes, blobSasTokenGenerator) override def withOptions(options: WorkflowOptions)(implicit as: ActorSystem, ec: ExecutionContext): Future[BlobPathBuilder] = { Future { diff --git a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/WorkspaceManagerApiClientProvider.scala b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/WorkspaceManagerApiClientProvider.scala index c083d262151..a9f52d92a91 100644 --- a/filesystems/blob/src/main/scala/cromwell/filesystems/blob/WorkspaceManagerApiClientProvider.scala +++ b/filesystems/blob/src/main/scala/cromwell/filesystems/blob/WorkspaceManagerApiClientProvider.scala @@ -4,29 +4,27 @@ import bio.terra.workspace.api.ControlledAzureResourceApi import bio.terra.workspace.client.ApiClient /** - * Represents a way to get various workspace manager clients + * Represents a way to get a client for interacting with workspace manager controlled resources. + * Additional WSM clients can be added here if needed. * * Pared down from `org.broadinstitute.dsde.rawls.dataaccess.workspacemanager.WorkspaceManagerApiClientProvider` * * For testing, create an anonymous subclass as in `org.broadinstitute.dsde.rawls.dataaccess.workspacemanager.HttpWorkspaceManagerDAOSpec` */ trait WorkspaceManagerApiClientProvider { - def getApiClient: ApiClient - - def getControlledAzureResourceApi: ControlledAzureResourceApi - + def getControlledAzureResourceApi(token: String): ControlledAzureResourceApi } -class HttpWorkspaceManagerClientProvider(baseWorkspaceManagerUrl: WorkspaceManagerURL, token: String) extends WorkspaceManagerApiClientProvider { - def getApiClient: ApiClient = { +class HttpWorkspaceManagerClientProvider(baseWorkspaceManagerUrl: WorkspaceManagerURL) extends WorkspaceManagerApiClientProvider { + private def getApiClient: ApiClient = { val client: ApiClient = new ApiClient() client.setBasePath(baseWorkspaceManagerUrl.value) - client.setAccessToken(token) - client } - def getControlledAzureResourceApi: ControlledAzureResourceApi = - new ControlledAzureResourceApi(getApiClient) - + def getControlledAzureResourceApi(token: String): ControlledAzureResourceApi = { + val apiClient = getApiClient + apiClient.setAccessToken(token) + new ControlledAzureResourceApi(apiClient) + } } diff --git a/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemConfigSpec.scala b/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemConfigSpec.scala new file mode 100644 index 00000000000..607ad5606f7 --- /dev/null +++ b/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobFileSystemConfigSpec.scala @@ -0,0 +1,75 @@ +package cromwell.filesystems.blob + +import com.typesafe.config.ConfigFactory +import common.exception.AggregatedMessageException +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.util.UUID + +class BlobFileSystemConfigSpec extends AnyFlatSpec with Matchers { + + private val endpoint = BlobPathBuilderSpec.buildEndpoint("storageAccount") + private val container = BlobContainerName("storageContainer") + private val workspaceId = WorkspaceId(UUID.fromString("B0BAFE77-0000-0000-0000-000000000000")) + private val containerResourceId = ContainerResourceId(UUID.fromString("F00B4911-0000-0000-0000-000000000000")) + private val workspaceManagerURL = WorkspaceManagerURL("https://wsm.example.com") + private val b2cToken = "b0gus-t0ken" + + it should "parse configs for a minimal functioning factory with native blob access" in { + val config = BlobFileSystemConfig( + ConfigFactory.parseString( + s""" + |container = "$container" + |endpoint = "$endpoint" + """.stripMargin) + ) + config.blobContainerName should equal(container) + config.endpointURL should equal(endpoint) + config.expiryBufferMinutes should equal(BlobFileSystemConfig.defaultExpiryBufferMinutes) + } + + it should "parse configs for a functioning factory with WSM-mediated blob access" in { + val config = BlobFileSystemConfig( + ConfigFactory.parseString( + s""" + |container = "$container" + |endpoint = "$endpoint" + |expiry-buffer-minutes = "20" + |workspace-manager { + | url = "$workspaceManagerURL" + | workspace-id = "$workspaceId" + | container-resource-id = "$containerResourceId" + | b2cToken = "$b2cToken" + |} + | + """.stripMargin) + ) + config.blobContainerName should equal(container) + config.endpointURL should equal(endpoint) + config.expiryBufferMinutes should equal(20L) + config.workspaceManagerConfig.isDefined shouldBe true + config.workspaceManagerConfig.get.url shouldBe workspaceManagerURL + config.workspaceManagerConfig.get.workspaceId shouldBe workspaceId + config.workspaceManagerConfig.get.containerResourceId shouldBe containerResourceId + config.workspaceManagerConfig.get.overrideWsmAuthToken.contains(b2cToken) shouldBe true + } + + it should "fail when partial WSM config is supplied" in { + val rawConfig = + ConfigFactory.parseString( + s""" + |container = "$container" + |endpoint = "$endpoint" + |expiry-buffer-minutes = "10" + |workspace-manager { + | url = "$workspaceManagerURL" + | container-resource-id = "$containerResourceId" + |} + | + """.stripMargin) + + val error = intercept[AggregatedMessageException](BlobFileSystemConfig(rawConfig)) + error.getMessage should include("No configuration setting found for key 'workspace-id'") + } +} diff --git a/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderFactorySpec.scala b/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderFactorySpec.scala index 0cad162c860..6bf00d66915 100644 --- a/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderFactorySpec.scala +++ b/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderFactorySpec.scala @@ -1,7 +1,6 @@ package cromwell.filesystems.blob import com.azure.core.credential.AzureSasCredential -import com.typesafe.config.ConfigFactory import common.mock.MockSugar import org.mockito.Mockito._ import org.scalatest.flatspec.AnyFlatSpec @@ -24,28 +23,6 @@ object BlobPathBuilderFactorySpec { } class BlobPathBuilderFactorySpec extends AnyFlatSpec with Matchers with MockSugar { def generateTokenExpiration(minutes: Long) = Instant.now.plus(minutes, ChronoUnit.MINUTES) - it should "parse configs for a functioning factory" in { - val endpoint = BlobPathBuilderSpec.buildEndpoint("storageAccount") - val container = BlobContainerName("storageContainer") - val workspaceId = WorkspaceId("B0BAFE77-0000-0000-0000-000000000000") - val workspaceManagerURL = WorkspaceManagerURL("https://wsm.example.com") - val instanceConfig = ConfigFactory.parseString( - s""" - |container = "$container" - - |endpoint = "$endpoint" - |expiry-buffer-minutes = "10" - |workspace-id = "$workspaceId" - """.stripMargin) - val singletonConfig = ConfigFactory.parseString(s"""workspace-manager-url = "$workspaceManagerURL" """) - val globalConfig = ConfigFactory.parseString("""""") - val factory = BlobPathBuilderFactory(globalConfig, instanceConfig, new BlobFileSystemConfig(singletonConfig)) - factory.container should equal(container) - factory.endpoint should equal(endpoint) - factory.expiryBufferMinutes should equal(10L) - factory.workspaceId should contain(workspaceId) - factory.workspaceManagerURL should contain(workspaceManagerURL) - } it should "build an example sas token of the correct format" in { val testToken = BlobPathBuilderFactorySpec.buildExampleSasToken(Instant.ofEpochMilli(1603794041000L)) @@ -94,8 +71,8 @@ class BlobPathBuilderFactorySpec extends AnyFlatSpec with Matchers with MockSuga val azureUri = BlobFileSystemManager.uri(endpoint) val fileSystems = mock[FileSystemAPI] - val blobTokenGenerator = mock[BlobTokenGenerator] - when(blobTokenGenerator.generateAccessToken).thenReturn(Try(sasToken)) + val blobTokenGenerator = mock[BlobSasTokenGenerator] + when(blobTokenGenerator.generateBlobSasToken).thenReturn(Try(sasToken)) val fsm = BlobFileSystemManager(container, endpoint, 10L, blobTokenGenerator, fileSystems, Some(expiredToken)) fsm.getExpiry should contain(expiredToken) @@ -123,8 +100,8 @@ class BlobPathBuilderFactorySpec extends AnyFlatSpec with Matchers with MockSuga val fileSystems = mock[FileSystemAPI] when(fileSystems.getFileSystem(azureUri)).thenReturn(Try(dummyFileSystem)) - val blobTokenGenerator = mock[BlobTokenGenerator] - when(blobTokenGenerator.generateAccessToken).thenReturn(Try(sasToken)) + val blobTokenGenerator = mock[BlobSasTokenGenerator] + when(blobTokenGenerator.generateBlobSasToken).thenReturn(Try(sasToken)) val fsm = BlobFileSystemManager(container, endpoint, 10L, blobTokenGenerator, fileSystems, Some(initialToken)) fsm.getExpiry should contain(initialToken) @@ -148,8 +125,8 @@ class BlobPathBuilderFactorySpec extends AnyFlatSpec with Matchers with MockSuga val fileSystems = mock[FileSystemAPI] when(fileSystems.getFileSystem(azureUri)).thenReturn(Failure(new FileSystemNotFoundException)) - val blobTokenGenerator = mock[BlobTokenGenerator] - when(blobTokenGenerator.generateAccessToken).thenReturn(Try(sasToken)) + val blobTokenGenerator = mock[BlobSasTokenGenerator] + when(blobTokenGenerator.generateBlobSasToken).thenReturn(Try(sasToken)) val fsm = BlobFileSystemManager(container, endpoint, 10L, blobTokenGenerator, fileSystems, Some(refreshedToken)) fsm.getExpiry.isDefined shouldBe true @@ -172,8 +149,8 @@ class BlobPathBuilderFactorySpec extends AnyFlatSpec with Matchers with MockSuga val azureUri = BlobFileSystemManager.uri(endpoint) val fileSystems = mock[FileSystemAPI] - val blobTokenGenerator = mock[BlobTokenGenerator] - when(blobTokenGenerator.generateAccessToken).thenReturn(Try(sasToken)) + val blobTokenGenerator = mock[BlobSasTokenGenerator] + when(blobTokenGenerator.generateBlobSasToken).thenReturn(Try(sasToken)) val fsm = BlobFileSystemManager(container, endpoint, 10L, blobTokenGenerator, fileSystems) fsm.getExpiry.isDefined shouldBe false diff --git a/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderSpec.scala b/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderSpec.scala index 671e7b2d35b..b20e2e2fe96 100644 --- a/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderSpec.scala +++ b/filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderSpec.scala @@ -62,7 +62,7 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar { val endpointHost = BlobPathBuilder.parseURI(endpoint.value).map(_.getHost).getOrElse(fail("Could not parse URI")) val store = BlobContainerName("inputs") val evalPath = "/test/inputFile.txt" - val blobTokenGenerator = NativeBlobTokenGenerator(store, endpoint) + val blobTokenGenerator = NativeBlobSasTokenGenerator(store, endpoint) val fsm: BlobFileSystemManager = BlobFileSystemManager(store, endpoint, 10L, blobTokenGenerator) val testString = endpoint.value + "/" + store + evalPath val blobPath: BlobPath = new BlobPathBuilder(store, endpoint)(fsm) build testString getOrElse fail() @@ -80,7 +80,7 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar { val endpoint = BlobPathBuilderSpec.buildEndpoint("coaexternalstorage") val store = BlobContainerName("inputs") val evalPath = "/test/inputFile.txt" - val blobTokenGenerator = NativeBlobTokenGenerator(store, endpoint) + val blobTokenGenerator = NativeBlobSasTokenGenerator(store, endpoint) val fsm: BlobFileSystemManager = BlobFileSystemManager(store, endpoint, 10, blobTokenGenerator) val testString = endpoint.value + "/" + store + evalPath val blobPath1: BlobPath = new BlobPathBuilder(store, endpoint)(fsm) build testString getOrElse fail() @@ -95,7 +95,7 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar { ignore should "resolve a path without duplicating container name" in { val endpoint = BlobPathBuilderSpec.buildEndpoint("coaexternalstorage") val store = BlobContainerName("inputs") - val blobTokenGenerator = NativeBlobTokenGenerator(store, endpoint) + val blobTokenGenerator = NativeBlobSasTokenGenerator(store, endpoint) val fsm: BlobFileSystemManager = BlobFileSystemManager(store, endpoint, 10, blobTokenGenerator) val rootString = s"${endpoint.value}/${store.value}/cromwell-execution" diff --git a/src/ci/resources/tes_application.conf b/src/ci/resources/tes_application.conf index e6595c8e941..9914bd54dff 100644 --- a/src/ci/resources/tes_application.conf +++ b/src/ci/resources/tes_application.conf @@ -5,9 +5,19 @@ filesystems { blob { class = "cromwell.filesystems.blob.BlobPathBuilderFactory" global { - class = "cromwell.filesystems.blob.BlobFileSystemConfig" + class = "cromwell.filesystems.blob.BlobFileSystemConfigWrapper" config { - workspace-manager-url: "https://workspace.dsde-dev.broadinstitute.org" + container: "cromwell" + endpoint: "https://.blob.core.windows.net" + subscription: "00001111-2222-3333-aaaa-bbbbccccdddd" + # WSM config is needed for accessing WSM-managed blob containers + # created in Terra workspaces. + workspace-manager { + url: "https://workspace.dsde-dev.broadinstitute.org" + workspace-id: "00001111-2222-3333-aaaa-bbbbccccdddd" + container-resource-id: "00001111-2222-3333-aaaa-bbbbccccdddd" + b2cToken: "Zardoz" + } } } } @@ -23,11 +33,6 @@ engine { } blob { enabled: false - container: "cromwell" - endpoint: "https://.blob.core.windows.net" - subscription: "00001111-2222-3333-aaaa-bbbbccccdddd" - workspace-id: "00001111-2222-3333-aaaa-bbbbccccdddd" - b2cToken: "Zardoz" } } } @@ -49,11 +54,6 @@ backend { filesystems { blob { enabled: false - container: "cromwell" - endpoint: "https://.blob.core.windows.net" - subscription: "00001111-2222-3333-aaaa-bbbbccccdddd" - workspace-id: "00001111-2222-3333-aaaa-bbbbccccdddd" - b2cToken: "Zardoz" } local { enabled: true