Skip to content

Commit

Permalink
ID-1328 system resource access policy creation on boot (#1482)
Browse files Browse the repository at this point in the history
  • Loading branch information
dvoet authored Jul 9, 2024
1 parent 94bb030 commit 90e69b5
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 4 deletions.
49 changes: 49 additions & 0 deletions src/main/resources/sam.conf
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,52 @@ janitor {
trackResourceTopicId = ${?JANITOR_TRACK_RESOURCE_TOPIC_ID}
}

terra.support.emails = [
// dynamically configuring lists is hard, so 10 slots are provided for support emails
// add more if more than 10 are ever needed
${?TERRA_SUPPORT_EMAIL_0}
${?TERRA_SUPPORT_EMAIL_1}
${?TERRA_SUPPORT_EMAIL_2}
${?TERRA_SUPPORT_EMAIL_3}
${?TERRA_SUPPORT_EMAIL_4}
${?TERRA_SUPPORT_EMAIL_5}
${?TERRA_SUPPORT_EMAIL_6}
${?TERRA_SUPPORT_EMAIL_7}
${?TERRA_SUPPORT_EMAIL_8}
${?TERRA_SUPPORT_EMAIL_9}
]

resourceAccessPolicies {
resource_type_admin {
workspace {
support {
memberEmails = ${terra.support.emails}
roles = ["support"]
}
}
managed-group {
support {
memberEmails = ${terra.support.emails}
roles = ["support"]
}
}
billing-project {
support {
memberEmails = ${terra.support.emails}
roles = ["support"]
}
}
dataset {
support {
memberEmails = ${terra.support.emails}
roles = ["support"]
}
}
datasnapshot {
support {
memberEmails = ${terra.support.emails}
roles = ["support"]
}
}
}
}
20 changes: 19 additions & 1 deletion src/main/scala/org/broadinstitute/dsde/workbench/sam/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import org.broadinstitute.dsde.workbench.google.{
HttpGoogleStorageDAO
}
import org.broadinstitute.dsde.workbench.google2.{GoogleStorageInterpreter, GoogleStorageService}
import org.broadinstitute.dsde.workbench.model.WorkbenchEmail
import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchException}
import org.broadinstitute.dsde.workbench.oauth2.{ClientId, OpenIDConnectConfiguration}
import org.broadinstitute.dsde.workbench.sam.api.{LivenessRoutes, SamRoutes, StandardSamUserDirectives}
import org.broadinstitute.dsde.workbench.sam.azure.{AzureService, CrlService}
Expand All @@ -44,6 +44,7 @@ import org.broadinstitute.dsde.workbench.sam.db.DbReference
import org.broadinstitute.dsde.workbench.sam.google._
import org.broadinstitute.dsde.workbench.sam.model._
import org.broadinstitute.dsde.workbench.sam.service._
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import org.broadinstitute.dsde.workbench.sam.util.Sentry.initSentry
import org.broadinstitute.dsde.workbench.util.DelegatePool
import org.typelevel.log4cats.StructuredLogger
Expand Down Expand Up @@ -80,6 +81,23 @@ object Boot extends IOApp with LazyLogging {

_ <- dependencies.policyEvaluatorService.initPolicy()

// make sure all users referenced by resourceAccessPolicies exist
_ <- appConfig.resourceAccessPolicies.flatMap { case (_, policy) => policy.memberEmails }.toList.traverse { email =>
dependencies.samApplication.userService.inviteUser(email, SamRequestContext()).attempt
}

// create resourceAccessPolicies
policyTrials <- dependencies.samApplication.resourceService.upsertResourceAccessPolicies(appConfig.resourceAccessPolicies)
_ = policyTrials.map {
case (policyId, Left(t)) =>
logger.error(s"FATAL - failure creating configured policy [$policyId] on startup", t)
case (policyId, Right(_)) =>
logger.info(s"Upserted configured policy [$policyId] at startup")
}
_ <- IO.raiseWhen(policyTrials.values.exists(_.isLeft))(
new WorkbenchException("FATAL - failure creating configured policy on startup, see above errors")
)

_ <- dependencies.cloudExtensionsInitializer.onBoot(dependencies.samApplication)

binding <- IO.fromFuture(IO(Http().newServerAt("0.0.0.0", 8080).bind(dependencies.samRoutes.route))).onError { case t: Throwable =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import org.broadinstitute.dsde.workbench.sam.config.AppConfig.AdminConfig
import org.broadinstitute.dsde.workbench.sam.config.GoogleServicesConfig.googleServicesConfigReader
import org.broadinstitute.dsde.workbench.sam.dataAccess.DistributedLockConfig
import org.broadinstitute.dsde.workbench.sam.model._
import org.broadinstitute.dsde.workbench.sam.model.api.AccessPolicyMembershipRequest

import java.time.Instant
import scala.concurrent.duration.Duration
import scala.jdk.CollectionConverters._

/** Created by dvoet on 7/18/17.
*/
Expand All @@ -30,7 +32,8 @@ final case class AppConfig(
adminConfig: AdminConfig,
azureServicesConfig: Option[AzureServicesConfig],
prometheusConfig: PrometheusConfig,
janitorConfig: JanitorConfig
janitorConfig: JanitorConfig,
resourceAccessPolicies: Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest]
)

object AppConfig {
Expand Down Expand Up @@ -228,6 +231,45 @@ object AppConfig {
)
}

implicit val accessPolicyDescendantPermissionsReader: ValueReader[AccessPolicyDescendantPermissions] = ValueReader.relative { config =>
AccessPolicyDescendantPermissions(
ResourceTypeName(config.as[String]("resourceTypeName")),
config.as[Option[Set[String]]]("actions").getOrElse(Set.empty).map(ResourceAction.apply),
config.as[Option[Set[String]]]("roles").getOrElse(Set.empty).map(ResourceRoleName.apply)
)
}

implicit val policyIdentifiersReader: ValueReader[PolicyIdentifiers] = ValueReader.relative { config =>
PolicyIdentifiers(
AccessPolicyName(config.as[String]("accessPolicyName")),
ResourceTypeName(config.as[String]("resourceTypeName")),
ResourceId(config.as[String]("resourceId"))
)
}

implicit val accessPolicyMembershipRequestReader: ValueReader[AccessPolicyMembershipRequest] = ValueReader.relative { config =>
AccessPolicyMembershipRequest(
config.as[Set[String]]("memberEmails").map(WorkbenchEmail),
config.as[Option[Set[String]]]("actions").getOrElse(Set.empty).map(ResourceAction.apply),
config.as[Option[Set[String]]]("roles").getOrElse(Set.empty).map(ResourceRoleName.apply),
config.as[Option[Set[AccessPolicyDescendantPermissions]]]("descendantPermissions"),
config.as[Option[Set[PolicyIdentifiers]]]("memberPolicies")
)
}

implicit val resourceAccessPoliciesConfigReader: ValueReader[Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest]] = ValueReader.relative { config =>
val policies = for {
resourceTypeName <- config.root().keySet().asScala
resourceId <- config.getConfig(resourceTypeName).root().keySet().asScala
policyName <- config.getConfig(s"$resourceTypeName.$resourceId").root().keySet().asScala
} yield {
val fullyQualifiedPolicyId =
FullyQualifiedPolicyId(FullyQualifiedResourceId(ResourceTypeName(resourceTypeName), ResourceId(resourceId)), AccessPolicyName(policyName))
fullyQualifiedPolicyId -> config.as[AccessPolicyMembershipRequest](s"$resourceTypeName.$resourceId.$policyName")
}
policies.toMap
}

