diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 6ee96181d76..3300096aab8 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -21,7 +21,7 @@ jobs: services: mysql: - image: mysql:8.0.40 + image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: leonardo-test MYSQL_USER: leonardo-test diff --git a/Dockerfile b/Dockerfile index 0cc1460437f..50e98a89156 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # 1. Build the Helm client Go lib # 2. Deploy Leonardo pointing to the Go lib -FROM golang:1.20 AS helm-go-lib-builder +FROM golang:1.23 AS helm-go-lib-builder # TODO Consider moving repo set-up to the build script to make CI versioning easier RUN mkdir /helm-go-lib-build && \ @@ -42,7 +42,7 @@ COPY --from=helm-go-lib-builder /helm-go-lib-build/helm-scala-sdk/helm-go-lib /l # Install the Helm3 CLI client using a provided script because installing it via the RHEL package managing didn't work RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 && \ chmod 700 get_helm.sh && \ - ./get_helm.sh --version v3.11.2 && \ + ./get_helm.sh --version v3.15.3 && \ rm get_helm.sh # Add the repos containing nginx, galaxy, setup apps, custom apps, cromwell and aou charts diff --git a/docker/run-mysql.sh b/docker/run-mysql.sh index 4bd2da4d11a..4d268d8bd13 100755 --- a/docker/run-mysql.sh +++ b/docker/run-mysql.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# The CloudSQL console simply states "MySQL 8.0" so we may not match the minor version number -MYSQL_VERSION=8.0.40 +# The CloudSQL console simply states "MySQL 8.4" so we may not match the minor version number +MYSQL_VERSION=8.4 start() { echo "attempting to remove old $CONTAINER container..." diff --git a/http/src/main/resources/leo.conf b/http/src/main/resources/leo.conf index e833d40fd08..da82b4b95bd 100644 --- a/http/src/main/resources/leo.conf +++ b/http/src/main/resources/leo.conf @@ -24,6 +24,9 @@ gce { } gke { + cluster { + version = ${?KUBERNETES_VERSION} + } galaxyApp { postgres.password = ${?GALAXY_POSTGRES_PASSWORD} orchUrl = ${?ORCH_URL} diff --git a/http/src/main/resources/org/broadinstitute/dsde/workbench/leonardo/liquibase/changesets/20170811_label.xml b/http/src/main/resources/org/broadinstitute/dsde/workbench/leonardo/liquibase/changesets/20170811_label.xml index 7fc63823529..2590074f6a5 100644 --- a/http/src/main/resources/org/broadinstitute/dsde/workbench/leonardo/liquibase/changesets/20170811_label.xml +++ b/http/src/main/resources/org/broadinstitute/dsde/workbench/leonardo/liquibase/changesets/20170811_label.xml @@ -1,6 +1,12 @@ + 8:1d8581dc0977ea88b1f006f6bc00f5b9 + + Mysql 8.4+ does not allow partial keys to be referenced for a foreign key anymore, see https://bugs.mysql.com/bug.php?id=114838. + This changeSet has been modified to reflect that; the validCheckSum is the checksum from when the flag did not need to be set because the default was OFF. + + SET restrict_fk_on_non_standard_key = OFF; diff --git a/http/src/main/resources/reference.conf b/http/src/main/resources/reference.conf index b29dfd64deb..b19492de714 100644 --- a/http/src/main/resources/reference.conf +++ b/http/src/main/resources/reference.conf @@ -70,7 +70,7 @@ dataproc { } # Cached dataproc image used by Terra - customDataprocImage = "projects/broad-dsp-gcr-public/global/images/leo-dataproc-image-2-1-11-debian11-2024-12-16-17-22-28" + customDataprocImage = "projects/broad-dsp-gcr-public/global/images/leo-dataproc-image-2-1-11-debian11-2025-01-21-15-07-39" # The ratio of memory allocated to spark. 0.8 = 80%. # Hail/Spark users generally allocate 80% of the ram to the JVM. @@ -723,7 +723,7 @@ gke { "69.173.112.0/21" ] # See https://cloud.google.com/kubernetes-engine/docs/release-notes - version = "1.28" + version = "1.30" nodepoolLockCacheExpiryTime = 1 hour nodepoolLockCacheMaxSize = 200 diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueries.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueries.scala index 80f5179cc76..90622e94706 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueries.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueries.scala @@ -6,7 +6,11 @@ import cats.syntax.all._ import org.broadinstitute.dsde.workbench.azure.AzureCloudContext import org.broadinstitute.dsde.workbench.google2.OperationName import org.broadinstitute.dsde.workbench.leonardo.{LabelMap, Runtime} -import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.RuntimeSamResourceId +import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.{ + ProjectSamResourceId, + RuntimeSamResourceId, + WorkspaceResourceSamResourceId +} import org.broadinstitute.dsde.workbench.leonardo.config.Config import org.broadinstitute.dsde.workbench.leonardo.db.LeoProfile.api._ import org.broadinstitute.dsde.workbench.leonardo.db.LeoProfile.dummyDate @@ -24,6 +28,7 @@ import org.broadinstitute.dsde.workbench.leonardo.model.{ import org.broadinstitute.dsde.workbench.model.google.{GcsBucketName, GoogleProject} import org.broadinstitute.dsde.workbench.model.{IP, WorkbenchEmail} +import java.util.UUID import java.sql.SQLDataException import java.time.Instant import scala.concurrent.ExecutionContext @@ -65,7 +70,6 @@ object RuntimeServiceDbQueries { Option[WorkspaceId], Option[(String, String)] ) - private object ListRuntimesRecord { def apply(product: ListRuntimesProduct): ListRuntimesRecord = product match { case (l, @@ -303,26 +307,33 @@ object RuntimeServiceDbQueries { /** * List runtimes filtered by the given terms. Only return authorized resources (per reader*Ids and/or owner*Ids). - * * @param labelMap * @param excludeStatuses * @param creatorEmail * @param workspaceId * @param cloudProvider - * @param runtimeIds + * @param readerRuntimeIds * @param readerWorkspaceIds * @param ownerWorkspaceIds * @param readerGoogleProjectIds * @param ownerGoogleProjectIds * @return */ - def listRuntimes(runtimeIds: Set[SamResourceId] = Set.empty, - cloudContext: Option[CloudContext] = None, - cloudProvider: Option[CloudProvider] = None, - creatorEmail: Option[WorkbenchEmail] = None, - excludeStatuses: List[RuntimeStatus] = List.empty, - labelMap: LabelMap = Map.empty[String, String], - workspaceId: Option[WorkspaceId] = None + def listRuntimes( + // Authorizations + ownerGoogleProjectIds: Set[ProjectSamResourceId] = Set.empty, + ownerWorkspaceIds: Set[WorkspaceResourceSamResourceId] = Set.empty, + readerGoogleProjectIds: Set[ProjectSamResourceId] = Set.empty, + readerRuntimeIds: Set[SamResourceId] = Set.empty, + readerWorkspaceIds: Set[WorkspaceResourceSamResourceId] = Set.empty, + + // Filters + cloudContext: Option[CloudContext] = None, + cloudProvider: Option[CloudProvider] = None, + creatorEmail: Option[WorkbenchEmail] = None, + excludeStatuses: List[RuntimeStatus] = List.empty, + labelMap: LabelMap = Map.empty[String, String], + workspaceId: Option[WorkspaceId] = None )(implicit ec: ExecutionContext): DBIO[Vector[ListRuntimeResponse2]] = { // Normalize filter params val provider = if (cloudProvider.isEmpty) { @@ -332,8 +343,93 @@ object RuntimeServiceDbQueries { } } else cloudProvider - val runtimes = clusterQuery - .filter(_.internalId inSetBind runtimeIds.map(_.asString)) + // Optimize Google project list if filtering to a specific cloud provider or context + val ownedProjects: Set[CloudContextDb] = ((provider, cloudContext) match { + case (Some(CloudProvider.Azure), _) => Set.empty[CloudContextDb] + case (Some(CloudProvider.Gcp), Some(CloudContext.Gcp(value))) => + ownerGoogleProjectIds.filter(samId => samId.googleProject == value) + case _ => ownerGoogleProjectIds + }).map { case samId: SamResourceId => + CloudContextDb(samId.resourceId) + } + val readProjects: Set[CloudContextDb] = ((provider, cloudContext) match { + case (Some(CloudProvider.Azure), _) => Set.empty[CloudContextDb] + case (Some(CloudProvider.Gcp), Some(CloudContext.Gcp(value))) => + readerGoogleProjectIds.filter(samId => samId.googleProject == value) + case _ => readerGoogleProjectIds + }).map { case samId: SamResourceId => + CloudContextDb(samId.resourceId) + } + + // Optimize workspace list if filtering to a single workspace + val ownedWorkspaces: Set[WorkspaceId] = (workspaceId match { + case Some(wId) => ownerWorkspaceIds.filter(samId => WorkspaceId(UUID.fromString(samId.resourceId)) == wId) + case None => ownerWorkspaceIds + }).map(samId => WorkspaceId(UUID.fromString(samId.resourceId))) + val readWorkspaces: Set[WorkspaceId] = (workspaceId match { + case Some(wId) => readerWorkspaceIds.filter(samId => WorkspaceId(UUID.fromString(samId.resourceId)) == wId) + case None => readerWorkspaceIds + }).map(samId => WorkspaceId(UUID.fromString(samId.resourceId))) + + val readRuntimes: Set[String] = readerRuntimeIds.map(readId => readId.asString) + + val runtimeInReadWorkspaces: Option[ClusterTable => Rep[Option[Boolean]]] = + if (readRuntimes.isEmpty || readWorkspaces.isEmpty) + None + else + Some(runtime => + (runtime.internalId inSetBind readRuntimes) && + (runtime.workspaceId inSetBind readWorkspaces) + ) + + val runtimeInReadProjects: Option[ClusterTable => Rep[Option[Boolean]]] = + if (readRuntimes.isEmpty || readProjects.isEmpty) + None + else + Some(runtime => + (runtime.internalId inSetBind readRuntimes) && + (runtime.cloudProvider.? === (CloudProvider.Gcp: CloudProvider)) && + (runtime.cloudContextDb inSetBind readProjects) + ) + + val runtimeInOwnedWorkspaces: Option[ClusterTable => Rep[Option[Boolean]]] = + if (ownedWorkspaces.isEmpty) + None + else + Some(runtime => runtime.workspaceId inSetBind ownedWorkspaces) + + val runtimeInOwnedProjects: Option[ClusterTable => Rep[Option[Boolean]]] = + if (ownedProjects.isEmpty) + None + else if (cloudContext.isDefined) { + // If cloudContext is defined, we're already applying the filter in runtimesFiltered below. + // No need to filter by the list of user owned projects anymore as long as the specified + // project is owned by the user. + if (ownedProjects.exists(x => x.value == cloudContext.get.asString)) + Some(_ => Some(true)) + else None + } else + Some(runtime => + (runtime.cloudProvider.? === (CloudProvider.Gcp: CloudProvider)) && + (runtime.cloudContextDb inSetBind ownedProjects) + ) + + val runtimesAuthorized = + clusterQuery.filter[Rep[Option[Boolean]]] { runtime: ClusterTable => + Seq( + runtimeInReadWorkspaces, + runtimeInOwnedWorkspaces, + runtimeInReadProjects, + runtimeInOwnedProjects + ) + .mapFilter(opt => opt) + .map(_(runtime)) + .reduceLeftOption(_ || _) + .getOrElse(Some(false): Rep[Option[Boolean]]) + } + + val runtimesFiltered = runtimesAuthorized + // Filter by params .filterOpt(workspaceId) { case (runtime, wId) => runtime.workspaceId === (Some(wId): Rep[Option[WorkspaceId]]) } @@ -362,6 +458,9 @@ object RuntimeServiceDbQueries { ) .length === labelMap.size } + + // Assemble response + val runtimesJoined = runtimesFiltered .join(runtimeConfigs) .on((runtime, runtimeConfig) => runtime.runtimeConfigId === runtimeConfig.id) .map { case (runtime, runtimeConfig) => @@ -399,7 +498,7 @@ object RuntimeServiceDbQueries { ) } - runtimes.result + runtimesJoined.result .map { records: Seq[ListRuntimesProduct] => records .map(record => ListRuntimesRecord(record)) diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/AppDependenciesBuilder.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/AppDependenciesBuilder.scala index 8e78839e2f7..0d17fdecddd 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/AppDependenciesBuilder.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/AppDependenciesBuilder.scala @@ -98,6 +98,7 @@ class AppDependenciesBuilder(baselineDependenciesBuilder: BaselineDependenciesBu val azureService = new RuntimeV2ServiceInterp[IO]( baselineDependencies.runtimeServicesConfig, + baselineDependencies.authProvider, baselineDependencies.publisherQueue, baselineDependencies.dateAccessedUpdaterQueue, baselineDependencies.wsmClientProvider, diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterp.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterp.scala index 288aa819bf2..6f1aeb7462a 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterp.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterp.scala @@ -20,6 +20,7 @@ import org.broadinstitute.dsde.workbench.google2.{ MachineTypeName, ZoneName } +import org.broadinstitute.dsde.workbench.leonardo.JsonCodec._ import org.broadinstitute.dsde.workbench.leonardo.RuntimeImageType.{CryptoDetector, Jupyter, Proxy, Welder} import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.{ PersistentDiskSamResourceId, @@ -34,6 +35,7 @@ import org.broadinstitute.dsde.workbench.leonardo.db._ import org.broadinstitute.dsde.workbench.leonardo.http.service.DiskServiceInterp.getDiskSamPolicyMap import org.broadinstitute.dsde.workbench.leonardo.http.service.RuntimeServiceInterp._ import org.broadinstitute.dsde.workbench.leonardo.model.SamResource.RuntimeSamResource +import org.broadinstitute.dsde.workbench.leonardo.model.SamResourceAction._ import org.broadinstitute.dsde.workbench.leonardo.model._ import org.broadinstitute.dsde.workbench.leonardo.monitor.LeoPubsubMessage._ import org.broadinstitute.dsde.workbench.leonardo.monitor.{ @@ -247,19 +249,36 @@ class RuntimeServiceInterp[F[_]: Parallel]( for { ctx <- as.ask - samResources <- samService.listResources(userInfo.accessToken.token, RuntimeSamResource.resourceType) + // throw 403 if user doesn't have project permission + hasProjectPermission <- cloudContext.traverse(cc => + authProvider.isUserProjectReader( + cc, + userInfo + ) + ) + _ <- ctx.span.traverse(s => F.delay(s.addAnnotation("Done checking project permission with Sam"))) + + _ <- F.raiseWhen(!hasProjectPermission.getOrElse(true))(ForbiddenError(userInfo.userEmail, Some(ctx.traceId))) (labelMap, includeDeleted, _) <- F.fromEither(processListParameters(params)) excludeStatuses = if (includeDeleted) List.empty else List(RuntimeStatus.Deleted) creatorOnly <- F.fromEither(processCreatorOnlyParameter(userInfo.userEmail, params, ctx.traceId)) + authorizedIds <- getAuthorizedIds(userInfo, creatorOnly) _ <- ctx.span.traverse(s => F.delay(s.addAnnotation("Start DB query for listRuntimes"))) runtimes <- RuntimeServiceDbQueries - .listRuntimes(samResources.map(RuntimeSamResourceId).toSet, - excludeStatuses = excludeStatuses, - creatorEmail = creatorOnly, - cloudContext = cloudContext, - labelMap = labelMap + .listRuntimes( + // Authorization scopes + ownerGoogleProjectIds = authorizedIds.ownerGoogleProjectIds, + ownerWorkspaceIds = authorizedIds.ownerWorkspaceIds, + readerGoogleProjectIds = authorizedIds.readerGoogleProjectIds, + readerRuntimeIds = authorizedIds.readerRuntimeIds, + readerWorkspaceIds = authorizedIds.readerWorkspaceIds, + // Filters + excludeStatuses = excludeStatuses, + creatorEmail = creatorOnly, + cloudContext = cloudContext, + labelMap = labelMap ) .transaction @@ -830,6 +849,55 @@ class RuntimeServiceInterp[F[_]: Parallel]( userEmail ) } yield runtime + + private[service] def getAuthorizedIds( + userInfo: UserInfo, + creatorEmail: Option[WorkbenchEmail] = None + )(implicit ev: Ask[F, AppContext]): F[AuthorizedIds] = for { + // Authorize: user has an active account and has accepted terms of service + _ <- authProvider.checkUserEnabled(userInfo) + + // Authorize: get resource IDs the user can see + // HACK: leonardo is modeling access control here, handling inheritance + // of workspace and project-level permissions. Sam and WSM already do this, + // and should be considered the point of truth. + + // HACK: leonardo short-circuits access control to grant access to runtime creators. + // This supports the use case where `terra-ui` requests status of runtimes that have + // not yet been provisioned in Sam. + creatorRuntimeIdsBackdoor: Set[RuntimeSamResourceId] <- creatorEmail match { + case Some(email: WorkbenchEmail) => + RuntimeServiceDbQueries + .listRuntimeIdsForCreator(email) + .map(_.map(_.samResource).toSet) + .transaction + case None => F.pure(Set.empty: Set[RuntimeSamResourceId]) + } + + // v1 runtimes (sam resource type `notebook-cluster`) are readable only + // by their creators (`Creator` is the SamResource.Runtime `ownerRoleName`), + // if the creator also has read access to the corresponding SamResource.Project + creatorV1RuntimeIds: Set[RuntimeSamResourceId] <- authProvider + .listResourceIds[RuntimeSamResourceId](hasOwnerRole = true, userInfo) + readerProjectIds: Set[ProjectSamResourceId] <- authProvider + .listResourceIds[ProjectSamResourceId](hasOwnerRole = false, userInfo) + + // v1 runtimes are discoverable by owners on the corresponding Project + ownerProjectIds: Set[ProjectSamResourceId] <- authProvider + .listResourceIds[ProjectSamResourceId](hasOwnerRole = true, userInfo) + + // combine: to read a runtime, user needs to be at least one of: + // - creator of a v1 runtime (Sam-authenticated) + // - any role on a v2 runtime (Sam-authenticated) + // - creator of a runtime (in Leo db) and filtering their request by creator-only + readerRuntimeIds: Set[SamResourceId] = creatorV1RuntimeIds ++ creatorRuntimeIdsBackdoor + } yield AuthorizedIds( + ownerGoogleProjectIds = ownerProjectIds, + ownerWorkspaceIds = Set.empty, + readerGoogleProjectIds = readerProjectIds, + readerRuntimeIds = readerRuntimeIds, + readerWorkspaceIds = Set.empty + ) } object RuntimeServiceInterp { diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterp.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterp.scala index 94e382cdc93..b41e70d9c5f 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterp.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterp.scala @@ -9,8 +9,10 @@ import cats.effect.std.Queue import cats.mtl.Ask import cats.syntax.all._ import org.broadinstitute.dsde.workbench.google2.{DiskName, MachineTypeName, ZoneName} +import org.broadinstitute.dsde.workbench.leonardo.JsonCodec._ import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.{ PersistentDiskSamResourceId, + ProjectSamResourceId, RuntimeSamResourceId, WorkspaceResourceSamResourceId, WsmResourceSamResourceId @@ -21,7 +23,14 @@ import org.broadinstitute.dsde.workbench.leonardo.dao.sam.{SamException, SamServ import org.broadinstitute.dsde.workbench.leonardo.db._ import org.broadinstitute.dsde.workbench.leonardo.http.service.DiskServiceInterp.getDiskSamPolicyMap import org.broadinstitute.dsde.workbench.leonardo.http.service.RuntimeServiceInterp.getRuntimeSamPolicyMap -import org.broadinstitute.dsde.workbench.leonardo.model.SamResource.RuntimeSamResource +// do not remove: `projectSamResourceAction`, `runtimeSamResourceAction`, `workspaceSamResourceAction`, `wsmResourceSamResourceAction`; `AppSamResourceAction` they are implicit +import org.broadinstitute.dsde.workbench.leonardo.model.SamResourceAction.{ + projectSamResourceAction, + runtimeSamResourceAction, + workspaceSamResourceAction, + wsmResourceSamResourceAction, + AppSamResourceAction +} import org.broadinstitute.dsde.workbench.leonardo.model._ import org.broadinstitute.dsde.workbench.leonardo.monitor.LeoPubsubMessage.{ CreateAzureRuntimeMessage, @@ -39,6 +48,7 @@ import scala.concurrent.ExecutionContext class RuntimeV2ServiceInterp[F[_]: Parallel]( config: RuntimeServiceConfig, + authProvider: LeoAuthProvider[F], publisherQueue: Queue[F, LeoPubsubMessage], dateAccessUpdaterQueue: Queue[F, UpdateDateAccessedMessage], wsmClientProvider: WsmApiClientProvider[F], @@ -76,18 +86,26 @@ class RuntimeV2ServiceInterp[F[_]: Parallel]( case (None, None) => F.raiseError[CloudContext](CloudContextNotFoundException(workspaceId, ctx.traceId)) } + samResource = WorkspaceResourceSamResourceId(workspaceId) + // Resolve the user email in Sam from the user token. This translates a pet token to the owner email. userEmail <- samService.getUserEmail(userInfo.accessToken.token) // enforcing one runtime per workspace/user at a time - samResources <- samService.listResources(userInfo.accessToken.token, RuntimeSamResource.resourceType) + authorizedIds <- getAuthorizedIds(userInfo, Some(userEmail), Some(samResource)) runtimes <- RuntimeServiceDbQueries .listRuntimes( - runtimeIds = samResources.map(RuntimeSamResourceId).toSet, - cloudProvider = Some(cloudContext.cloudProvider), - creatorEmail = Some(userEmail), + // Authorization scopes + ownerGoogleProjectIds = authorizedIds.ownerGoogleProjectIds, + ownerWorkspaceIds = authorizedIds.ownerWorkspaceIds, + readerGoogleProjectIds = authorizedIds.readerGoogleProjectIds, + readerRuntimeIds = authorizedIds.readerRuntimeIds, + readerWorkspaceIds = authorizedIds.readerWorkspaceIds, + // Filters excludeStatuses = List(RuntimeStatus.Deleted, RuntimeStatus.Deleting), - workspaceId = Some(workspaceId) + creatorEmail = Some(userEmail), + workspaceId = Some(workspaceId), + cloudProvider = Some(cloudContext.cloudProvider) ) .transaction @@ -338,10 +356,25 @@ class RuntimeV2ServiceInterp[F[_]: Parallel]( as: Ask[F, AppContext] ): F[Unit] = for { - samResources <- samService.listResources(userInfo.accessToken.token, RuntimeSamResource.resourceType) + ctx <- as.ask + + workspaceSamId = WorkspaceResourceSamResourceId(workspaceId) + hasWorkspacePermission <- authProvider.isUserWorkspaceReader( + workspaceSamId, + userInfo + ) + _ <- F.raiseUnless(hasWorkspacePermission)(ForbiddenError(userInfo.userEmail)) + + authorizedIds <- getAuthorizedIds(userInfo, workspaceSamId = Some(workspaceSamId)) runtimes <- RuntimeServiceDbQueries .listRuntimes( - runtimeIds = samResources.map(RuntimeSamResourceId).toSet, + // Authorization scopes + ownerGoogleProjectIds = authorizedIds.ownerGoogleProjectIds, + ownerWorkspaceIds = authorizedIds.ownerWorkspaceIds, + readerGoogleProjectIds = authorizedIds.readerGoogleProjectIds, + readerRuntimeIds = authorizedIds.readerRuntimeIds, + readerWorkspaceIds = authorizedIds.readerWorkspaceIds, + // Filters excludeStatuses = List(RuntimeStatus.Deleted), workspaceId = Some(workspaceId) ) @@ -427,16 +460,23 @@ class RuntimeV2ServiceInterp[F[_]: Parallel]( (labelMap, includeDeleted, _) <- F.fromEither(processListParameters(params)) excludeStatuses = if (includeDeleted) List.empty else List(RuntimeStatus.Deleted) creatorEmail <- F.fromEither(processCreatorOnlyParameter(userInfo.userEmail, params, ctx.traceId)) + maybeWorkspaceSamId = workspaceId.map(WorkspaceResourceSamResourceId) - samResources <- samService.listResources(userInfo.accessToken.token, RuntimeSamResource.resourceType) + authorizedIds <- getAuthorizedIds(userInfo, creatorEmail, maybeWorkspaceSamId) runtimes <- RuntimeServiceDbQueries .listRuntimes( - runtimeIds = samResources.map(RuntimeSamResourceId).toSet, - cloudProvider = cloudProvider, - creatorEmail = creatorEmail, - excludeStatuses = excludeStatuses, - labelMap = labelMap, - workspaceId = workspaceId + // Authorization scopes + ownerGoogleProjectIds = authorizedIds.ownerGoogleProjectIds, + ownerWorkspaceIds = authorizedIds.ownerWorkspaceIds, + readerGoogleProjectIds = authorizedIds.readerGoogleProjectIds, + readerRuntimeIds = authorizedIds.readerRuntimeIds, + readerWorkspaceIds = authorizedIds.readerWorkspaceIds, + // Filters + cloudProvider = cloudProvider, // Google | Azure + creatorEmail = creatorEmail, // whether to filter out runtimes user did not create + excludeStatuses = excludeStatuses, // whether to filter out Deleted runtimes + labelMap = labelMap, // arbitrary key-value labels to filter by + workspaceId = workspaceId // whether to find only runtimes in a single workspace ) .map(_.toList) .transaction @@ -516,6 +556,84 @@ class RuntimeV2ServiceInterp[F[_]: Parallel]( .transaction >> clusterQuery.updateClusterStatus(runtimeId, RuntimeStatus.Error, ctx.now).transaction.void + private def getAuthorizedIds( + userInfo: UserInfo, + creatorEmail: Option[WorkbenchEmail] = None, + workspaceSamId: Option[WorkspaceResourceSamResourceId] = None + )(implicit ev: Ask[F, AppContext]): F[AuthorizedIds] = for { + // Authorize: user has an active account and has accepted terms of service + _ <- authProvider.checkUserEnabled(userInfo) + + // Authorize: get resource IDs the user can see + // HACK: leonardo is modeling access control here, handling inheritance + // of workspace and project-level permissions. Sam and WSM already do this, + // and should be considered the point of truth. + + // HACK: leonardo short-circuits access control to grant access to runtime creators. + // This supports the use case where `terra-ui` requests status of runtimes that have + // not yet been provisioned in Sam. + creatorRuntimeIdsBackdoor: Set[RuntimeSamResourceId] <- creatorEmail match { + case Some(email: WorkbenchEmail) => + RuntimeServiceDbQueries + .listRuntimeIdsForCreator(email) + .map(_.map(_.samResource).toSet) + .transaction + case None => F.pure(Set.empty: Set[RuntimeSamResourceId]) + } + + // v1 runtimes (sam resource type `notebook-cluster`) are readable only + // by their creators (`Creator` is the SamResource.Runtime `ownerRoleName`), + // if the creator also has read access to the corresponding SamResource.Project + creatorV1RuntimeIds: Set[RuntimeSamResourceId] <- authProvider + .listResourceIds[RuntimeSamResourceId](hasOwnerRole = true, userInfo) + readerProjectIds: Set[ProjectSamResourceId] <- authProvider + .listResourceIds[ProjectSamResourceId](hasOwnerRole = false, userInfo) + + // v1 runtimes are discoverable by owners on the corresponding Project + ownerProjectIds: Set[ProjectSamResourceId] <- authProvider + .listResourceIds[ProjectSamResourceId](hasOwnerRole = true, userInfo) + + // v2 runtimes are WSM-managed resources and they're modeled as `WsmResourceSamResource`s. + // HACK: accept any ID in the list of readable WSM resources as a valid + // readable v2 runtime ID. Some of these IDs are for non-runtime resources. + // TODO [] call WSM to list workspaces, then list runtimes per workspace, to get these IDs? + + // v2 runtimes are readable by users with read access to both runtime and workspace + readerV2WsmIds: Set[WsmResourceSamResourceId] <- authProvider + .listResourceIds[WsmResourceSamResourceId](hasOwnerRole = false, userInfo) + readerWorkspaceIds: Set[WorkspaceResourceSamResourceId] <- workspaceSamId match { + case Some(samId) => + for { + isWorkspaceReader <- authProvider.isUserWorkspaceReader(samId, userInfo) + workspaceIds: Set[WorkspaceResourceSamResourceId] = + if (isWorkspaceReader) Set(samId) else Set.empty + } yield workspaceIds + case None => authProvider.listResourceIds[WorkspaceResourceSamResourceId](hasOwnerRole = false, userInfo) + } + + // v2 runtimes are discoverable by owners on the corresponding Workspace + ownerWorkspaceIds: Set[WorkspaceResourceSamResourceId] <- workspaceSamId match { + case Some(samId) => + for { + isWorkspaceOwner <- authProvider.isUserWorkspaceOwner(samId, userInfo) + workspaceIds: Set[WorkspaceResourceSamResourceId] = if (isWorkspaceOwner) Set(samId) else Set.empty + } yield workspaceIds + case None => authProvider.listResourceIds[WorkspaceResourceSamResourceId](hasOwnerRole = true, userInfo) + } + + // combine: to read a runtime, user needs to be at least one of: + // - creator of a v1 runtime (Sam-authenticated) + // - any role on a v2 runtime (Sam-authenticated) + // - creator of a runtime (in Leo db) and filtering their request by creator-only + readerRuntimeIds: Set[SamResourceId] = creatorV1RuntimeIds ++ readerV2WsmIds ++ creatorRuntimeIdsBackdoor + } yield AuthorizedIds( + ownerGoogleProjectIds = ownerProjectIds, + ownerWorkspaceIds = ownerWorkspaceIds, + readerGoogleProjectIds = readerProjectIds, + readerRuntimeIds = readerRuntimeIds, + readerWorkspaceIds = readerWorkspaceIds + ) + private def convertToRuntime( workspaceId: WorkspaceId, runtimeName: RuntimeName, @@ -575,6 +693,13 @@ class RuntimeV2ServiceInterp[F[_]: Parallel]( } } +final case class AuthorizedIds( + val ownerGoogleProjectIds: Set[ProjectSamResourceId], + val ownerWorkspaceIds: Set[WorkspaceResourceSamResourceId], + val readerGoogleProjectIds: Set[ProjectSamResourceId], + val readerRuntimeIds: Set[SamResourceId], + val readerWorkspaceIds: Set[WorkspaceResourceSamResourceId] +) final case class WorkspaceNotFoundException(workspaceId: WorkspaceId, traceId: TraceId) extends LeoException( diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitor.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitor.scala index c5d9052354a..2c6c73b41c3 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitor.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitor.scala @@ -118,8 +118,9 @@ abstract class BaseCloudServiceRuntimeMonitor[F[_]] { ): F[CheckResult] = for { ctx <- ev.ask + // Delete the runtime if deleteRuntime is true + // Stop the runtime otherwise _ <- List( - // Delete the cluster in Google runtimeAlg .deleteRuntime( DeleteRuntimeParams(runtimeAndRuntimeConfig, mainInstance) @@ -131,7 +132,8 @@ abstract class BaseCloudServiceRuntimeMonitor[F[_]] { StopRuntimeParams(runtimeAndRuntimeConfig, ctx.now, true) ) .void - .whenA(!deleteRuntime), // When we don't delete runtime, we should stop the runtime + .whenA(!deleteRuntime), + // save cluster error in the DB saveRuntimeError( runtimeAndRuntimeConfig.runtime.id, @@ -167,6 +169,9 @@ abstract class BaseCloudServiceRuntimeMonitor[F[_]] { persistentDiskOpt <- rc.persistentDiskId.flatTraverse(did => persistentDiskQuery.getPersistentDiskRecord(did).transaction ) + + // if there's a disk in Creating/Failed status, delete it + // any other state, detach from the runtime _ <- persistentDiskOpt match { case Some(value) => if (value.status == DiskStatus.Creating || value.status == DiskStatus.Failed) { @@ -175,13 +180,14 @@ abstract class BaseCloudServiceRuntimeMonitor[F[_]] { .delete(d.id, ctx.now) .transaction ) - } else F.unit + } else clusterQuery.detachPersistentDisk(runtimeAndRuntimeConfig.runtime.id, ctx.now).transaction case None => F.unit } } yield () } } yield () } else F.unit + // Update the cluster status to Error only if the runtime is non-Deleted. // If the user has explicitly deleted their runtime by this point then // we don't want to move it back to Error status. @@ -191,7 +197,6 @@ abstract class BaseCloudServiceRuntimeMonitor[F[_]] { curStatusOpt, new Exception(s"Cluster with id ${runtimeAndRuntimeConfig.runtime.id} not found in the database") ) - _ <- clusterQuery.detachPersistentDisk(runtimeAndRuntimeConfig.runtime.id, ctx.now).transaction _ <- curStatus match { case RuntimeStatus.Deleted => logger.info(ctx.loggingCtx)( diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/config/ConfigSpec.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/config/ConfigSpec.scala index 944c6ac0ace..1763ba4c209 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/config/ConfigSpec.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/config/ConfigSpec.scala @@ -84,7 +84,7 @@ final class ConfigSpec extends AnyFlatSpec with Matchers { "69.173.127.240/28", "69.173.112.0/21" ).map(CidrIP), - KubernetesClusterVersion("1.28"), + KubernetesClusterVersion("1.30"), 1 hour, 200, AutopilotConfig(AutopilotResource(500, 3, 1), AutopilotResource(500, 3, 1)) diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueriesSpec.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueriesSpec.scala index 3df9fcdbde5..5242dfc94bd 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueriesSpec.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueriesSpec.scala @@ -62,8 +62,16 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit c1 <- IO( makeCluster(1).saveWithRuntimeConfig(d1RuntimeConfig) ) + c1WorkspaceIds = c1.workspaceId match { + case Some(workspaceId) => Set(WorkspaceResourceSamResourceId(workspaceId)) + case None => Set.empty[WorkspaceResourceSamResourceId] + } list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = Set(c1.samResource), excludeStatuses = List(RuntimeStatus.Deleted)) + .listRuntimes( + readerRuntimeIds = Set(c1.samResource), + readerWorkspaceIds = c1WorkspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction // Two runtimes exist: c1, c2 d2 <- makePersistentDisk(Some(DiskName("d2"))).save() @@ -77,8 +85,15 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit c2 <- IO( makeCluster(2).saveWithRuntimeConfig(d2RuntimeConfig) ) + bothWorkspaceIds = Set(c1.workspaceId, c2.workspaceId).collect { case Some(workspaceId) => + WorkspaceResourceSamResourceId(workspaceId) + } list3 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = Set(c1.samResource, c2.samResource), excludeStatuses = List(RuntimeStatus.Deleted)) + .listRuntimes( + readerRuntimeIds = Set(c1.samResource, c2.samResource), + readerWorkspaceIds = bothWorkspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction // no authorizations => no runtimes @@ -164,14 +179,22 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit // Note that c3 exists but is not visible googleProject = GoogleProject(c1.cloudContext.asString) + projectIds = Set(ProjectSamResourceId(googleProject)) runtimeIds = Set(c1.samResource: SamResourceId, c2.samResource: SamResourceId) + workspaceIds = Set(workspaceId1, workspaceId2).map(WorkspaceResourceSamResourceId) list0 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + readerGoogleProjectIds = projectIds + ) .transaction list1 <- RuntimeServiceDbQueries .listRuntimes( - runtimeIds = runtimeIds, + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + readerGoogleProjectIds = projectIds, cloudContext = Some(CloudContext.Gcp(googleProject)), cloudProvider = Some(CloudProvider.Gcp), creatorEmail = Some(c1.auditInfo.creator), @@ -182,7 +205,9 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit .transaction list2 <- RuntimeServiceDbQueries .listRuntimes( - runtimeIds = runtimeIds, + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + readerGoogleProjectIds = projectIds, cloudProvider = Some(CloudProvider.Azure), creatorEmail = Some(c2.auditInfo.creator), excludeStatuses = List(RuntimeStatus.Deleted), @@ -237,27 +262,57 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit "creator" -> c2.auditInfo.creator.value ) runtimeIds = Set(c1.samResource: SamResourceId, c2.samResource: SamResourceId) + bothProjectIds = Set( + ProjectSamResourceId(GoogleProject(c1.cloudContext.asString)), + ProjectSamResourceId(GoogleProject(c2.cloudContext.asString)) + ) list0 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted)) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction list1 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted), labelMap = labels1) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + labelMap = labels1, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted), labelMap = labels2) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + labelMap = labels2, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction _ <- labelQuery.saveAllForResource(c1.id, LabelResourceType.Runtime, labels1).transaction _ <- labelQuery.saveAllForResource(c2.id, LabelResourceType.Runtime, labels2).transaction list3 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted), labelMap = labels1) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + labelMap = labels1, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction list4 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted), labelMap = labels2) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + labelMap = labels2, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction list5 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - excludeStatuses = List(RuntimeStatus.Deleted), - labelMap = Map("googleProject" -> c1.cloudContext.asString) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + labelMap = Map("googleProject" -> c1.cloudContext.asString), + excludeStatuses = List(RuntimeStatus.Deleted) ) .transaction end <- IO.realTimeInstant @@ -304,16 +359,24 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit makeCluster(2).saveWithRuntimeConfig(c2RuntimeConfig) ) runtimeIds = Set(c1.samResource: SamResourceId, c2.samResource: SamResourceId) + bothProjectIds = Set( + ProjectSamResourceId(GoogleProject(c1.cloudContext.asString)), + ProjectSamResourceId(GoogleProject(c2.cloudContext.asString)) + ) list1 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - cloudContext = Some(cloudContextGcp), - excludeStatuses = List(RuntimeStatus.Deleted) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + excludeStatuses = List(RuntimeStatus.Deleted), + cloudContext = Some(cloudContextGcp) ) .transaction list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - cloudContext = Some(cloudContext2Gcp), - excludeStatuses = List(RuntimeStatus.Deleted) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = bothProjectIds, + excludeStatuses = List(RuntimeStatus.Deleted), + cloudContext = Some(cloudContext2Gcp) ) .transaction end <- IO.realTimeInstant @@ -379,12 +442,21 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit ) ) runtimeIds = Set(c1.samResource: SamResourceId, c2.samResource: SamResourceId, c3.samResource: SamResourceId) + projectIds = Set( + ProjectSamResourceId(GoogleProject(c1.cloudContext.asString)), + ProjectSamResourceId(GoogleProject(c2.cloudContext.asString)), + ProjectSamResourceId(GoogleProject(c3.cloudContext.asString)) + ) list1 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds) + .listRuntimes(readerRuntimeIds = runtimeIds, readerGoogleProjectIds = projectIds) .transaction list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted)) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = projectIds, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction end <- IO.realTimeInstant elapsed = (end.toEpochMilli - start.toEpochMilli).millis @@ -438,11 +510,27 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit ) ) runtimeIds = Set(c1.samResource: SamResourceId, c2.samResource: SamResourceId, c3.samResource: SamResourceId) + projectIds = Set( + ProjectSamResourceId(GoogleProject(c1.cloudContext.asString)), + ProjectSamResourceId(GoogleProject(c2.cloudContext.asString)) + ) + workspaceIds = Set(c3.workspaceId).collect { case Some(workspaceId) => + WorkspaceResourceSamResourceId(workspaceId) + } list1 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = projectIds, + readerWorkspaceIds = workspaceIds + ) .transaction list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, excludeStatuses = List(RuntimeStatus.Deleted)) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerGoogleProjectIds = projectIds, + readerWorkspaceIds = workspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted) + ) .transaction end <- IO.realTimeInstant elapsed = (end.toEpochMilli - start.toEpochMilli).millis @@ -504,14 +592,21 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit ) ) runtimeIds = Set(c1.samResource: SamResourceId, c2.samResource: SamResourceId, c3.samResource: SamResourceId) + workspaceIds = Set(workspaceId1, workspaceId2).map(WorkspaceResourceSamResourceId) list1 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, workspaceId = Some(workspaceId1)) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + workspaceId = Some(workspaceId1) + ) .transaction list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - excludeStatuses = List(RuntimeStatus.Deleted), - workspaceId = Some(workspaceId2) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted), + workspaceId = Some(workspaceId2) ) .transaction end <- IO.realTimeInstant @@ -594,41 +689,51 @@ class RuntimeServiceDbQueriesSpec extends AnyFlatSpecLike with TestComponent wit ) c5ClusterRecord <- clusterQuery.getActiveClusterRecordByName(c5.cloudContext, c5.runtimeName).transaction runtimeIds = Set(c1, c2, c3, c4, c5).map(_.samResource: SamResourceId) + workspaceIds = Set(workspaceId1, workspaceId2).map(WorkspaceResourceSamResourceId) list1 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - cloudProvider = Some(CloudProvider.Azure), - excludeStatuses = List(RuntimeStatus.Deleted), - workspaceId = Some(workspaceId1) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted), + workspaceId = Some(workspaceId1), + cloudProvider = Some(CloudProvider.Azure) ) .transaction list2 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - cloudProvider = Some(CloudProvider.Azure), - excludeStatuses = List(RuntimeStatus.Deleted), - workspaceId = Some(workspaceId2) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted), + workspaceId = Some(workspaceId2), + cloudProvider = Some(CloudProvider.Azure) ) .transaction list3 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - cloudProvider = Some(CloudProvider.Gcp), - excludeStatuses = List(RuntimeStatus.Deleted), - workspaceId = Some(workspaceId1) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted), + workspaceId = Some(workspaceId1), + cloudProvider = Some(CloudProvider.Gcp) ) .transaction list4 <- RuntimeServiceDbQueries - .listRuntimes(runtimeIds = runtimeIds, - cloudProvider = Some(CloudProvider.Azure), - excludeStatuses = List(RuntimeStatus.Deleted) + .listRuntimes( + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, + excludeStatuses = List(RuntimeStatus.Deleted), + cloudProvider = Some(CloudProvider.Azure) ) .transaction list5 <- RuntimeServiceDbQueries .listRuntimes( - runtimeIds = runtimeIds, - cloudProvider = Some(CloudProvider.Azure), - creatorEmail = Some(c5ClusterRecord.get.auditInfo.creator), + readerRuntimeIds = runtimeIds, + readerWorkspaceIds = workspaceIds, excludeStatuses = List(RuntimeStatus.Deleted), - workspaceId = Some(workspaceId2) + creatorEmail = Some(c5ClusterRecord.get.auditInfo.creator), + workspaceId = Some(workspaceId2), + cloudProvider = Some(CloudProvider.Azure) ) .transaction end <- IO.realTimeInstant diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/TestLeoRoutes.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/TestLeoRoutes.scala index d73ba194a14..7d54524504a 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/TestLeoRoutes.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/TestLeoRoutes.scala @@ -130,6 +130,7 @@ trait TestLeoRoutes { val runtimev2Service = new RuntimeV2ServiceInterp[IO]( serviceConfig, + allowListAuthProvider, QueueFactory.makePublisherQueue(), QueueFactory.makeDateAccessedQueue(), wsmClientProvider, diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterpSpec.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterpSpec.scala index 82e83e51295..dce120b75f6 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterpSpec.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterpSpec.scala @@ -839,7 +839,7 @@ class RuntimeServiceInterpTest res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) } - it should "throw RuntimeNotFoundException for nonexistent clusters" in isolatedDbTest { + it should "throw ClusterNotFoundException for nonexistent clusters" in isolatedDbTest { val exc = runtimeService .getRuntime(userInfo, CloudContext.Gcp(GoogleProject("nonexistent")), RuntimeName("cluster")) .attempt @@ -873,10 +873,13 @@ class RuntimeServiceInterpTest val userInfo = mockUserInfo("grendel@mom.mere") val runtimeIds = Vector(RuntimeSamResourceId(UUID.randomUUID.toString), RuntimeSamResourceId(UUID.randomUUID.toString)) - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(runtimeIds.map(_.resourceId).toList)) - val service = makeRuntimeService(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(runtimeIds(0), runtimeIds(1)), + readerProjectSamIds = Set(ProjectSamResourceId(project)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + val service = makeRuntimeService(authProvider = mockAuthProvider) val res = for { _ <- IO(makeCluster(1, samResource = runtimeIds(0)).save()) @@ -893,10 +896,13 @@ class RuntimeServiceInterpTest RuntimeSamResourceId(UUID.randomUUID.toString), RuntimeSamResourceId(UUID.randomUUID.toString) ) - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(runtimeIds.map(_.resourceId).toList)) - val service = makeRuntimeService(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(runtimeIds(0), runtimeIds(1)), + readerProjectSamIds = Set(ProjectSamResourceId(project), ProjectSamResourceId(project2)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + val service = makeRuntimeService(authProvider = mockAuthProvider) val res = for { _ <- IO(makeCluster(1).copy(samResource = runtimeIds(0)).save()) @@ -912,10 +918,13 @@ class RuntimeServiceInterpTest val userInfo = mockUserInfo("grendel@mom.mere") val runtimeIds = Vector(RuntimeSamResourceId(UUID.randomUUID.toString), RuntimeSamResourceId(UUID.randomUUID.toString)) - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(runtimeIds.map(_.resourceId).toList)) - val service = makeRuntimeService(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(runtimeIds(0), runtimeIds(1)), + readerProjectSamIds = Set(ProjectSamResourceId(project)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + val service = makeRuntimeService(authProvider = mockAuthProvider) val res = for { runtime1 <- IO(makeCluster(1).copy(samResource = runtimeIds(0)).save()) @@ -934,10 +943,12 @@ class RuntimeServiceInterpTest val userInfo = mockUserInfo("grendel@mere.mom") val runtimeIds = Vector(RuntimeSamResourceId(UUID.randomUUID.toString), RuntimeSamResourceId(UUID.randomUUID.toString)) - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(runtimeIds.map(_.resourceId).toList)) - val service = makeRuntimeService(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + ownerProjectSamIds = Set(ProjectSamResourceId(project)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + val service = makeRuntimeService(authProvider = mockAuthProvider) // Make runtimes belonging to different users than the calling user val res = for { @@ -1009,10 +1020,13 @@ class RuntimeServiceInterpTest runtime2.patchInProgress ) - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(runtime1.samResource.resourceId, runtime2.samResource.resourceId))) - val service = makeRuntimeService(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(runtime1.samResource, runtime2.samResource), + readerProjectSamIds = Set(ProjectSamResourceId(project)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + val service = makeRuntimeService(authProvider = mockAuthProvider) service .listRuntimes(userInfo, None, Map("_labels" -> "foo=bar")) @@ -1373,9 +1387,28 @@ class RuntimeServiceInterpTest when(samService.checkAuthorized(isEq(userInfo.accessToken.token), any(), isEq(RuntimeAction.DeleteRuntime))(any())) .thenReturn(IO.unit) when(samService.deleteResource(any(), any())(any())).thenReturn(IO.unit) + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(runtimeIds(0), runtimeIds(1)), + readerProjectSamIds = Set(ProjectSamResourceId(project)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + when( + mockAuthProvider.getActionsWithProjectFallback[RuntimeSamResourceId, RuntimeAction](any, any, isEq(userInfo))(any, + any + ) + ) + .thenReturn( + IO.pure( + (List(RuntimeAction.GetRuntimeStatus, RuntimeAction.DeleteRuntime), + List(ProjectAction.GetRuntimeStatus, ProjectAction.DeleteRuntime) + ) + ) + ) + val publisherQueue = QueueFactory.makePublisherQueue() val service = - makeRuntimeService(publisherQueue = publisherQueue, samService = samService) + makeRuntimeService(authProvider = mockAuthProvider, publisherQueue = publisherQueue, samService = samService) val res = for { pd1 <- makePersistentDisk().save() @@ -1432,9 +1465,28 @@ class RuntimeServiceInterpTest when(samService.getUserEmail(isEq(userInfo.accessToken.token))(any())).thenReturn(IO.pure(userInfo.userEmail)) when(samService.checkAuthorized(any(), any(), any())(any())).thenReturn(IO.unit) when(samService.deleteResource(any(), any())(any())).thenReturn(IO.unit) + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(runtimeIds(0), runtimeIds(1)), + readerProjectSamIds = Set(ProjectSamResourceId(project)) + ) + when(mockAuthProvider.isUserProjectReader(any, isEq(userInfo))(any)).thenReturn(IO.pure(true)) + when( + mockAuthProvider.getActionsWithProjectFallback[RuntimeSamResourceId, RuntimeAction](any, any, isEq(userInfo))(any, + any + ) + ) + .thenReturn( + IO.pure( + (List(RuntimeAction.GetRuntimeStatus, RuntimeAction.DeleteRuntime), + List(ProjectAction.GetRuntimeStatus, ProjectAction.DeleteRuntime) + ) + ) + ) + val publisherQueue = QueueFactory.makePublisherQueue() val service = - makeRuntimeService(publisherQueue = publisherQueue, samService = samService) + makeRuntimeService(authProvider = mockAuthProvider, publisherQueue = publisherQueue, samService = samService) val res = for { pd1 <- makePersistentDisk().save() @@ -1508,13 +1560,8 @@ class RuntimeServiceInterpTest ) ) - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(runtimeIds.map(_.resourceId).toList)) - val publisherQueue = QueueFactory.makePublisherQueue() - val service = - makeRuntimeService(authProvider = mockAuthProvider, publisherQueue = publisherQueue, samService = samService) + val service = makeRuntimeService(authProvider = mockAuthProvider, publisherQueue = publisherQueue) val res = for { pd1 <- makePersistentDisk().save() diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterpSpec.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterpSpec.scala index 5cc5b50244b..b17fed898fd 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterpSpec.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeV2ServiceInterpSpec.scala @@ -8,20 +8,36 @@ import cats.effect.IO import cats.effect.std.Queue import cats.mtl.Ask import com.azure.resourcemanager.compute.models.VirtualMachineSizeTypes +import io.circe.Decoder import org.broadinstitute.dsde.workbench.azure._ import org.broadinstitute.dsde.workbench.google2.DiskName import org.broadinstitute.dsde.workbench.leonardo.CommonTestData._ +import org.broadinstitute.dsde.workbench.leonardo.JsonCodec.{ + projectSamResourceDecoder, + runtimeSamResourceDecoder, + workspaceSamResourceIdDecoder, + wsmResourceSamResourceIdDecoder +} import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.{ + ProjectSamResourceId, RuntimeSamResourceId, WorkspaceResourceSamResourceId, WsmResourceSamResourceId } -import org.broadinstitute.dsde.workbench.leonardo.TestUtils.appContext +import org.broadinstitute.dsde.workbench.leonardo.auth.AllowlistAuthProvider +import org.broadinstitute.dsde.workbench.leonardo.TestUtils.{appContext, defaultMockitoAnswer} import org.broadinstitute.dsde.workbench.leonardo.config.Config import org.broadinstitute.dsde.workbench.leonardo.dao._ import org.broadinstitute.dsde.workbench.leonardo.dao.sam.{SamException, SamService} import org.broadinstitute.dsde.workbench.leonardo.db._ import org.broadinstitute.dsde.workbench.leonardo.model.SamResource.RuntimeSamResource +import org.broadinstitute.dsde.workbench.leonardo.model.SamResourceAction.{ + projectSamResourceAction, + runtimeSamResourceAction, + workspaceSamResourceAction, + wsmResourceSamResourceAction, + AppSamResourceAction +} import org.broadinstitute.dsde.workbench.leonardo.model._ import org.broadinstitute.dsde.workbench.leonardo.monitor.LeoPubsubMessage.{ CreateAzureRuntimeMessage, @@ -33,7 +49,8 @@ import org.broadinstitute.dsde.workbench.leonardo.monitor.{LeoPubsubMessage, Upd import org.broadinstitute.dsde.workbench.leonardo.util.QueueFactory import org.broadinstitute.dsde.workbench.model.google.GoogleProject import org.broadinstitute.dsde.workbench.model.{TraceId, UserInfo, WorkbenchEmail, WorkbenchUserId} -import org.mockito.ArgumentMatchers.{any, eq => isEq} +import org.mockito.ArgumentMatchers.{any, argThat, eq => isEq} +import org.scalatest.prop.TableDrivenPropertyChecks._ import org.mockito.Mockito.when import org.scalatest.flatspec.AnyFlatSpec import org.scalatestplus.mockito.MockitoSugar @@ -58,11 +75,12 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with // used when we care about queue state def makeInterp( queue: Queue[IO, LeoPubsubMessage] = QueueFactory.makePublisherQueue(), + authProvider: AllowlistAuthProvider = allowListAuthProvider, dateAccessedQueue: Queue[IO, UpdateDateAccessedMessage] = QueueFactory.makeDateAccessedQueue(), wsmClientProvider: WsmApiClientProvider[IO] = wsmClientProvider, samService: SamService[IO] = MockSamService ) = - new RuntimeV2ServiceInterp[IO](serviceConfig, queue, dateAccessedQueue, wsmClientProvider, samService) + new RuntimeV2ServiceInterp[IO](serviceConfig, authProvider, queue, dateAccessedQueue, wsmClientProvider, samService) // need to set previous runtime to deleted status before creating next to avoid exception def setRuntimeDeleted(workspaceId: WorkspaceId, name: RuntimeName): IO[Long] = @@ -87,12 +105,174 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with samService } + /** + * Generate a mocked AuthProvider which will permit action on the given resource IDs by the given user. + * TODO: cover actions beside `checkUserEnabled` and `listResourceIds` + * @param userInfo + * @param readerRuntimeSamIds + * @param readerWorkspaceSamIds + * @param readerProjectSamIds + * @param ownerWorkspaceSamIds + * @param ownerProjectSamIds + * @return + */ + def mockAuthorize( + userInfo: UserInfo, + readerRuntimeSamIds: Set[RuntimeSamResourceId] = Set.empty, + readerWsmSamIds: Set[WsmResourceSamResourceId] = Set.empty, + readerWorkspaceSamIds: Set[WorkspaceResourceSamResourceId] = Set.empty, + readerProjectSamIds: Set[ProjectSamResourceId] = Set.empty, + ownerWorkspaceSamIds: Set[WorkspaceResourceSamResourceId] = Set.empty, + ownerProjectSamIds: Set[ProjectSamResourceId] = Set.empty + ): AllowlistAuthProvider = { + val mockAuthProvider: AllowlistAuthProvider = mock[AllowlistAuthProvider](defaultMockitoAnswer[IO]) + + when(mockAuthProvider.checkUserEnabled(isEq(userInfo))(any)).thenReturn(IO.unit) + when( + mockAuthProvider.listResourceIds[RuntimeSamResourceId](isEq(true), isEq(userInfo))( + any(runtimeSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[RuntimeSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(readerRuntimeSamIds)) + when( + mockAuthProvider.listResourceIds[WsmResourceSamResourceId](isEq(false), isEq(userInfo))( + any(wsmResourceSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[WsmResourceSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(readerWsmSamIds)) + when( + mockAuthProvider.listResourceIds[WorkspaceResourceSamResourceId](isEq(false), isEq(userInfo))( + any(workspaceSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[WorkspaceResourceSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(readerWorkspaceSamIds)) + when( + mockAuthProvider.listResourceIds[ProjectSamResourceId](isEq(false), isEq(userInfo))( + any(projectSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[ProjectSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ) + .thenReturn(IO.pure(readerProjectSamIds)) + when( + mockAuthProvider.listResourceIds[WorkspaceResourceSamResourceId](isEq(true), isEq(userInfo))( + any(workspaceSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[WorkspaceResourceSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ) + .thenReturn(IO.pure(ownerWorkspaceSamIds)) + when( + mockAuthProvider.listResourceIds[ProjectSamResourceId](isEq(true), isEq(userInfo))( + any(projectSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[ProjectSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ) + .thenReturn(IO.pure(ownerProjectSamIds)) + + mockAuthProvider + } + + /** + * Generate a mocked AuthProvider which will permit action on the given resource IDs by the given user, + * when the list request is restricted to one workspace. Expects isUserWorkspace* instead of listResourceIds. + * TODO: cover actions beside `checkUserEnabled` and `listResourceIds` + * + * @param userInfo + * @param readerRuntimeSamIds + * @param readerWorkspaceSamIds + * @param readerProjectSamIds + * @param ownerWorkspaceSamIds + * @param ownerProjectSamIds + * @return + */ + def mockAuthorizeForOneWorkspace( + userInfo: UserInfo, + readerRuntimeSamIds: Set[RuntimeSamResourceId] = Set.empty, + readerWsmSamIds: Set[WsmResourceSamResourceId] = Set.empty, + readerWorkspaceSamIds: Set[WorkspaceResourceSamResourceId] = Set.empty, + readerProjectSamIds: Set[ProjectSamResourceId] = Set.empty, + ownerWorkspaceSamIds: Set[WorkspaceResourceSamResourceId] = Set.empty, + ownerProjectSamIds: Set[ProjectSamResourceId] = Set.empty + ): AllowlistAuthProvider = { + val mockAuthProvider: AllowlistAuthProvider = mock[AllowlistAuthProvider](defaultMockitoAnswer[IO]) + + when(mockAuthProvider.checkUserEnabled(isEq(userInfo))(any)).thenReturn(IO.unit) + when( + mockAuthProvider.listResourceIds[RuntimeSamResourceId](isEq(true), isEq(userInfo))( + any(runtimeSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[RuntimeSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(readerRuntimeSamIds)) + when( + mockAuthProvider.listResourceIds[WsmResourceSamResourceId](isEq(false), isEq(userInfo))( + any(wsmResourceSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[WsmResourceSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(readerWsmSamIds)) + when( + mockAuthProvider.isUserWorkspaceReader(any, isEq(userInfo))( + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(false)) + when( + mockAuthProvider.isUserWorkspaceReader(argThat(readerWorkspaceSamIds.contains(_)), isEq(userInfo))( + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(true)) + when( + mockAuthProvider.listResourceIds[ProjectSamResourceId](isEq(false), isEq(userInfo))( + any(projectSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[ProjectSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ) + .thenReturn(IO.pure(readerProjectSamIds)) + when( + mockAuthProvider.isUserWorkspaceOwner(any, isEq(userInfo))( + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(false)) + when( + mockAuthProvider.isUserWorkspaceOwner(argThat(ownerWorkspaceSamIds.contains(_)), isEq(userInfo))( + any(Ask[IO, TraceId].getClass) + ) + ).thenReturn(IO.pure(true)) + when( + mockAuthProvider.listResourceIds[ProjectSamResourceId](isEq(true), isEq(userInfo))( + any(projectSamResourceAction.getClass), + any(AppSamResourceAction.getClass), + any(Decoder[ProjectSamResourceId].getClass), + any(Ask[IO, TraceId].getClass) + ) + ) + .thenReturn(IO.pure(ownerProjectSamIds)) + + mockAuthProvider + } + def mockUserInfo(email: String = userEmail.toString()): UserInfo = UserInfo(OAuth2BearerToken(""), WorkbenchUserId(s"userId-${email}"), WorkbenchEmail(email), 0) val runtimeV2Service = new RuntimeV2ServiceInterp[IO]( serviceConfig, + allowListAuthProvider, QueueFactory.makePublisherQueue(), QueueFactory.makeDateAccessedQueue(), wsmClientProvider, @@ -102,6 +282,7 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with val runtimeV2Service2 = new RuntimeV2ServiceInterp[IO]( serviceConfig, + allowListAuthProvider2, QueueFactory.makePublisherQueue(), QueueFactory.makeDateAccessedQueue(), wsmClientProvider, @@ -357,8 +538,6 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with _ <- persistentDiskQuery.updateStatus(disk.id, DiskStatus.Ready, now).transaction runtime <- clusterQuery.getClusterWithDiskId(disk.id).transaction - _ = when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(runtime.get.internalId))) err <- runtimeV2Service .createRuntime(userInfo, name1, workspaceId, true, defaultCreateAzureRuntimeReq) @@ -377,11 +556,6 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with runtimeV2Service .createRuntime(userInfo, name0, workspaceId, false, defaultCreateAzureRuntimeReq) .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) - val runtime = - runtimeV2Service.getRuntime(userInfo, name0, workspaceId).unsafeRunSync()(cats.effect.unsafe.IORuntime.global) - - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(runtime.samResource.resourceId))) val exc = runtimeV2Service .createRuntime(userInfo, name2, workspaceId, false, defaultCreateAzureRuntimeReq) @@ -1261,7 +1435,7 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with when(samService.getUserEmail(userInfo3.accessToken.token)).thenReturn(IO.pure(userInfo3.userEmail)) val publisherQueue = QueueFactory.makePublisherQueue() - val azureService = makeInterp(publisherQueue, samService = samService) + val azureService = makeInterp(publisherQueue) val res = for { context <- appContext.ask[AppContext] @@ -1320,10 +1494,6 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with preDeleteCluster_1 <- RuntimeServiceDbQueries.getActiveRuntimeRecord(workspaceId, runtimeName_1).transaction preDeleteCluster_2 <- RuntimeServiceDbQueries.getActiveRuntimeRecord(workspaceId, runtimeName_2).transaction preDeleteCluster_3 <- RuntimeServiceDbQueries.getActiveRuntimeRecord(workspaceId, runtimeName_3).transaction - _ = when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn( - IO.pure(List(preDeleteCluster_1.internalId, preDeleteCluster_2.internalId, preDeleteCluster_3.internalId)) - ) _ <- clusterQuery.updateClusterStatus(preDeleteCluster_1.id, RuntimeStatus.Deleted, context.now).transaction _ <- clusterQuery.updateClusterStatus(preDeleteCluster_2.id, RuntimeStatus.Running, context.now).transaction @@ -1398,7 +1568,7 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with when(samService.getUserEmail(userInfo2.accessToken.token)).thenReturn(IO.pure(userInfo2.userEmail)) val publisherQueue = QueueFactory.makePublisherQueue() - val azureService = makeInterp(publisherQueue, samService = samService) + val azureService = makeInterp(publisherQueue) val res = for { context <- appContext.ask[AppContext] @@ -1440,8 +1610,7 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with _ <- clusterQuery.updateClusterStatus(preDeleteCluster_1.id, RuntimeStatus.Creating, context.now).transaction preDeleteCluster_2 = preDeleteClusterOpt_2.get _ <- clusterQuery.updateClusterStatus(preDeleteCluster_2.id, RuntimeStatus.Running, context.now).transaction - _ = when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(preDeleteCluster_1.samResource.resourceId, preDeleteCluster_2.samResource.resourceId))) + _ <- azureService.deleteAllRuntimes(userInfo, workspaceId, true) } yield () @@ -1460,7 +1629,15 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with val samService = mock[SamService[IO]] when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) .thenReturn(IO.pure(List(runtimeId1, runtimeId2))) - val testService = makeInterp(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + Set(RuntimeSamResourceId(runtimeId1), RuntimeSamResourceId(runtimeId2)), + Set.empty, + Set(WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceIdAzure)))), + Set(ProjectSamResourceId(GoogleProject(projectIdGcp))) + ) + + val testService = makeInterp(authProvider = mockAuthProvider, samService = samService) val res = for { samResource1 <- IO(RuntimeSamResourceId(runtimeId1)) @@ -1501,6 +1678,171 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) } + it should "list runtimes, omitting runtimes for workspaces and projects user cannot read" in isolatedDbTest { + val runtimeId1 = UUID.randomUUID.toString + val runtimeId2 = UUID.randomUUID.toString + val runtimeId3 = UUID.randomUUID.toString + val runtimeId4 = UUID.randomUUID.toString + val projectIdGcp1 = "gcp-context-1" + val projectIdGcp2 = "gcp-context-2" + val workspaceIdAzure1 = UUID.randomUUID.toString + val workspaceIdAzure2 = UUID.randomUUID.toString + + val userInfo = mockUserInfo("jerome@vore.gov") + val mockAuthProvider = mockAuthorize( + userInfo, + // user can read runtimes which are 'notebook-cluster' aka Runtimes + Set(RuntimeSamResourceId(runtimeId1), RuntimeSamResourceId(runtimeId2), RuntimeSamResourceId(runtimeId3)), + // user can read runtimes which are WsmResources + Set( + WsmResourceSamResourceId(WsmControlledResourceId(UUID.fromString(runtimeId3))), + WsmResourceSamResourceId(WsmControlledResourceId(UUID.fromString(runtimeId4))) + ), + // user can only read workspace1 + Set(WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceIdAzure1)))), + // user can only read project1 + Set(ProjectSamResourceId(GoogleProject(projectIdGcp1))) + ) + val testService = makeInterp(authProvider = mockAuthProvider) + + val res = for { + // GCP runtime 1 (in project1): seen + samResource1 <- IO(RuntimeSamResourceId(runtimeId1)) + runtime1 <- IO( + makeCluster(1) + .copy(samResource = samResource1, cloudContext = CloudContext.Gcp(GoogleProject(projectIdGcp1))) + .save() + ) + // GCP runtime 2 (in project2): hidden + samResource2 <- IO(RuntimeSamResourceId(runtimeId2)) + runtime2 <- IO( + makeCluster(2) + .copy(samResource = samResource2, cloudContext = CloudContext.Gcp(GoogleProject(projectIdGcp2))) + .save() + ) + // Azure runtime 3 (in workspace1): seen + samResource3 <- IO(RuntimeSamResourceId(runtimeId3)) + runtime3 <- IO( + makeCluster(3) + .copy( + samResource = samResource3, + cloudContext = CloudContext.Azure(CommonTestData.azureCloudContext), + workspaceId = Some(WorkspaceId(UUID.fromString(workspaceIdAzure1))) + ) + .save() + ) + // Azure runtime 4 (in workspace2): hidden + samResource4 <- IO(RuntimeSamResourceId(runtimeId4)) + runtime4 <- IO( + makeCluster(4) + .copy( + samResource = samResource4, + cloudContext = CloudContext.Azure(CommonTestData.azureCloudContext), + workspaceId = Some(WorkspaceId(UUID.fromString(workspaceIdAzure2))) + ) + .save() + ) + listResponse <- testService.listRuntimes(userInfo, None, None, Map.empty) + } yield listResponse.map(_.samResource).toSet shouldBe Set(samResource1, samResource3) + + res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + + it should "list runtimes given different user permissions" in isolatedDbTest { + forAll( + Table( + ("context", "runtimeAccess", "contextAccess", "isListed"), + // Any runtime access; no access to wrapper context => hidden + (TestContext.GoogleProject, TestRuntimeAccess.Nothing, TestContextAccess.Nothing, false), + (TestContext.GoogleWorkspace, TestRuntimeAccess.Nothing, TestContextAccess.Nothing, false), + (TestContext.AzureWorkspace, TestRuntimeAccess.Nothing, TestContextAccess.Nothing, false), + (TestContext.GoogleProject, TestRuntimeAccess.Reader, TestContextAccess.Nothing, false), + (TestContext.GoogleWorkspace, TestRuntimeAccess.Reader, TestContextAccess.Nothing, false), + (TestContext.AzureWorkspace, TestRuntimeAccess.Reader, TestContextAccess.Nothing, false), + // No access to runtime; read access to wrapper context => hidden + (TestContext.GoogleProject, TestRuntimeAccess.Nothing, TestContextAccess.Reader, false), + (TestContext.GoogleWorkspace, TestRuntimeAccess.Nothing, TestContextAccess.Reader, false), + (TestContext.AzureWorkspace, TestRuntimeAccess.Nothing, TestContextAccess.Reader, false), + // Read access to runtime; read access to wrapper context => shown + (TestContext.GoogleProject, TestRuntimeAccess.Reader, TestContextAccess.Reader, true), + (TestContext.GoogleWorkspace, TestRuntimeAccess.Reader, TestContextAccess.Reader, true), + (TestContext.AzureWorkspace, TestRuntimeAccess.Reader, TestContextAccess.Reader, true), + // Any runtime access; owner of wrapper context => shown + (TestContext.GoogleProject, TestRuntimeAccess.Nothing, TestContextAccess.Owner, true), + (TestContext.GoogleWorkspace, TestRuntimeAccess.Nothing, TestContextAccess.Owner, true), + (TestContext.AzureWorkspace, TestRuntimeAccess.Nothing, TestContextAccess.Owner, true), + (TestContext.GoogleProject, TestRuntimeAccess.Reader, TestContextAccess.Owner, true), + (TestContext.GoogleWorkspace, TestRuntimeAccess.Reader, TestContextAccess.Owner, true), + (TestContext.AzureWorkspace, TestRuntimeAccess.Reader, TestContextAccess.Owner, true) + ) + ) { + ( + context: TestContext.Context, + runtimeAccess: TestRuntimeAccess.Role, + contextAccess: TestContextAccess.Role, + isListed: Boolean + ) => + val runtimeId = UUID.randomUUID.toString + val contextId = UUID.randomUUID.toString + + val userInfo = mockUserInfo("jerome@vore.gov") + val mockAuthProvider = mockAuthorize( + userInfo, + readerRuntimeSamIds = Set(RuntimeSamResourceId(runtimeId)) + .filter(_ => runtimeAccess == TestRuntimeAccess.Reader), + Set.empty, + readerWorkspaceSamIds = Set(WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(contextId)))) + .filter(_ => context != TestContext.GoogleProject && contextAccess != TestContextAccess.Nothing), + readerProjectSamIds = Set(ProjectSamResourceId(GoogleProject(contextId))) + .filter(_ => context == TestContext.GoogleProject && contextAccess != TestContextAccess.Nothing), + ownerWorkspaceSamIds = Set(WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(contextId)))) + .filter(_ => context != TestContext.GoogleProject && contextAccess == TestContextAccess.Owner), + ownerProjectSamIds = Set(ProjectSamResourceId(GoogleProject(contextId))) + .filter(_ => context == TestContext.GoogleProject && contextAccess == TestContextAccess.Owner) + ) + val testService = makeInterp(authProvider = mockAuthProvider) + + val res = for { + projectRuntime <- IO( + makeCluster(1) + .copy( + samResource = RuntimeSamResourceId(runtimeId), + cloudContext = CloudContext.Gcp(GoogleProject(contextId)) + ) + ) + googleWorkspaceRuntime <- IO( + makeCluster(2) + .copy( + samResource = RuntimeSamResourceId(runtimeId), + cloudContext = CloudContext.Gcp(GoogleProject(contextId)), + workspaceId = Some(WorkspaceId(UUID.fromString(contextId))) + ) + ) + azureWorkspaceRuntime <- IO( + makeCluster(3) + .copy( + samResource = RuntimeSamResourceId(runtimeId), + cloudContext = CloudContext.Azure( + AzureCloudContext(TenantId(contextId), SubscriptionId(contextId), ManagedResourceGroupName(contextId)) + ), + workspaceId = Some(WorkspaceId(UUID.fromString(contextId))) + ) + ) + runtime = context match { + case TestContext.GoogleProject => projectRuntime + case TestContext.GoogleWorkspace => googleWorkspaceRuntime + case TestContext.AzureWorkspace => azureWorkspaceRuntime + } + _ = runtime.save() + expectedResults = if (isListed) Set(runtime.samResource) else Set.empty + + listResponse <- testService.listRuntimes(userInfo, None, None, Map.empty) + } yield listResponse.map(_.samResource).toSet shouldBe expectedResults + + res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + } + it should "list runtimes with a workspace and/or cloudProvider" in isolatedDbTest { val runtimeId1 = UUID.randomUUID.toString val runtimeId2 = UUID.randomUUID.toString @@ -1513,11 +1855,45 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with val workspaceId2 = UUID.randomUUID.toString val workspaceId3 = UUID.randomUUID.toString - val samService = mock[SamService[IO]] - when(samService.listResources(any(), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(runtimeId1, runtimeId2, runtimeId3, runtimeId4, runtimeId5))) - - val testService = makeInterp(samService = samService) + val userInfo = mockUserInfo("jerome@vore.gov") + val mockAuthProvider = mockAuthorize( + userInfo, + // user can read runtimes 3, 4, and 5 + Set(RuntimeSamResourceId(runtimeId3), RuntimeSamResourceId(runtimeId4), RuntimeSamResourceId(runtimeId5)), + Set.empty, + // user can read all workspaces + Set( + WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId1))), + WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId2))), + WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId3))) + ), + // user can read all projects + Set(ProjectSamResourceId(GoogleProject(projectIdGcp1)), ProjectSamResourceId(GoogleProject(projectIdGcp2))), + // user owns workspace 1 + Set(WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId1)))), + // user owns project 1 + Set(ProjectSamResourceId(GoogleProject(projectIdGcp1))) + ) + val mockAuthProviderForOneWorkspace = mockAuthorizeForOneWorkspace( + userInfo, + // user can read runtimes 3, 4, and 5 + Set(RuntimeSamResourceId(runtimeId3), RuntimeSamResourceId(runtimeId4), RuntimeSamResourceId(runtimeId5)), + Set.empty, + // user can read all workspaces + Set( + WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId1))), + WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId2))), + WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId3))) + ), + // user can read all projects + Set(ProjectSamResourceId(GoogleProject(projectIdGcp1)), ProjectSamResourceId(GoogleProject(projectIdGcp2))), + // user owns workspace 1 + Set(WorkspaceResourceSamResourceId(WorkspaceId(UUID.fromString(workspaceId1)))), + // user owns project 1 + Set(ProjectSamResourceId(GoogleProject(projectIdGcp1))) + ) + val testService = makeInterp(authProvider = mockAuthProvider) + val testServiceForOneWorkspace = makeInterp(authProvider = mockAuthProviderForOneWorkspace) val res = for { samResource1 <- IO(RuntimeSamResourceId(runtimeId1)) @@ -1588,41 +1964,38 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with .save() ) - responseIdsWorkspace1 <- testService.listRuntimes(userInfo, Some(workspace1), None, Map.empty) - responseIdsWorkspace2 <- testService.listRuntimes(userInfo, Some(workspace2), None, Map.empty) - responseIdsWorkspace3 <- testService.listRuntimes(userInfo, Some(workspace3), None, Map.empty) - responseIdsAzure <- testService.listRuntimes(userInfo, None, Some(CloudProvider.Azure), Map.empty) - responseIdsGcp <- testService.listRuntimes(userInfo, None, Some(CloudProvider.Gcp), Map.empty) - responseIdsAzureWorkspace1 <- testService.listRuntimes(userInfo, - Some(workspace1), - Some(CloudProvider.Azure), - Map.empty - ) - responseIdsAzureWorkspace2 <- testService.listRuntimes(userInfo, - Some(workspace2), - Some(CloudProvider.Azure), - Map.empty - ) - responseIdsGcpWorkspace1 <- testService.listRuntimes(userInfo, - Some(workspace1), - Some(CloudProvider.Gcp), - Map.empty - ) - responseIdsGcpWorkspace2 <- testService.listRuntimes(userInfo, - Some(workspace2), - Some(CloudProvider.Gcp), - Map.empty - ) + // Test the service call and pluck Sam resource IDs to compare with expected results + getResultIds = ( + userInfo: UserInfo, + workspaceId: Option[WorkspaceId], + cloudProvider: Option[CloudProvider], + params: Map[String, String] + ) => { + val service = if (workspaceId.isEmpty) testService else testServiceForOneWorkspace + service + .listRuntimes(userInfo, workspaceId, cloudProvider, params) + .flatMap(result => IO(result.map(_.samResource).toSet)) + } + + responseIdsWorkspace1 <- getResultIds(userInfo, Some(workspace1), None, Map.empty) + responseIdsWorkspace2 <- getResultIds(userInfo, Some(workspace2), None, Map.empty) + responseIdsWorkspace3 <- getResultIds(userInfo, Some(workspace3), None, Map.empty) + responseIdsAzure <- getResultIds(userInfo, None, Some(CloudProvider.Azure), Map.empty) + responseIdsGcp <- getResultIds(userInfo, None, Some(CloudProvider.Gcp), Map.empty) + responseIdsAzureWorkspace1 <- getResultIds(userInfo, Some(workspace1), Some(CloudProvider.Azure), Map.empty) + responseIdsAzureWorkspace2 <- getResultIds(userInfo, Some(workspace2), Some(CloudProvider.Azure), Map.empty) + responseIdsGcpWorkspace1 <- getResultIds(userInfo, Some(workspace1), Some(CloudProvider.Gcp), Map.empty) + responseIdsGcpWorkspace2 <- getResultIds(userInfo, Some(workspace2), Some(CloudProvider.Gcp), Map.empty) } yield { - responseIdsWorkspace1.map(_.samResource).toSet shouldBe Set(samResource1) - responseIdsWorkspace2.map(_.samResource).toSet shouldBe Set(samResource2, samResource3) - responseIdsWorkspace3.map(_.samResource).toSet shouldBe Set(samResource4) - responseIdsAzure.map(_.samResource).toSet shouldBe Set(samResource1, samResource4) - responseIdsGcp.map(_.samResource).toSet shouldBe Set(samResource2, samResource3, samResource5) - responseIdsAzureWorkspace1.map(_.samResource).toSet shouldBe Set(samResource1) - responseIdsAzureWorkspace2.map(_.samResource).toSet shouldBe Set.empty - responseIdsGcpWorkspace1.map(_.samResource).toSet shouldBe Set.empty - responseIdsGcpWorkspace2.map(_.samResource).toSet shouldBe Set(samResource2, samResource3) + responseIdsWorkspace1 shouldBe Set(samResource1) + responseIdsWorkspace2 shouldBe Set(samResource2, samResource3) + responseIdsWorkspace3 shouldBe Set(samResource4) + responseIdsAzure shouldBe Set(samResource1, samResource4) + responseIdsGcp shouldBe Set(samResource2, samResource3, samResource5) + responseIdsAzureWorkspace1 shouldBe Set(samResource1) + responseIdsAzureWorkspace2 shouldBe Set.empty + responseIdsGcpWorkspace1 shouldBe Set.empty + responseIdsGcpWorkspace2 shouldBe Set(samResource2, samResource3) } res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) @@ -1632,11 +2005,16 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with val runtimeId1 = RuntimeSamResourceId(UUID.randomUUID.toString) val runtimeId2 = RuntimeSamResourceId(UUID.randomUUID.toString) val workspaceId1 = WorkspaceId(UUID.randomUUID) - - val samService = mock[SamService[IO]] - when(samService.listResources(any(), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(runtimeId1.resourceId, runtimeId2.resourceId))) - val testService = makeInterp(samService = samService) + val userInfo = mockUserInfo("karen@styx.hel") + val mockAuthProvider = mockAuthorize( + userInfo, + // can read all runtimes + Set(runtimeId1, runtimeId2), + Set.empty, + // can read all workspaces + Set(WorkspaceResourceSamResourceId(workspaceId1)) + ) + val testService = makeInterp(authProvider = mockAuthProvider) val res = for { samResource1 <- IO(runtimeId1) samResource2 <- IO(runtimeId2) @@ -1705,13 +2083,15 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with val workspaceId1 = WorkspaceId(UUID.randomUUID) val userInfoCreator = mockUserInfo("karen@styx.hel") val userInfoOther = mockUserInfo("mike@heavn.io") - val samService = mock[SamService[IO]] - when( - samService.listResources(isEq(userInfoCreator.accessToken.token), isEq(RuntimeSamResource.resourceType))(any()) + val mockAuthProvider = mockAuthorize( + userInfoCreator, + // user has auth permission for runtime1 + Set.empty, + Set(wsmId1), + // user can read workspace1 + Set(WorkspaceResourceSamResourceId(workspaceId1)) ) - .thenReturn(IO.pure(List(wsmId1.resourceId, runtimeId3.resourceId))) - - val testService = makeInterp(samService = samService) + val testService = makeInterp(authProvider = mockAuthProvider) val res = for { // runtime 1: I created, in a workspace I can read => visible samResource1 <- IO(RuntimeSamResourceId(wsmId1.resourceId.toString)) @@ -1721,7 +2101,7 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with .save() ) - // runtime 2: I created, but I don't have permission => hidden + // runtime 2: I created, but in a workspace I cannot read, and I DO NOT HAVE SAM PERMISSION => hidden samResource2 <- IO(runtimeId2) runtime2 <- IO( makeCluster(2, Some(userInfoCreator.userEmail)) @@ -1729,7 +2109,7 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with .save() ) - // runtime 3: someone else created, but I can read => hidden if role=creator, else visible + // runtime 3: someone else created, in a workspace I can read => hidden samResource3 <- IO(runtimeId3) runtime3 <- IO( makeCluster(3, Some(userInfoOther.userEmail)) @@ -1737,11 +2117,19 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with .save() ) + // runtime 4: I created, in a workspace I can read, and I DO NOT HAVE SAM PERMISSION => seen if role=creator, else hid + samResource4 <- IO(runtimeId4) + runtime4 <- IO( + makeCluster(4, Some(userInfoCreator.userEmail)) + .copy(samResource = samResource4, workspaceId = Some(workspaceId1)) + .save() + ) + listResponseCreator <- testService.listRuntimes(userInfoCreator, None, None, Map("role" -> "creator")) listResponseAny <- testService.listRuntimes(userInfoCreator, None, None, Map.empty) } yield { - listResponseCreator.map(_.samResource).toSet shouldBe Set(samResource1) - listResponseAny.map(_.samResource).toSet shouldBe Set(samResource1, samResource3) + listResponseCreator.map(_.samResource).toSet shouldBe Set(samResource1, samResource4) + listResponseAny.map(_.samResource).toSet shouldBe Set(samResource1) } res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) @@ -1754,11 +2142,17 @@ class RuntimeV2ServiceInterpSpec extends AnyFlatSpec with LeonardoTestSuite with val runtimeId2 = RuntimeSamResourceId(UUID.randomUUID.toString) val workspaceId1 = WorkspaceId(UUID.randomUUID) val userInfo = mockUserInfo("karen@styx.hel") - val samService = mock[SamService[IO]] - when(samService.listResources(isEq(userInfo.accessToken.token), isEq(RuntimeSamResource.resourceType))(any())) - .thenReturn(IO.pure(List(runtimeId1.resourceId, runtimeId2.resourceId))) - - val testService = makeInterp(samService = samService) + val mockAuthProvider = mockAuthorize( + userInfo, + // can read all runtimes + Set(runtimeId1, runtimeId2), + Set.empty, + // can read workspace + Set(WorkspaceResourceSamResourceId(workspaceId1)), + // owns workspace + ownerWorkspaceSamIds = Set(WorkspaceResourceSamResourceId(workspaceId1)) + ) + val testService = makeInterp(authProvider = mockAuthProvider) // Make runtimes belonging to different users than the calling user val res = for { diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitorSpec.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitorSpec.scala index 63ba08f4503..a0a3f2a27b2 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitorSpec.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/monitor/BaseCloudServiceRuntimeMonitorSpec.scala @@ -101,6 +101,7 @@ class BaseCloudServiceRuntimeMonitorSpec extends AnyFlatSpec with Matchers with disk <- makePersistentDisk().save() start <- IO.realTimeInstant tid <- traceId.ask[TraceId] + implicit0(ec: ExecutionContext) = scala.concurrent.ExecutionContext.Implicits.global runtime <- IO( makeCluster(0) .copy(status = RuntimeStatus.Creating) @@ -115,12 +116,14 @@ class BaseCloudServiceRuntimeMonitorSpec extends AnyFlatSpec with Matchers with runtimeConfig <- RuntimeConfigQueries .getRuntimeConfig(runtime.runtimeConfigId)(scala.concurrent.ExecutionContext.Implicits.global) .transaction + diskStatus <- persistentDiskQuery.getStatus(disk.id).transaction } yield { // handleCheckTools should have been interrupted after 10 seconds and moved the runtime to Error status elapsed shouldBe 10000L +- 2000L status shouldBe Some(RuntimeStatus.Error) res shouldBe (((), None)) - runtimeConfig.asInstanceOf[RuntimeConfig.GceWithPdConfig].persistentDiskId shouldBe None + runtimeConfig.asInstanceOf[RuntimeConfig.GceWithPdConfig].persistentDiskId shouldBe Some(disk.id) + diskStatus shouldBe (Some(DiskStatus.Ready)) } res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) @@ -141,6 +144,8 @@ class BaseCloudServiceRuntimeMonitorSpec extends AnyFlatSpec with Matchers with disk <- makePersistentDisk().save() start <- IO.realTimeInstant tid <- traceId.ask[TraceId] + implicit0(ec: ExecutionContext) = scala.concurrent.ExecutionContext.Implicits.global + runtime <- IO( makeCluster(0) .copy(status = RuntimeStatus.Creating) @@ -161,13 +166,16 @@ class BaseCloudServiceRuntimeMonitorSpec extends AnyFlatSpec with Matchers with runtimeConfig <- RuntimeConfigQueries .getRuntimeConfig(runtime.runtimeConfigId)(scala.concurrent.ExecutionContext.Implicits.global) .transaction + diskStatus <- persistentDiskQuery.getStatus(disk.id).transaction } yield { customInterval shouldBe 1.seconds // handleCheckTools should have been interrupted after 5 seconds and moved the runtime to Error status elapsed shouldBe 5000L +- 1000L status shouldBe Some(RuntimeStatus.Error) res shouldBe (((), None)) - runtimeConfig.asInstanceOf[RuntimeConfig.GceWithPdConfig].persistentDiskId shouldBe None + runtimeConfig.asInstanceOf[RuntimeConfig.GceWithPdConfig].persistentDiskId shouldBe Some(disk.id) + diskStatus shouldBe (Some(DiskStatus.Ready)) + } res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) @@ -303,6 +311,47 @@ class BaseCloudServiceRuntimeMonitorSpec extends AnyFlatSpec with Matchers with res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) } + it should "detach Ready disk on failed runtime create" in isolatedDbTest { + val runtimeMonitor = baseRuntimeMonitor(false) + + val res = for { + start <- IO.realTimeInstant + tid <- traceId.ask[TraceId] + implicit0(ec: ExecutionContext) = scala.concurrent.ExecutionContext.Implicits.global + disk <- makePersistentDisk().save() + _ <- persistentDiskQuery.updateStatus(disk.id, DiskStatus.Ready, Instant.now()).transaction + runtime <- IO( + makeCluster(0) + .copy(status = RuntimeStatus.Creating) + .saveWithRuntimeConfig(CommonTestData.defaultGceRuntimeWithPDConfig(Some(disk.id))) + ) + runtimeConfig <- RuntimeConfigQueries.getRuntimeConfig(runtime.runtimeConfigId).transaction + + runtimeAndRuntimeConfig = RuntimeAndRuntimeConfig(runtime, runtimeConfig) + monitorContext = MonitorContext(start, runtime.id, tid, RuntimeStatus.Creating) + runCheckTools = Stream.eval( + runtimeMonitor.handleCheckTools(monitorContext, runtimeAndRuntimeConfig, IP("1.2.3.4"), None, true, None) + ) + deleteRuntime = Stream.sleep[IO](2 seconds) ++ Stream.eval( + clusterQuery.completeDeletion(runtime.id, start).transaction + ) + // run above tasks concurrently and wait for both to terminate + _ <- Stream(runCheckTools, deleteRuntime).parJoin(2).compile.drain.timeout(15 seconds) + + end <- IO.realTimeInstant + elapsed = end.toEpochMilli - start.toEpochMilli + status <- clusterQuery.getClusterStatus(runtime.id).transaction + diskStatus <- persistentDiskQuery.getStatus(disk.id).transaction + } yield { + // handleCheckTools should have timed out after 10 seconds and the runtime should remain in Deleted status + elapsed should be >= 10000L + status shouldBe Some(RuntimeStatus.Deleted) + diskStatus shouldBe (Some(DiskStatus.Ready)) + } + + res.unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + class MockRuntimeMonitor(isWelderReady: Boolean, timeouts: Map[RuntimeStatus, FiniteDuration], googleStorageService: GoogleStorageService[IO] = FakeGoogleStorageService diff --git a/project/Dependencies.scala b/project/Dependencies.scala index c3b4f56b076..e1c2d47b3de 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -26,7 +26,7 @@ object Dependencies { val workbenchOauth2V = "0.8-3e0cf25" val workbenchAzureV = s"0.8-$workbenchLibsHash" - val helmScalaSdkV = "0.0.8.5" + val helmScalaSdkV = "0.0.9.1" val excludeAkkaHttp = ExclusionRule(organization = "com.typesafe.akka", name = s"akka-http_${scalaV}") val excludeAkkaStream = ExclusionRule(organization = "com.typesafe.akka", name = s"akka-stream_${scalaV}")