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

[TOAZ-355] [TOAZ-356] Use Managed Identity auth when running Azure Control Plane, support for Service Catalog deployed Azure Managed Apps #1451

Merged
merged 16 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 env/local.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export ADMIN_SERVICE_ACCOUNT_4="src/main/resources/rendered/admin-service-accoun
export ADMIN_SERVICE_ACCOUNT_5="src/main/resources/rendered/admin-service-account-5.json"
export SERVICE_ACCOUNT_ADMINS="[email protected], [email protected]"
export AZURE_ENABLED="false"
export AZURE_SERVICE_CATALOG_APPS_ENABLED="false"
export AZURE_MANAGED_APP_WORKLOAD_CLIENT_ID="661e243c-5ef9-4a9c-9be3-b7f5585828b3"
export EMAIL_DOMAIN="dev.test.firecloud.org"
export ENVIRONMENT="dev"
export GOOGLE_APPS_DOMAIN="test.firecloud.org"
Expand Down
4 changes: 4 additions & 0 deletions env/test.env
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
export SERVICE_ACCOUNT_ADMINS="[email protected], [email protected]"
export AZURE_ENABLED="false"
export CREATE_BEE_WORKFLOW_TEST="true"
export AZURE_MANAGED_APP_CLIENT_ID="foo"
export AZURE_MANAGED_APP_CLIENT_SECRET="foo"
export AZURE_MANAGED_APP_TENANT_ID="foo"
export AZURE_MANAGED_APP_WORKLOAD_CLIENT_ID="foo"
export AZURE_ALLOW_MANAGED_IDENTITY_USER_CREATION="false"
export AZURE_SERVICE_CATALOG_APPS_ENABLED="false"
export EMAIL_DOMAIN="dev.test.firecloud.org"
export ENVIRONMENT="local"
export GOOGLE_APPS_DOMAIN="test.firecloud.org"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ object MockTestSupport extends MockTestSupport {
val googleServicesConfig: GoogleServicesConfig = appConfig.googleConfig.get.googleServicesConfig
val configResourceTypes: Map[ResourceTypeName, ResourceType] = config.as[Map[String, ResourceType]]("resourceTypes").values.map(rt => rt.name -> rt).toMap
val adminConfig: AdminConfig = config.as[AdminConfig]("admin")
val azureServicesConfig: Option[AzureServicesConfig] = appConfig.azureServicesConfig
val databaseEnabled: Boolean = config.getBoolean("db.enabled")
val databaseEnabledClue = "-- skipping tests that talk to a real database"

Expand Down Expand Up @@ -141,7 +142,11 @@ object MockTestSupport extends MockTestSupport {
val mockManagedGroupService =
new ManagedGroupService(mockResourceService, policyEvaluatorService, resourceTypes, policyDAO, directoryDAO, googleExt, "example.com")
val tosService = new TosService(googleExt, directoryDAO, tosConfig)
val azureService = new AzureService(MockCrlService(), directoryDAO, new MockAzureManagedResourceGroupDAO)

val azureService = azureServicesConfig.map { azureConfig =>
new AzureService(azureConfig, MockCrlService(), directoryDAO, new MockAzureManagedResourceGroupDAO)
}

MockSamDependencies(
mockResourceService,
policyEvaluatorService,
Expand All @@ -153,7 +158,7 @@ object MockTestSupport extends MockTestSupport {
policyDAO,
googleExt,
FakeOpenIDConnectConfiguration,
azureService
azureService:Option[AzureService]
)
}

Expand All @@ -173,7 +178,7 @@ object MockTestSupport extends MockTestSupport {
samDependencies.tosService,
LiquibaseConfig("", initWithLiquibase = false),
samDependencies.oauth2Config,
Some(samDependencies.azureService)
samDependencies.azureService
) with MockSamUserDirectives with GoogleExtensionRoutes {
override val cloudExtensions: CloudExtensions = samDependencies.cloudExtensions
override val googleExtensions: GoogleExtensions = samDependencies.cloudExtensions match {
Expand Down Expand Up @@ -287,7 +292,7 @@ final case class MockSamDependencies(
policyDao: AccessPolicyDAO,
cloudExtensions: CloudExtensions,
oauth2Config: OpenIDConnectConfiguration,
azureService: AzureService
azureService: Option[AzureService]
)

object ConnectedTest extends Tag("connected test")
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ class SamProviderSpec
accessPolicyDAO,
googleExt,
FakeOpenIDConnectConfiguration,
azureService
Option(azureService)
)

override def beforeAll(): Unit = {
Expand Down
48 changes: 31 additions & 17 deletions src/main/resources/sam.conf
Original file line number Diff line number Diff line change
Expand Up @@ -184,43 +184,57 @@ admin {
azureServices {
azureEnabled = ${?AZURE_ENABLED}
allowManagedIdentityUserCreation = ${?AZURE_ALLOW_MANAGED_IDENTITY_USER_CREATION}
managedAppClientId = ${?AZURE_MANAGED_APP_CLIENT_ID}
managedAppClientSecret = ${?AZURE_MANAGED_APP_CLIENT_SECRET}
managedAppTenantId = ${?AZURE_MANAGED_APP_TENANT_ID}
managedAppPlans = [
{
managedAppServicePrincipal {
clientId = ${?AZURE_MANAGED_APP_CLIENT_ID}
clientSecret = ${?AZURE_MANAGED_APP_CLIENT_SECRET}
tenantId = ${?AZURE_MANAGED_APP_TENANT_ID}
}
managedAppWorkloadClientId = ${?AZURE_MANAGED_APP_WORKLOAD_CLIENT_ID}

azureServiceCatalog {
enabled = ${?AZURE_SERVICE_CATALOG_ENABLED} # defaults to false
authorizedUserKey = "authorizedTerraUser";
managedAppTypeServiceCatalog = "ServiceCatalog";
}

azureMarketPlace {
enabled = ${?AZURE_MARKET_PLACE_ENABLED} # defaults to true
managedAppPlans = [
{
name = "terra-prod"
publisher = "thebroadinstituteinc1615909626976"
authorizedUserKey = authorizedTerraUser
}
{
}
{
name = "terra-dev"
publisher = "thebroadinstituteinc1615909626976"
authorizedUserKey = authorizedTerraUser
}
{
}
{
name = "terra-workspace-dev-plan"
publisher = "thebroadinstituteinc1615909626976"
authorizedUserKey = authorizedTerraUser
}
{
}
{
name = "terra-aster-prod"
publisher = "thebroadinstituteinc1615909626976"
authorizedUserKey = "authorizedTerraUser"
}
{
}
{
name = "tdr-dev"
publisher = "thebroadinstituteinc1615909626976"
authorizedUserKey = "authorizedTDRUser"
}
{
}
{
name = "tdr-prod"
publisher = "thebroadinstituteinc1615909626976"
authorizedUserKey = "authorizedTDRUser"
}
]
}
]
}
}


janitor {
enabled = ${?JANITOR_ENABLED}
clientCredentialFilePath = ${?JANITOR_CLIENT_CREDENTIAL_FILE_PATH}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ object Boot extends IOApp with LazyLogging {
val resourceTypeMap = config.resourceTypes.map(rt => rt.name -> rt).toMap
val policyEvaluatorService = PolicyEvaluatorService(config.emailDomain, resourceTypeMap, accessPolicyDAO, directoryDAO)
val azureService = config.azureServicesConfig.map { azureConfig =>
new AzureService(new CrlService(azureConfig, config.janitorConfig), directoryDAO, azureManagedResourceGroupDAO)
new AzureService(azureConfig, new CrlService(azureConfig, config.janitorConfig), directoryDAO, azureManagedResourceGroupDAO)
}
val resourceService = new ResourceService(
resourceTypeMap,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package org.broadinstitute.dsde.workbench.sam.azure
import akka.http.scaladsl.model.StatusCodes
import bio.terra.cloudres.azure.resourcemanager.common.Defaults
import bio.terra.cloudres.azure.resourcemanager.msi.data.CreateUserAssignedManagedIdentityRequestData
import cats.data.OptionT
import cats.effect.IO
import cats.implicits.toTraverseOps
import com.azure.core.management.Region
Expand All @@ -13,7 +12,7 @@ import com.azure.resourcemanager.resources.ResourceManager
import com.azure.resourcemanager.resources.models.ResourceGroup
import org.broadinstitute.dsde.workbench.model.{ErrorReport, WorkbenchEmail, WorkbenchException, WorkbenchExceptionWithErrorReport, WorkbenchUserId}
import org.broadinstitute.dsde.workbench.sam._
import org.broadinstitute.dsde.workbench.sam.config.ManagedAppPlan
import org.broadinstitute.dsde.workbench.sam.config.{AzureMarketPlace, AzureServiceCatalog, AzureServicesConfig, ManagedAppPlan}
import org.broadinstitute.dsde.workbench.sam.dataAccess.{AzureManagedResourceGroupDAO, DirectoryDAO}
import org.broadinstitute.dsde.workbench.sam.model.{FullyQualifiedResourceId, ResourceAction}
import org.broadinstitute.dsde.workbench.sam.model.api.SamUser
Expand All @@ -23,6 +22,7 @@ import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import scala.jdk.CollectionConverters._

class AzureService(
config: AzureServicesConfig,
crlService: CrlService,
directoryDAO: DirectoryDAO,
azureManagedResourceGroupDAO: AzureManagedResourceGroupDAO
Expand Down Expand Up @@ -228,31 +228,41 @@ class AzureService(
/** Resolves a managed resource group in Azure and returns the terra.billingProfileId tag value. This is used for access control checks during route handling.
*/
def getBillingProfileId(request: GetOrCreatePetManagedIdentityRequest, samRequestContext: SamRequestContext): IO[Option[BillingProfileId]] =
// get the billing profile id from the database
// if not there, for backwards compatibility, get the billing profile id from a tag on the Azure resource
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this backwards compatibility is no longer needed

OptionT(getBillingProfileIdFromSamDb(request, samRequestContext))
.orElseF(getBillingProfileIdFromAzureTag(request, samRequestContext))
.value
getBillingProfileIdFromSamDb(request, samRequestContext)

private def getBillingProfileIdFromSamDb(request: GetOrCreatePetManagedIdentityRequest, samRequestContext: SamRequestContext): IO[Option[BillingProfileId]] =
for {
maybeMrg <- azureManagedResourceGroupDAO.getManagedResourceGroupByCoordinates(request.toManagedResourceGroupCoordinates, samRequestContext)
} yield maybeMrg.map(_.billingProfileId)

private def getBillingProfileIdFromAzureTag(
request: GetOrCreatePetManagedIdentityRequest,
samRequestContext: SamRequestContext
): IO[Option[BillingProfileId]] = traceIOWithContext("getBillingProfileIdFromAzureTag", samRequestContext) { _ =>
private def validateManagedResourceGroup(
mrgCoords: ManagedResourceGroupCoordinates,
samRequestContext: SamRequestContext,
validateUser: Boolean = true
): IO[Unit] =
for {
mrg <- validateManagedResourceGroup(request.toManagedResourceGroupCoordinates, samRequestContext, false)
} yield getBillingProfileFromTag(mrg)
}
_ <- IO.raiseWhen(config.azureServiceCatalog.isEmpty && config.azureMarketPlace.isEmpty)(
new WorkbenchException("Either azure service catalog or azure market place must be configured")
)
_ <- config.azureServiceCatalog
.map { serviceCatalog =>
validateServiceCatalogManagedResourceGroup(serviceCatalog, mrgCoords, samRequestContext, validateUser)
}
.getOrElse(IO.unit)

_ <- config.azureMarketPlace
.map { marketPlace =>
validateMarketPlaceManagedResourceGroup(marketPlace, mrgCoords, samRequestContext, validateUser)
}
.getOrElse(IO.unit)
} yield ()

/** Validates a managed resource group. Algorithm:
* 1. Resolve the MRG in Azure 2. Get the managed app id from the MRG 3. Resolve the managed app 4. Get the managed app "plan" name and publisher 5.
* Validate the plan name and publisher matches a configured value 6. Validate that the caller is on the list of authorized users for the app
*/
private def validateManagedResourceGroup(
private def validateMarketPlaceManagedResourceGroup(
marketPlace: AzureMarketPlace,
mrgCoords: ManagedResourceGroupCoordinates,
samRequestContext: SamRequestContext,
validateUser: Boolean = true
Expand All @@ -264,23 +274,51 @@ class AzureService(
appManager <- crlService.buildApplicationManager(mrgCoords.tenantId, mrgCoords.subscriptionId)
appsInSubscription <- IO(appManager.applications().list().asScala.toSeq)
managedApp <- IO.fromOption(appsInSubscription.find(_.managedResourceGroupId() == mrg.id()))(managedAppValidationFailure)
plan <- validatePlan(managedApp, crlService.getManagedAppPlans)
_ <- if (validateUser) validateAuthorizedAppUser(managedApp, plan, samRequestContext) else IO.unit
plan <- validatePlan(managedApp, marketPlace.managedAppPlans)
_ <- if (validateUser) validateAuthorizedAppUser(managedApp, plan.authorizedUserKey, samRequestContext) else IO.unit
} yield mrg
}

/** Validates a managed resource group deployed from Azure Service Catalog. Service Catalog apps do not contain a "plan" or publisher. Algorithm:
* 1. Resolve the MRG in Azure 2. Get the managed app id from the MRG 3. Resolve the managed app 4. Validate the app kind is "ServiceCatalog" 5. Validate
* that the caller is on the list of authorized users for the app
*/
private def validateServiceCatalogManagedResourceGroup(
serviceCatalog: AzureServiceCatalog,
mrgCoords: ManagedResourceGroupCoordinates,
samRequestContext: SamRequestContext,
validateUser: Boolean = true
): IO[ResourceGroup] =
traceIOWithContext("validateServiceCatalogManagedResourceGroup", samRequestContext) { _ =>
for {
resourceManager <- crlService.buildResourceManager(mrgCoords.tenantId, mrgCoords.subscriptionId)
mrg <- lookupMrg(mrgCoords, resourceManager)
appManager <- crlService.buildApplicationManager(mrgCoords.tenantId, mrgCoords.subscriptionId)
appsInSubscription <- IO(appManager.applications().list().asScala)
managedApp <- IO.fromOption(appsInSubscription.find(_.managedResourceGroupId() == mrg.id()))(managedAppValidationFailure)
_ <-
if (managedApp.kind() == serviceCatalog.managedAppTypeServiceCatalog && validateUser) {
validateAuthorizedAppUser(
managedApp,
serviceCatalog.authorizedUserKey,
samRequestContext
)
} else IO.unit
} yield mrg
}

/** The users authorized to setup a managed application are stored as a comma separated list of email addresses in the parameters of the application. The
* azure api is java so this code needs to deal with possible nulls and java Maps. Also the application parameters are untyped, fun.
* @param app
* @param plan
* @param authorizedUserKey
* @param samRequestContext
* @return
*/
private def validateAuthorizedAppUser(app: Application, plan: ManagedAppPlan, samRequestContext: SamRequestContext): IO[Unit] = {
private def validateAuthorizedAppUser(app: Application, authorizedUserKey: String, samRequestContext: SamRequestContext): IO[Unit] = {
val authorizedUsersValue = for {
parametersObj <- Option(app.parameters()) if parametersObj.isInstanceOf[java.util.Map[_, _]]
parametersMap = parametersObj.asInstanceOf[java.util.Map[_, _]]
paramValuesObj <- Option(parametersMap.get(plan.authorizedUserKey)) if paramValuesObj.isInstanceOf[java.util.Map[_, _]]
paramValuesObj <- Option(parametersMap.get(authorizedUserKey)) if paramValuesObj.isInstanceOf[java.util.Map[_, _]]
paramValues = paramValuesObj.asInstanceOf[java.util.Map[_, _]]
authorizedUsersValue <- Option(paramValues.get("value"))
} yield authorizedUsersValue.toString
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import bio.terra.cloudres.azure.resourcemanager.common.Defaults
import bio.terra.cloudres.common.ClientConfig
import bio.terra.cloudres.common.cleanup.CleanupConfig
import cats.effect.IO
import com.azure.core.credential.TokenCredential
import com.azure.core.management.AzureEnvironment
import com.azure.core.management.profile.AzureProfile
import com.azure.identity.{ClientSecretCredential, ClientSecretCredentialBuilder}
import com.azure.identity.{ChainedTokenCredentialBuilder, ClientSecretCredentialBuilder, ManagedIdentityCredentialBuilder}
import com.azure.resourcemanager.managedapplications.ApplicationManager
import com.azure.resourcemanager.msi.MsiManager
import com.azure.resourcemanager.resources.ResourceManager
import com.google.auth.oauth2.ServiceAccountCredentials
import org.broadinstitute.dsde.workbench.sam.config.{AzureServicesConfig, JanitorConfig, ManagedAppPlan}
import org.broadinstitute.dsde.workbench.sam.config.{AzureServicesConfig, JanitorConfig}

import java.io.FileInputStream
import scala.concurrent.duration._
Expand Down Expand Up @@ -57,17 +58,29 @@ class CrlService(config: AzureServicesConfig, janitorConfig: JanitorConfig) {
IO(ApplicationManager.authenticate(credential, profile))
}

def getManagedAppPlans: Seq[ManagedAppPlan] = config.managedAppPlans
private def getCredentialAndProfile(tenantId: TenantId, subscriptionId: SubscriptionId): (TokenCredential, AzureProfile) = {

private def getCredentialAndProfile(tenantId: TenantId, subscriptionId: SubscriptionId): (ClientSecretCredential, AzureProfile) = {
val credential = new ClientSecretCredentialBuilder()
.clientId(config.managedAppClientId)
.clientSecret(config.managedAppClientSecret)
.tenantId(config.managedAppTenantId)
.build
// When an access token is requested, the chain will try each
// credential in order, stopping when one provides a token
//
// For Managed Identity auth, SAM must be deployed to an Azure service
// other platforms will fall through to Service Principal auth
val credential = new ChainedTokenCredentialBuilder()
config.managedAppWorkloadClientId.foreach { workloadClientId =>
credential.addLast(new ManagedIdentityCredentialBuilder().clientId(workloadClientId).build)
}
config.managedAppServicePrincipal.foreach { servicePrincipalConfig =>
credential.addLast(
new ClientSecretCredentialBuilder()
.clientId(servicePrincipalConfig.clientId)
.clientSecret(servicePrincipalConfig.clientSecret)
.tenantId(servicePrincipalConfig.tenantId)
.build
)
}

val profile = new AzureProfile(tenantId.value, subscriptionId.value, AzureEnvironment.AZURE)

(credential, profile)
(credential.build(), profile)
}
}
Loading
Loading