/** Loads all the configs for the Sam App. All values defined in `src/main/resources/sam.conf` will take precedence over any other configs. In this way, we
* can still use configs rendered by `firecloud-develop` that render to `config/sam.conf` if we want. To do so, you must render `config/sam.conf` and then do
* not populate ENV variables for `src/main/resources/sam.conf`.
Expand Down Expand Up @@ -278,7 +320,8 @@ object AppConfig {
adminConfig = config.as[AdminConfig]("admin"),
azureServicesConfig = config.getAs[AzureServicesConfig]("azureServices"),
prometheusConfig = config.as[PrometheusConfig]("prometheus"),
janitorConfig = config.as[JanitorConfig]("janitor")
janitorConfig = config.as[JanitorConfig]("janitor"),
resourceAccessPolicies = config.as[Option[Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest]]]("resourceAccessPolicies").getOrElse(Map.empty)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,44 @@ class ResourceService(
} yield resourceTypes.values
}

/** Called at startup to create any policies in configuration. Does not create users or resources.
* @return
* for each requested policy, either the policy created or an error
*/
def upsertResourceAccessPolicies(
resourceAccessPolicies: Map[FullyQualifiedPolicyId, AccessPolicyMembershipRequest],
samRequestContext: SamRequestContext = SamRequestContext()
): IO[Map[FullyQualifiedPolicyId, Either[Throwable, AccessPolicy]]] =
resourceAccessPolicies.toList
.traverse { case (fullyQualifiedPolicyId, accessPolicyMembershipRequest) =>
val upsertIO = for {
resourceTypeOption <- getResourceType(fullyQualifiedPolicyId.resource.resourceTypeName)
resourceType = resourceTypeOption.getOrElse(
throw new WorkbenchException(s"Resource type ${fullyQualifiedPolicyId.resource.resourceTypeName} not found")
)
upsertedPolicy <-
if (resourceType.name.isResourceTypeAdmin) {
overwriteAdminPolicy(
resourceType,
fullyQualifiedPolicyId.accessPolicyName,
fullyQualifiedPolicyId.resource,
accessPolicyMembershipRequest,
samRequestContext
)
} else {
overwritePolicy(
resourceType,
fullyQualifiedPolicyId.accessPolicyName,
fullyQualifiedPolicyId.resource,
accessPolicyMembershipRequest,
samRequestContext
)
}
} yield upsertedPolicy
upsertIO.attempt.map(fullyQualifiedPolicyId -> _)
}
.map(_.toMap)

