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

[WM-2500][WM-2502] Fetch Github token from ECM for importing and running private workflows #7392

Merged
merged 19 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -7,10 +7,14 @@ object GithubAuthVending {
override def serviceName: String = "GithubAuthVending"
}

case class GithubAuthRequest(terraToken: String) extends GithubAuthVendingMessage
// types of tokens
case class TerraToken(value: String)
case class GithubToken(value: String)

case class GithubAuthRequest(terraToken: TerraToken) extends GithubAuthVendingMessage

sealed trait GithubAuthVendingResponse extends GithubAuthVendingMessage
case class GithubAuthTokenResponse(accessToken: String) extends GithubAuthVendingResponse
case class GithubAuthTokenResponse(githubAccessToken: GithubToken) extends GithubAuthVendingResponse
case object NoGithubAuthResponse extends GithubAuthVendingResponse
case class GithubAuthVendingFailure(errorMsg: String) extends GithubAuthVendingResponse

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
GithubAuthTokenResponse,
GithubAuthVendingFailure,
GithubAuthVendingResponse,
NoGithubAuthResponse
NoGithubAuthResponse,
TerraToken
}

import net.ceedubs.ficus.Ficus._

import scala.concurrent.{ExecutionContext, Future}

trait GithubAuthVendingSupport extends AskSupport with StrictLogging {
Expand All @@ -30,7 +31,7 @@
def importAuthProvider(token: String)(implicit timeout: Timeout): ImportAuthProvider = new GithubImportAuthProvider {
override def authHeader(): Future[Map[String, String]] =
serviceRegistryActor
.ask(GithubAuthRequest(token))
.ask(GithubAuthRequest(TerraToken(token)))

Check warning on line 34 in services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala#L34

Added line #L34 was not covered by tests
.mapTo[GithubAuthVendingResponse]
.recoverWith {
case e: AskTimeoutException =>
Expand All @@ -41,10 +42,11 @@
Future.failed(new Exception("Failed to resolve github auth token", e))
}
.flatMap {
case GithubAuthTokenResponse(token) => Future.successful(Map("Authorization" -> s"Bearer ${token}"))
case GithubAuthTokenResponse(githubToken) =>
Future.successful(Map("Authorization" -> s"Bearer ${githubToken.value}"))

Check warning on line 46 in services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala#L46

Added line #L46 was not covered by tests
case NoGithubAuthResponse => Future.successful(Map.empty)
case GithubAuthVendingFailure(error) =>
Future.failed(new Exception(s"Failed to resolve GitHub auth token. Error: $error"))

Check warning on line 49 in services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/GithubAuthVendingSupport.scala#L49

Added line #L49 was not covered by tests
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,22 @@
private val getGithubAccessTokenApiPath = "api/oauth/v1/github/access-token"

/*
ECM doesn't have a standard error response format. Some of the responses contains HTML tags in it. This helper
method returns custom error message for 401 and 403 errors as they contain HTML tags. For 400, 404 and 500 the
Swagger suggests that the response format is of ErrorReport schema and this method tries to extract the
actual message from the JSON object and returns it. In case of other status codes or if it fails to parse JSON it
returns the original error response.
ErrorReport schema: {"message":<actual_error_msg>, "statusCode":<code>}
ECM does generally return standard JSON error response, but for 401 status code it seems some other layer in
between (like the apache proxies, etc) returns HTML pages. This helper method returns custom error message for 401
status code as it contains HTML tags. For all other status code, the response format is generally of ErrorReport
schema and this method tries to extract the actual message from the JSON object and return it. In case it fails
to parse JSON, it returns the original error response body.
ErrorReport schema: {"message":"<actual_error_msg>", "statusCode":<code>}
*/
def extractErrorMessage(errorCode: StatusCode, responseBodyAsStr: String): String =
errorCode match {
case StatusCodes.Unauthorized => "Invalid or missing authentication credentials."

Check warning on line 26 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L26

Added line #L26 was not covered by tests
case StatusCodes.Forbidden => "User doesn't have the right permission(s) to fetch Github token."
case StatusCodes.BadRequest | StatusCodes.NotFound | StatusCodes.InternalServerError =>
case _ =>
Try(responseBodyAsStr.parseJson) match {

Check warning on line 28 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L28

Added line #L28 was not covered by tests
case Success(JsObject(fields)) =>
fields.get("message").map(_.toString().replaceAll("\"", "")).getOrElse(responseBodyAsStr)
case _ => responseBodyAsStr

Check warning on line 31 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L30-L31

Added lines #L30 - L31 were not covered by tests
}
case _ => responseBodyAsStr
}

def getGithubAccessToken(
Expand All @@ -39,15 +37,15 @@
)(implicit actorSystem: ActorSystem, ec: ExecutionContext): Future[String] = {

def responseEntityToFutureStr(responseEntity: ResponseEntity): Future[String] =
responseEntity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.utf8String)

Check warning on line 40 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L40

Added line #L40 was not covered by tests

val headers: HttpHeader = RawHeader("Authorization", s"Bearer $userToken")

Check warning on line 42 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L42

Added line #L42 was not covered by tests
val httpRequest =
HttpRequest(method = HttpMethods.GET, uri = s"$baseEcmUrl/$getGithubAccessTokenApiPath").withHeaders(headers)

Check warning on line 44 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L44

Added line #L44 was not covered by tests

Http()
.singleRequest(httpRequest)
.flatMap((response: HttpResponse) =>

Check warning on line 48 in services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/ecm/EcmService.scala#L48

Added line #L48 was not covered by tests
if (response.status.isFailure()) {
responseEntityToFutureStr(response.entity) flatMap { errorBody =>
val errorMessage = extractErrorMessage(response.status, errorBody)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import com.typesafe.config.Config
import com.typesafe.scalalogging.LazyLogging
import common.util.StringUtil.EnhancedToStringable
import cromwell.core.Dispatcher.ServiceDispatcher
import cromwell.services.auth.GithubAuthVending.{
GithubAuthRequest,
GithubAuthTokenResponse,
GithubAuthVendingFailure,
GithubToken,
NoGithubAuthResponse
}
import cromwell.services.auth.ecm.{EcmConfig, EcmService}
Expand All @@ -25,27 +27,32 @@

lazy val enabled: Boolean = serviceConfig.getBoolean("enabled")

private lazy val ecmConfigOpt: Option[EcmConfig] = EcmConfig.apply(serviceConfig)
lazy val ecmConfigOpt: Option[EcmConfig] = EcmConfig.apply(serviceConfig)
lazy val ecmServiceOpt: Option[EcmService] = ecmConfigOpt.map(ecmConfig => new EcmService(ecmConfig.baseUrl))

override def receive: Receive = {
case GithubAuthRequest(userToken) if enabled =>
case GithubAuthRequest(terraToken) if enabled =>
val respondTo = sender()

Check warning on line 35 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L34-L35

Added lines #L34 - L35 were not covered by tests
ecmServiceOpt match {
case Some(ecmService) =>
ecmService.getGithubAccessToken(userToken) onComplete {
case Success(githubToken) => respondTo ! GithubAuthTokenResponse(githubToken)
ecmService.getGithubAccessToken(terraToken.value) onComplete {
salonishah11 marked this conversation as resolved.
Show resolved Hide resolved
case Success(token) => respondTo ! GithubAuthTokenResponse(GithubToken(token))
salonishah11 marked this conversation as resolved.
Show resolved Hide resolved
case Failure(e) => respondTo ! GithubAuthVendingFailure(e.getMessage)

Check warning on line 40 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L38-L40

Added lines #L38 - L40 were not covered by tests
}
case None =>
respondTo ! GithubAuthVendingFailure(

Check warning on line 43 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L43

Added line #L43 was not covered by tests
"Invalid configuration for service 'GithubAuthVending': missing 'ecm.base-url' value."
)
}
case GithubAuthRequest(_) => sender() ! NoGithubAuthResponse

Check warning on line 47 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L47

Added line #L47 was not covered by tests
// This service currently doesn't do any work on shutdown but the service registry pattern requires it
// (see https://github.com/broadinstitute/cromwell/issues/2575)
case ShutdownCommand => context stop self
case _ => sender() ! NoGithubAuthResponse
case other =>

Check warning on line 51 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L51

Added line #L51 was not covered by tests
logger.error(
s"Programmer Error: Unexpected message ${other.toPrettyElidedString(1000)} received by ${this.self.path.name}."
)
sender() ! GithubAuthVendingFailure(s"Received unexpected message ${other.toPrettyElidedString(1000)}.")

Check warning on line 55 in services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala

View check run for this annotation

Codecov / codecov/patch

services/src/main/scala/cromwell/services/auth/impl/GithubAuthVendingActor.scala#L55

Added line #L55 was not covered by tests
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import com.typesafe.config.ConfigFactory
import cromwell.core.TestKitSuite
import cromwell.languages.util.ImportResolver.GithubImportAuthProvider
import cromwell.services.ServiceRegistryActor.ServiceRegistryFailure
import cromwell.services.auth.GithubAuthVending.GithubAuthRequest
import cromwell.services.auth.GithubAuthVending.{GithubAuthRequest, GithubToken, TerraToken}
import cromwell.services.auth.GithubAuthVendingSupportSpec.TestGithubAuthVendingSupport
import org.scalatest.concurrent.Eventually
import org.scalatest.flatspec.AnyFlatSpecLike
Expand Down Expand Up @@ -42,8 +42,8 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthTokenResponse("github-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthTokenResponse(GithubToken("github-token")))

Await.result(authHeader, 10.seconds) should be(Map("Authorization" -> "Bearer github-token"))
}
Expand All @@ -54,7 +54,7 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(GithubAuthVending.NoGithubAuthResponse)

Await.result(authHeader, 10.seconds) should be(Map.empty)
Expand All @@ -66,7 +66,7 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(GithubAuthVending.GithubAuthVendingFailure("BOOM"))

eventually {
Expand Down Expand Up @@ -94,7 +94,7 @@ class GithubAuthVendingSupportSpec extends TestKitSuite with AnyFlatSpecLike wit
val provider = testSupport.importAuthProvider("user-token")
val authHeader: Future[Map[String, String]] = provider.authHeader()

serviceRegistryActor.expectMsg(GithubAuthRequest("user-token"))
serviceRegistryActor.expectMsg(GithubAuthRequest(TerraToken("user-token")))
serviceRegistryActor.reply(ServiceRegistryFailure("GithubAuthVending"))

eventually {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class EcmServiceSpec extends AnyFlatSpec with Matchers with TableDrivenPropertyC

private val ecmService = new EcmService("https://mock-ecm-url.org")

private val ecm400ErrorMsg = "No enum constant bio.terra.externalcreds.generated.model.Provider.githubb"
private val ecm400ErrorMsg = "No enum constant bio.terra.externalcreds.generated.model.Provider.MyOwnProvider"
private val ecm404ErrorMsg =
"No linked account found for user ID: 123 and provider: github. Please go to the Terra Profile page External Identities tab to link your account for this provider"

Expand All @@ -20,11 +20,6 @@ class EcmServiceSpec extends AnyFlatSpec with Matchers with TableDrivenPropertyC
"<h2>could be anything</h2>",
"Invalid or missing authentication credentials."
),
("return custom 403 error when status code is 403",
StatusCodes.Forbidden,
"<h2>could be anything</h2>",
"User doesn't have the right permission(s) to fetch Github token."
),
("extract message from valid ErrorReport JSON if status code is 400",
StatusCodes.BadRequest,
s"""{ "message" : "$ecm400ErrorMsg", "statusCode" : 400}""",
Expand All @@ -45,10 +40,10 @@ class EcmServiceSpec extends AnyFlatSpec with Matchers with TableDrivenPropertyC
"Response error - not a JSON",
"Response error - not a JSON"
),
("return response error body for other non-success status codes",
StatusCodes.BadGateway,
"Response error - Bad Gateway",
"Response error - Bad Gateway"
("return response error body if JSON doesn't contain 'message' key",
StatusCodes.BadRequest,
"""{"non-message-key" : "error message"}""",
"""{"non-message-key" : "error message"}"""
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,94 +8,81 @@ import cromwell.services.auth.GithubAuthVending.{
GithubAuthRequest,
GithubAuthTokenResponse,
GithubAuthVendingFailure,
NoGithubAuthResponse
GithubToken,
NoGithubAuthResponse,
TerraToken
}
import cromwell.services.auth.ecm.EcmService
import cromwell.services.auth.impl.GithubAuthVendingActorSpec.TestGithubAuthVendingActor
import org.scalatest.concurrent.Eventually
import org.scalatest.flatspec.AnyFlatSpecLike
import org.scalatest.matchers.should.Matchers
import org.scalatest.prop.TableDrivenPropertyChecks

import scala.concurrent.{ExecutionContext, Future}

class GithubAuthVendingActorSpec extends TestKitSuite with AnyFlatSpecLike with Matchers with Eventually {
class GithubAuthVendingActorSpec
extends TestKitSuite
with AnyFlatSpecLike
with Matchers
with Eventually
with TableDrivenPropertyChecks {

final private val validUserToken = "valid_user_token"
final private val githubAuthEnabledServiceConfig =
ConfigFactory.parseString(s"""
|enabled = true
|auth.azure = true
|ecm.base-url = "https://mock-ecm-url.org"
""".stripMargin)

behavior of "GithubAuthVendingActor"

it should "return NoGithubAuthResponse if GithubAuthVending is disabled" in {
val serviceConfig = ConfigFactory.parseString(s"""
|enabled = false
|auth.azure = false
""".stripMargin)

val serviceRegistryActor = TestProbe()
val actor = system.actorOf(
Props(new GithubAuthVendingActor(serviceConfig, ConfigFactory.parseString(""), serviceRegistryActor.ref))
)

eventually {
serviceRegistryActor.send(actor, GithubAuthRequest("valid_user_token"))
serviceRegistryActor.expectMsg(NoGithubAuthResponse)
}
}

it should "return invalid configuration error if no ECM base url is found" in {
val serviceConfig = ConfigFactory.parseString(s"""
|enabled = true
|auth.azure = true
""".stripMargin)

val serviceRegistryActor = TestProbe()
val actor = system.actorOf(
Props(new GithubAuthVendingActor(serviceConfig, ConfigFactory.parseString(""), serviceRegistryActor.ref))
)

eventually {
serviceRegistryActor.send(actor, GithubAuthRequest("valid_user_token"))
serviceRegistryActor.expectMsg(
GithubAuthVendingFailure("Invalid configuration for service 'GithubAuthVending': missing 'ecm.base-url' value.")
)
}
}
private def githubAuthConfigWithoutEcmUrl(flag: Boolean): Config =
ConfigFactory.parseString(s"""
|enabled = $flag
|auth.azure = $flag
""".stripMargin)

it should "return Github token if found" in {
val serviceRegistryActor = TestProbe()
val actor = system.actorOf(
Props(
new TestGithubAuthVendingActor(githubAuthEnabledServiceConfig,
ConfigFactory.parseString(""),
serviceRegistryActor.ref
)
)
private val testCases = Table(
("test name", "service config", "use TestEcmService class", "terra token", "response"),
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a case for "valid config / no token for user in ECM"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you think this is covered by the last test case return failure message if ECM service returns non-successful response? It seems that ECM would return a non-successful response if there was no Github token associated with the user and the last test case does simulate getGithubAccessToken() returning a Failed future which is what would happen in "no token for user in ECM" case too.

("return NoGithubAuthResponse if GithubAuthVending is disabled",
githubAuthConfigWithoutEcmUrl(false),
false,
validUserToken,
NoGithubAuthResponse
),
("return invalid configuration error if no ECM base url is found",
githubAuthConfigWithoutEcmUrl(true),
false,
validUserToken,
GithubAuthVendingFailure("Invalid configuration for service 'GithubAuthVending': missing 'ecm.base-url' value.")
),
("return Github token if found",
githubAuthEnabledServiceConfig,
true,
validUserToken,
GithubAuthTokenResponse(GithubToken("gha_token"))
),
("return failure message if ECM service returns non-successful response",
githubAuthEnabledServiceConfig,
true,
"invalid_user_token",
GithubAuthVendingFailure("Exception thrown for testing purposes")
)
)

eventually {
serviceRegistryActor.send(actor, GithubAuthRequest("valid_user_token"))
serviceRegistryActor.expectMsg(GithubAuthTokenResponse("gha_token"))
}
}
behavior of "GithubAuthVendingActor"

it should "return failure message if ECM service returns non-successful response" in {
val serviceRegistryActor = TestProbe()
val actor = system.actorOf(
Props(
new TestGithubAuthVendingActor(githubAuthEnabledServiceConfig,
ConfigFactory.parseString(""),
serviceRegistryActor.ref
)
forAll(testCases) { (testName, serviceConfig, useTestEcmService, userTerraToken, expectedResponseMsg) =>
it should testName in {
val serviceRegistryActor = TestProbe()
val actor = system.actorOf(
Props(new TestGithubAuthVendingActor(serviceConfig, serviceRegistryActor.ref, useTestEcmService))
)
)

eventually {
serviceRegistryActor.send(actor, GithubAuthRequest("invalid_user_token"))
serviceRegistryActor.expectMsg(GithubAuthVendingFailure("Exception thrown for testing purposes"))
eventually {
serviceRegistryActor.send(actor, GithubAuthRequest(TerraToken(userTerraToken)))
serviceRegistryActor.expectMsg(expectedResponseMsg)
}
salonishah11 marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand All @@ -113,8 +100,13 @@ class TestEcmService(baseUrl: String) extends EcmService(baseUrl) {

object GithubAuthVendingActorSpec {

class TestGithubAuthVendingActor(serviceConfig: Config, globalConfig: Config, serviceRegistryActor: ActorRef)
extends GithubAuthVendingActor(serviceConfig, globalConfig, serviceRegistryActor) {
override lazy val ecmServiceOpt: Option[EcmService] = Some(new TestEcmService("https://mock-ecm-url.org"))
class TestGithubAuthVendingActor(serviceConfig: Config,
serviceRegistryActor: ActorRef,
useTestEcmServiceClass: Boolean
) extends GithubAuthVendingActor(serviceConfig, ConfigFactory.parseString(""), serviceRegistryActor) {

override lazy val ecmServiceOpt: Option[EcmService] =
if (useTestEcmServiceClass) Some(new TestEcmService("https://mock-ecm-url.org"))
else ecmConfigOpt.map(ecmConfig => new EcmService(ecmConfig.baseUrl))
}
}
Loading