Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Revert "[CORE-182] Move listRuntimes over to new Sam permissions model (#4810)" #4823

Merged
merged 2 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
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
Expand All @@ -24,6 +28,7 @@
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
Expand Down Expand Up @@ -65,7 +70,6 @@
Option[WorkspaceId],
Option[(String, String)]
)

private object ListRuntimesRecord {
def apply(product: ListRuntimesProduct): ListRuntimesRecord = product match {
case (l,
Expand Down Expand Up @@ -303,26 +307,33 @@

/**
* 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) {
Expand All @@ -332,8 +343,93 @@
}
} 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

Check warning on line 410 in http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueries.scala

View check run for this annotation

Codecov / codecov/patch

http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/RuntimeServiceDbQueries.scala#L408-L410

Added lines #L408 - L410 were not covered by tests
} 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]])
}
Expand Down Expand Up @@ -362,6 +458,9 @@
)
.length === labelMap.size
}

// Assemble response
val runtimesJoined = runtimesFiltered
.join(runtimeConfigs)
.on((runtime, runtimeConfig) => runtime.runtimeConfigId === runtimeConfig.id)
.map { case (runtime, runtimeConfig) =>
Expand Down Expand Up @@ -399,7 +498,7 @@
)
}

runtimes.result
runtimesJoined.result
.map { records: Seq[ListRuntimesProduct] =>
records
.map(record => ListRuntimesRecord(record))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class AppDependenciesBuilder(baselineDependenciesBuilder: BaselineDependenciesBu

val azureService = new RuntimeV2ServiceInterp[IO](
baselineDependencies.runtimeServicesConfig,
baselineDependencies.authProvider,
baselineDependencies.publisherQueue,
baselineDependencies.dateAccessedUpdaterQueue,
baselineDependencies.wsmClientProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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,
Expand All @@ -37,7 +38,6 @@
workspaceSamResourceAction
}
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._
Expand Down Expand Up @@ -249,19 +249,36 @@
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

Expand Down Expand Up @@ -823,6 +840,55 @@

_ <- checkRuntimeAction(userInfo, cloudContext, runtimeName, runtime.samResource, action, 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

Check warning on line 864 in http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterp.scala

View check run for this annotation

Codecov / codecov/patch

http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/RuntimeServiceInterp.scala#L862-L864

Added lines #L862 - L864 were not covered by tests
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 {
Expand Down
Loading
Loading