def createResourceType(resourceType: ResourceType, samRequestContext: SamRequestContext): IO[ResourceType] =
accessPolicyDAO.createResourceType(resourceType, samRequestContext)

Expand Down
22 changes: 22 additions & 0 deletions src/test/resources/sam.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,26 @@ prometheus {

admin {
serviceAccountAdmins = ${?SERVICE_ACCOUNT_ADMINS}
}

resourceAccessPolicies {
resource_type_admin {
workspace {
rawls-policy {
memberEmails = ["[email protected]"]
descendantPermissions = [
{
resourceTypeName = "workspace",
roles = ["owner"]
}
]
}
}
kubernetes-app {
leo-policy {
memberEmails = ["[email protected]"]
roles = ["support"]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
package org.broadinstitute.dsde.workbench.sam.config

import com.typesafe.config.{ConfigException, ConfigFactory, ConfigValueFactory}
import org.broadinstitute.dsde.workbench.model.WorkbenchEmail
import org.broadinstitute.dsde.workbench.sam.model.api.AccessPolicyMembershipRequest
import org.broadinstitute.dsde.workbench.sam.model.{
AccessPolicyDescendantPermissions,
AccessPolicyName,
FullyQualifiedPolicyId,
FullyQualifiedResourceId,
ResourceId,
ResourceRoleName,
ResourceTypeName,
SamResourceTypes
}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

Expand Down Expand Up @@ -36,4 +48,28 @@ class AppConfigSpec extends AnyFlatSpec with Matchers {
AppConfig.readConfig(combinedConfig.withoutPath("googleServices.appName"))
}
}

it should "load resourceAccessPolicies" in {
val appConfig = AppConfig.load
appConfig.resourceAccessPolicies should contain allElementsOf Map(
FullyQualifiedPolicyId(FullyQualifiedResourceId(SamResourceTypes.resourceTypeAdminName, ResourceId("workspace")), AccessPolicyName("rawls-policy")) ->
AccessPolicyMembershipRequest(
Set(WorkbenchEmail("[email protected]")),
Set.empty,
Set.empty,
Some(
Set(
AccessPolicyDescendantPermissions(
ResourceTypeName("workspace"),
Set.empty,
Set(ResourceRoleName("owner"))
)
)
),
None
),
FullyQualifiedPolicyId(FullyQualifiedResourceId(SamResourceTypes.resourceTypeAdminName, ResourceId("kubernetes-app")), AccessPolicyName("leo-policy")) ->
AccessPolicyMembershipRequest(Set(WorkbenchEmail("[email protected]")), Set.empty, Set(ResourceRoleName("support")), None, None)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class ResourceServiceSpec
private val resourceTypeAdmin = ResourceType(
SamResourceTypes.resourceTypeAdminName,
Set.empty,
Set.empty,
Set(ResourceRole(ownerRoleName, Set.empty)),
ownerRoleName
)

Expand Down Expand Up @@ -3093,6 +3093,67 @@ class ResourceServiceSpec
runAuditLogTest(service.overwritePolicyMembers(policy.id, Set(dummyUser.email), samRequestContext), List(AccessAdded))
}

"upsertResourceAccessPolicies" should "upsert a policy" in {
assume(databaseEnabled, databaseEnabledClue)

val resourceName = ResourceId("resource")
val resource = FullyQualifiedResourceId(defaultResourceType.name, resourceName)

service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync()

service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync()

val testPolicyId = FullyQualifiedPolicyId(resource, AccessPolicyName(UUID.randomUUID().toString))
val expectedPolicy = AccessPolicy(testPolicyId, Set(dummyUser.id), WorkbenchEmail(""), Set.empty, Set.empty, Set.empty, false)
val returnedPolicies = service
.upsertResourceAccessPolicies(
Map(
testPolicyId -> AccessPolicyMembershipRequest(
Set(dummyUser.email),
Set.empty,
Set.empty
)
)
)
.unsafeRunSync()
.collect { case (_, Right(policy)) =>
policy.copy(email = WorkbenchEmail(""))
}

returnedPolicies should contain theSameElementsAs Set(expectedPolicy)

policyDAO.loadPolicy(testPolicyId, samRequestContext).unsafeRunSync().map(_.copy(email = WorkbenchEmail(""))) shouldBe Some(expectedPolicy)
}

it should "validate admin policies" in {
assume(databaseEnabled, databaseEnabledClue)

val resourceName = ResourceId(defaultResourceType.name.value)
val resource = FullyQualifiedResourceId(resourceTypeAdmin.name, resourceName)

service.createResourceType(resourceTypeAdmin, samRequestContext).unsafeRunSync()
service.createResourceType(defaultResourceType, samRequestContext).unsafeRunSync()

service.createResource(resourceTypeAdmin, resourceName, dummyUser, samRequestContext).unsafeRunSync()
service.createResource(defaultResourceType, resourceName, dummyUser, samRequestContext).unsafeRunSync()

val testPolicyId = FullyQualifiedPolicyId(resource, AccessPolicyName(UUID.randomUUID().toString))
val returnedPolicies = service
.upsertResourceAccessPolicies(
Map(
testPolicyId -> AccessPolicyMembershipRequest(
Set(dummyUser.email),
Set.empty,
Set.empty
)
)
)
.unsafeRunSync()

returnedPolicies.size shouldBe 1
returnedPolicies.head._2.isLeft shouldBe true
}

/** Sets up a test log appender attached to the audit logger, runs the `test` IO, ensures that `events` were appended. If tryTwice` run `test` again to make
* sure subsequent calls to no log more messages. Ends by tearing down the log appender.
*/
Expand Down

0 comments on commit 90e69b5

Please sign in to comment.