-
Notifications
You must be signed in to change notification settings - Fork 360
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
Changes from 17 commits
a6c1092
9467e26
fb0d6bf
9a26105
2b105ac
c5495f9
60ca5ed
29a412a
1372959
dcbc763
a9861d7
93f98ea
3d3935e
4701188
ab3a534
ea97924
dbd6efd
2d7dd21
05b1e15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package cromwell.services.auth.ecm | ||
|
||
import com.typesafe.config.Config | ||
import net.ceedubs.ficus.Ficus._ | ||
|
||
final case class EcmConfig(baseUrl: Option[String]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TOL is there a reason the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Discussed via Slack but posting here for reference: Since these values are more related to enabling |
||
|
||
object EcmConfig { | ||
def apply(config: Config): EcmConfig = EcmConfig(config.as[Option[String]]("ecm.base-url")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if enabled is false? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Discussed via Slack but posting here for reference: Since it is separate from ecm config it is handled separately here |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
package cromwell.services.auth.ecm | ||
|
||
import akka.actor.ActorSystem | ||
import akka.http.scaladsl.Http | ||
import akka.http.scaladsl.model._ | ||
import akka.http.scaladsl.model.headers.RawHeader | ||
import akka.util.ByteString | ||
import cromwell.services.auth.GithubAuthVending.{GithubToken, TerraToken} | ||
import spray.json._ | ||
|
||
import scala.concurrent.{ExecutionContext, Future} | ||
import scala.util.{Success, Try} | ||
|
||
class EcmService(baseEcmUrl: String) { | ||
private val getGithubAccessTokenApiPath = "api/oauth/v1/github/access-token" | ||
|
||
/* | ||
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." | ||
case _ => | ||
Try(responseBodyAsStr.parseJson) match { | ||
case Success(JsObject(fields)) => | ||
fields.get("message").map(_.toString().replaceAll("\"", "")).getOrElse(responseBodyAsStr) | ||
case _ => responseBodyAsStr | ||
} | ||
} | ||
|
||
def getGithubAccessToken( | ||
userToken: TerraToken | ||
)(implicit actorSystem: ActorSystem, ec: ExecutionContext): Future[GithubToken] = { | ||
|
||
def responseEntityToFutureStr(responseEntity: ResponseEntity): Future[String] = | ||
responseEntity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.utf8String) | ||
Comment on lines
+40
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know akka is picky and can throw exceptions if we don't read the bytes in time (within 1 second?). How comfortable are we that this future will evaluate in time? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I copied the pattern we have in Cromwell (see WorkflowCallbackActor.scala and TesAsyncBackendJobExecutionActor.scala) and didn't realize that 1 second was the default timeout. Is there an implicit timeout being imported in these 2 references that I also need to update here? 🤔 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's possible that we're hitting this error in cases where we aren't choosing to read the bytes at all, rather than cases where we take too long to read them. We're planning to do WX-1525 next week, which should confirm that one way or the other. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In this case, should the whole response be loaded into memory as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would just make sure that |
||
|
||
val headers: HttpHeader = RawHeader("Authorization", s"Bearer ${userToken.value}") | ||
val httpRequest = | ||
HttpRequest(method = HttpMethods.GET, uri = s"$baseEcmUrl/$getGithubAccessTokenApiPath").withHeaders(headers) | ||
|
||
Http() | ||
.singleRequest(httpRequest) | ||
.flatMap((response: HttpResponse) => | ||
if (response.status.isFailure()) { | ||
responseEntityToFutureStr(response.entity) flatMap { errorBody => | ||
val errorMessage = extractErrorMessage(response.status, errorBody) | ||
Future.failed(new RuntimeException(s"HTTP ${response.status.value}. $errorMessage")) | ||
} | ||
} else responseEntityToFutureStr(response.entity).map(GithubToken) | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package cromwell.services.auth.ecm | ||
|
||
import com.typesafe.config.ConfigFactory | ||
import org.scalatest.flatspec.AnyFlatSpec | ||
import org.scalatest.matchers.should.Matchers | ||
|
||
class EcmConfigSpec extends AnyFlatSpec with Matchers { | ||
|
||
it should "parse ECM base url when present" in { | ||
val config = ConfigFactory.parseString(s""" | ||
|enabled = true | ||
|auth.azure = true | ||
|ecm.base-url = "https://mock-ecm-url.org" | ||
""".stripMargin) | ||
|
||
val actualEcmConfig = EcmConfig(config) | ||
|
||
actualEcmConfig.baseUrl shouldBe defined | ||
actualEcmConfig.baseUrl.get shouldBe "https://mock-ecm-url.org" | ||
} | ||
|
||
it should "return None when ECM base url is absent" in { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we also have a case for the other config values (to continue the theme... like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes they should be covered here |
||
val config = ConfigFactory.parseString(s""" | ||
|enabled = true | ||
|auth.azure = true | ||
""".stripMargin) | ||
|
||
EcmConfig(config).baseUrl shouldBe None | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package cromwell.services.auth.ecm | ||
|
||
import akka.http.scaladsl.model.StatusCodes | ||
import org.scalatest.flatspec.AnyFlatSpec | ||
import org.scalatest.matchers.should.Matchers | ||
import org.scalatest.prop.TableDrivenPropertyChecks | ||
|
||
class EcmServiceSpec extends AnyFlatSpec with Matchers with TableDrivenPropertyChecks { | ||
|
||
private val ecmService = new EcmService("https://mock-ecm-url.org") | ||
|
||
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" | ||
|
||
private val testCases = Table( | ||
("test name", "response status code", "response body string", "expected error message"), | ||
("return custom 401 error when status code is 401", | ||
StatusCodes.Unauthorized, | ||
"<h2>could be anything</h2>", | ||
"Invalid or missing authentication credentials." | ||
), | ||
("extract message from valid ErrorReport JSON if status code is 400", | ||
StatusCodes.BadRequest, | ||
s"""{ "message" : "$ecm400ErrorMsg", "statusCode" : 400}""", | ||
ecm400ErrorMsg | ||
), | ||
("extract message from valid ErrorReport JSON if status code is 404", | ||
StatusCodes.NotFound, | ||
s"""{ "message" : "$ecm404ErrorMsg", "statusCode" : 404}""", | ||
ecm404ErrorMsg | ||
), | ||
("extract message from valid ErrorReport JSON if status code is 500", | ||
StatusCodes.InternalServerError, | ||
"""{ "message" : "Internal error", "statusCode" : 500}""", | ||
"Internal error" | ||
), | ||
("return response error body if it fails to parse JSON", | ||
StatusCodes.InternalServerError, | ||
"Response error - not a JSON", | ||
"Response error - not a JSON" | ||
), | ||
("return response error body if JSON doesn't contain 'message' key", | ||
StatusCodes.BadRequest, | ||
"""{"non-message-key" : "error message"}""", | ||
"""{"non-message-key" : "error message"}""" | ||
) | ||
) | ||
|
||
behavior of "extractErrorMessage in EcmService" | ||
|
||
forAll(testCases) { (testName, statusCode, responseBodyAsStr, expectedErrorMsg) => | ||
it should testName in { | ||
assert(ecmService.extractErrorMessage(statusCode, responseBodyAsStr) == expectedErrorMsg) | ||
} | ||
Comment on lines
+52
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Woohoo for table based tests! |
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My previous comment disappeared because of another change to this file (thanks Github) so just calling out that we talked about removing this URL in a now-outdated thread.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, I had the change locally but forgot to push it.
ecm.base-url
should now have valuehttps://example.org