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

[CORE-182] Move listRuntimes over to new Sam permissions model #4810

Merged
merged 11 commits into from
Jan 2, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ 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.{
ProjectSamResourceId,
RuntimeSamResourceId,
WorkspaceResourceSamResourceId
}
import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.RuntimeSamResourceId
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 @@ -28,7 +24,6 @@ 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
Expand Down Expand Up @@ -70,6 +65,7 @@ object RuntimeServiceDbQueries {
Option[WorkspaceId],
Option[(String, String)]
)

private object ListRuntimesRecord {
def apply(product: ListRuntimesProduct): ListRuntimesRecord = product match {
case (l,
Expand Down Expand Up @@ -307,33 +303,26 @@ 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 readerRuntimeIds
* @param runtimeIds
* @param readerWorkspaceIds
* @param ownerWorkspaceIds
* @param readerGoogleProjectIds
* @param ownerGoogleProjectIds
* @return
*/
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
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
)(implicit ec: ExecutionContext): DBIO[Vector[ListRuntimeResponse2]] = {
// Normalize filter params
val provider = if (cloudProvider.isEmpty) {
Expand All @@ -343,93 +332,8 @@ object RuntimeServiceDbQueries {
}
} else cloudProvider

// 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
val runtimes = clusterQuery
.filter(_.internalId inSetBind runtimeIds.map(_.asString))
Copy link
Collaborator

Choose a reason for hiding this comment

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

wow this is a huge improvement, thanks!

.filterOpt(workspaceId) { case (runtime, wId) =>
runtime.workspaceId === (Some(wId): Rep[Option[WorkspaceId]])
}
Expand Down Expand Up @@ -458,9 +362,6 @@ object RuntimeServiceDbQueries {
)
.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 @@ -498,7 +399,7 @@ object RuntimeServiceDbQueries {
)
}

runtimesJoined.result
runtimes.result
.map { records: Seq[ListRuntimesProduct] =>
records
.map(record => ListRuntimesRecord(record))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ 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,
Expand All @@ -39,6 +38,7 @@ import org.broadinstitute.dsde.workbench.leonardo.model.SamResourceAction.{
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 @@ -267,36 +267,19 @@ class RuntimeServiceInterp[F[_]: Parallel](
for {
ctx <- as.ask

// 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)))
samResources <- samService.listResources(userInfo.accessToken.token, RuntimeSamResource.resourceType)

(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(
// 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
.listRuntimes(samResources.map(RuntimeSamResourceId).toSet,
excludeStatuses = excludeStatuses,
creatorEmail = creatorOnly,
cloudContext = cloudContext,
labelMap = labelMap
)
.transaction

Expand Down Expand Up @@ -1006,56 +989,6 @@ class RuntimeServiceInterp[F[_]: Parallel](
else Async[F].pure((mt, true))
}
} yield targetMachineType

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 {
Expand Down
Loading
Loading