diff --git a/apps/dashboard-app/src/main/scala/net/shrine/dashboard/DashboardService.scala b/apps/dashboard-app/src/main/scala/net/shrine/dashboard/DashboardService.scala index 176d273cc..af0db79d5 100644 --- a/apps/dashboard-app/src/main/scala/net/shrine/dashboard/DashboardService.scala +++ b/apps/dashboard-app/src/main/scala/net/shrine/dashboard/DashboardService.scala @@ -1,479 +1,480 @@ package net.shrine.dashboard import akka.actor.Actor import akka.event.Logging import net.shrine.authentication.UserAuthenticator import net.shrine.authorization.steward.OutboundUser import net.shrine.dashboard.jwtauth.ShrineJwtAuthenticator import net.shrine.i2b2.protocol.pm.User import net.shrine.status.protocol.{Config => StatusProtocolConfig} import net.shrine.dashboard.httpclient.HttpClientDirectives.{forwardUnmatchedPath, requestUriThenRoute} import net.shrine.log.Loggable import net.shrine.problem.{ProblemDigest, Problems} import net.shrine.serialization.NodeSeqSerializer import shapeless.HNil import spray.http.{HttpRequest, HttpResponse, StatusCodes, Uri} import spray.httpx.Json4sSupport import spray.routing.directives.LogEntry import spray.routing._ import org.json4s.{DefaultFormats, Formats} import org.json4s.native.JsonMethods.{parse => json4sParse} import scala.collection.immutable.Iterable import scala.concurrent.duration.{Duration, FiniteDuration, SECONDS} import scala.concurrent.ExecutionContext.Implicits.global /** * Mixes the DashboardService trait with an Akka Actor to provide the actual service. */ class DashboardServiceActor extends Actor with DashboardService { // the HttpService trait defines only one abstract member, which // connects the services environment to the enclosing actor or test def actorRefFactory = context // this actor only runs our route, but you could add // other things here, like request stream processing // or timeout handling def receive = runRoute(route) } /** * A web service that provides the Dashboard endpoints. It is a trait to support testing independent of Akka. */ trait DashboardService extends HttpService with Json4sSupport with Loggable { implicit def json4sFormats: Formats = DefaultFormats val userAuthenticator = UserAuthenticator(DashboardConfigSource.config) //don't need to do anything special for unauthorized users, but they do need access to a static form. lazy val route:Route = gruntWatchCorsSupport{ redirectToIndex ~ staticResources ~ makeTrouble ~ about ~ authenticatedInBrowser ~ authenticatedDashboard } /** logs the request method, uri and response at info level */ def logEntryForRequestResponse(req: HttpRequest): Any => Option[LogEntry] = { case res: HttpResponse => Some(LogEntry(s"\n Request: $req\n Response: $res", Logging.InfoLevel)) case _ => None // other kind of responses } /** logs just the request method, uri and response status at info level */ def logEntryForRequest(req: HttpRequest): Any => Option[LogEntry] = { case res: HttpResponse => Some(LogEntry(s"\n Request: $req\n Response status: ${res.status}", Logging.InfoLevel)) case _ => None // other kind of responses } def authenticatedInBrowser: Route = pathPrefixTest("user"|"admin"|"toDashboard") { logRequestResponse(logEntryForRequestResponse _) { //logging is controlled by Akka's config, slf4j, and log4j config reportIfFailedToAuthenticate { authenticate(userAuthenticator.basicUserAuthenticator) { user => pathPrefix("user") { userRoute(user) } ~ - pathPrefix("admin") { - adminRoute(user) - } ~ - pathPrefix("toDashboard") { - toDashboardRoute(user) - } + pathPrefix("admin") { + adminRoute(user) + } ~ + pathPrefix("toDashboard") { + toDashboardRoute(user) + } } } } } val reportIfFailedToAuthenticate = routeRouteResponse { case Rejected(List(AuthenticationFailedRejection(_,_))) => complete("AuthenticationFailed") } def authenticatedDashboard:Route = pathPrefix("fromDashboard") { logRequestResponse(logEntryForRequestResponse _) { //logging is controlled by Akka's config, slf4j, and log4j config get { //all remote dashboard calls are gets. authenticate(ShrineJwtAuthenticator.authenticate) { user => adminRoute(user) } } } } def makeTrouble = pathPrefix("makeTrouble") { complete(throw new IllegalStateException("fake trouble")) } lazy val redirectToIndex = pathEnd { redirect("shrine-dashboard/client/index.html", StatusCodes.PermanentRedirect) //todo pick up "shrine-dashboard" programatically - } ~ + } ~ ( path("index.html") | pathSingleSlash) { redirect("client/index.html", StatusCodes.PermanentRedirect) - } + } lazy val staticResources = pathPrefix("client") { pathEnd { redirect("client/index.html", StatusCodes.PermanentRedirect) } ~ pathSingleSlash { redirect("index.html", StatusCodes.PermanentRedirect) } ~ { getFromResourceDirectory("client") } } lazy val about = pathPrefix("about") { complete("Nothing here yet") //todo } def userRoute(user:User):Route = get { pathPrefix("whoami") { complete(OutboundUser.createFromUser(user)) } } //todo check that this an admin. def adminRoute(user:User):Route = get { pathPrefix("happy") { val happyBaseUrl: String = DashboardConfigSource.config.getString("shrine.dashboard.happyBaseUrl") forwardUnmatchedPath(happyBaseUrl) } ~ - pathPrefix("messWithHappyVersion") { //todo is this used? + pathPrefix("messWithHappyVersion") { //todo is this used? val happyBaseUrl: String = DashboardConfigSource.config.getString("shrine.dashboard.happyBaseUrl") - def pullClasspathFromConfig(httpResponse:HttpResponse,uri:Uri):Route = { - ctx => { - val result = httpResponse.entity.asString - ctx.complete(s"Got '$result' from $uri") + def pullClasspathFromConfig(httpResponse:HttpResponse,uri:Uri):Route = { + ctx => { + val result = httpResponse.entity.asString + ctx.complete(s"Got '$result' from $uri") + } } - } - requestUriThenRoute(happyBaseUrl+"/version",pullClasspathFromConfig) - } ~ - pathPrefix("ping") {complete("pong")}~ - pathPrefix("status"){statusRoute(user)} + requestUriThenRoute(happyBaseUrl+"/version",pullClasspathFromConfig) + } ~ + pathPrefix("ping") {complete("pong")}~ + pathPrefix("status"){statusRoute(user)} } //Manually test this by running a curl command //curl -k -w "\n%{response_code}\n" -u dave:kablam "https://shrine-dev1.catalyst:6443/shrine-dashboard/toDashboard/shrine-dev2.catalyst/shrine-dashboard/fromDashboard/ping" /** * Forward a request from this dashboard to a remote dashboard */ def toDashboardRoute(user:User):Route = get { pathPrefix(Segment) { dnsName => val remoteDashboardProtocol = DashboardConfigSource.config.getString("shrine.dashboard.remoteDashboard.protocol") val remoteDashboardPort = DashboardConfigSource.config.getString("shrine.dashboard.remoteDashboard.port") val remoteDashboardPathPrefix = DashboardConfigSource.config.getString("shrine.dashboard.remoteDashboard.pathPrefix") val baseUrl = s"$remoteDashboardProtocol$dnsName$remoteDashboardPort/$remoteDashboardPathPrefix" forwardUnmatchedPath(baseUrl,Some(ShrineJwtAuthenticator.createOAuthCredentials(user))) } } def statusRoute(user:User):Route = get { pathPrefix("config"){getConfig}~ - pathPrefix("classpath"){getClasspath}~ - pathPrefix("options"){getOptionalParts}~ //todo rename path to optionalParts - pathPrefix("summary"){getSummary}~ - pathPrefix("problems"){getProblems} + pathPrefix("classpath"){getClasspath}~ + pathPrefix("options"){getOptionalParts}~ //todo rename path to optionalParts + pathPrefix("summary"){getSummary}~ + pathPrefix("problems"){getProblems} } val statusBaseUrl = DashboardConfigSource.config.getString("shrine.dashboard.statusBaseUrl") lazy val getConfig:Route = { def completeConfigRoute(httpResponse:HttpResponse,uri:Uri):Route = { ctx => { val config = ParsedConfig(httpResponse.entity.asString) ctx.complete( ShrineConfig(config) ) } } requestUriThenRoute(statusBaseUrl + "/config", completeConfigRoute) } lazy val getClasspath:Route = { def pullClasspathFromConfig(httpResponse:HttpResponse,uri:Uri):Route = { ctx => { val result = httpResponse.entity.asString val shrineConfig = ShrineConfig(ParsedConfig(result)) ctx.complete(shrineConfig) } } requestUriThenRoute(statusBaseUrl + "/config",pullClasspathFromConfig) } lazy val getOptionalParts:Route = { requestUriThenRoute(statusBaseUrl + "/optionalParts") } lazy val getSummary:Route = { requestUriThenRoute(statusBaseUrl + "/summary") } // table based view, can see N problems at a time. Front end sends how many problems that they want // to skip, and it will take N the 'nearest N' ie with n = 20, 0-19 -> 20, 20-39 -> 20-40 lazy val getProblems:Route = { def floorMod(x: Int, y: Int) = { x - (x % y) } val db = Problems.DatabaseConnector - parameters("offset" ? 0, "n" ? 20, "epoch" ? -1l) { (offsetPreMod: Int, nPreMax: Int, epoch: Long) => - val n = Math.max(0, nPreMax) - val moddedOffset = floorMod(Math.max(0, offsetPreMod), n) - - // Constructing the query with comprehension means we only use one database connection. - // TODO: review with Dave to see if it's too clever - val query = - if (epoch == -1l) - for { - result <- db.IO.sizeAndProblemDigest(n, moddedOffset) - } yield (result._2, floorMod(Math.max(0, moddedOffset), n), n, result._1) - else - for { - dateOffset <- db.IO.findIndexOfDate(epoch) - moddedOffset = floorMod(dateOffset, n) - result <- db.IO.sizeAndProblemDigest(n, moddedOffset) - } yield (result._2, moddedOffset, n, result._1) - - val response: ProblemResponse = ProblemResponse.tupled(db.runBlocking(query)) - implicit val formats = response.json4sMarshaller - complete(response) + parameters("offset" ? 0, "n" ? 20, "epoch" ? -1l) { + (offsetPreMod: Int, nPreMax: Int, epoch: Long) => { + val n = Math.max(0, nPreMax) + val moddedOffset = floorMod(Math.max(0, offsetPreMod), n) + + // Constructing the query with comprehension means we only use one database connection. + // TODO: review with Dave to see if it's too clever + val query = for { + result <- db.IO.sizeAndProblemDigest(n, moddedOffset) + } yield (result._2, floorMod(Math.max(0, moddedOffset), n), n, result._1) + + + val query2 = for { + dateOffset <- db.IO.findIndexOfDate(epoch) + moddedOffset = floorMod(dateOffset, n) + result <- db.IO.sizeAndProblemDigest(n, moddedOffset) + } yield (result._2, moddedOffset, n, result._1) + + val response: ProblemResponse = ProblemResponse.tupled(db.runBlocking(if (epoch == -1l) query else query2)) + implicit val formats = response.json4sMarshaller + complete(response) + } } } } case class ProblemResponse(size: Int, offset: Int, n: Int, problems: Seq[ProblemDigest]) extends Json4sSupport { override implicit def json4sFormats: Formats = DefaultFormats + new NodeSeqSerializer } /** - * Centralized parsing logic for map of shrine.conf - * the class literal `T.class` in Java. - */ + * Centralized parsing logic for map of shrine.conf + * the class literal `T.class` in Java. + */ //todo most of this info should come directly from the status service in Shrine, not from reading the config case class ParsedConfig(configMap:Map[String, String]){ private val trueVal = "true" private val rootKey = "shrine" def isHub = getOrElse(rootKey + ".hub.create", "") .toLowerCase == trueVal def stewardEnabled = configMap.keySet .contains(rootKey + ".queryEntryPoint.shrineSteward") def shouldQuerySelf = getOrElse(rootKey + ".hub.shouldQuerySelf", "") .toLowerCase == trueVal def fromJsonString(jsonString:String): String = jsonString.split("\"").mkString("") def get(key:String): Option[String] = configMap.get(key).map(fromJsonString) def getOrElse(key:String, elseVal:String = ""): String = get(key).getOrElse(elseVal) } object ParsedConfig { def apply(jsonString:String):ParsedConfig = { implicit def json4sFormats: Formats = DefaultFormats ParsedConfig(json4sParse(jsonString).extract[StatusProtocolConfig].keyValues.filterKeys(_.toLowerCase.startsWith("shrine"))) } } case class DownstreamNode(name:String, url:String) object DownstreamNode { def create(configMap:Map[String,String]):Iterable[DownstreamNode] = { for ((k, v) <- configMap.filterKeys(_.toLowerCase.startsWith ("shrine.hub.downstreamnodes"))) yield DownstreamNode(k.split('.').last,v.split("\"").mkString("")) } } //todo replace with the actual config, scrubbed of passwords case class ShrineConfig(isHub:Boolean, hub:Hub, pmEndpoint:Endpoint, ontEndpoint:Endpoint, hiveCredentials: HiveCredentials, adapter: Adapter, queryEntryPoint:QEP, networkStatusQuery:String ) object ShrineConfig{ def apply(config:ParsedConfig):ShrineConfig = { val hub = Hub(config) val isHub = config.isHub val pmEndpoint = Endpoint("pm",config) val ontEndpoint = Endpoint("ont",config) val hiveCredentials = HiveCredentials(config) val adapter = Adapter(config) val queryEntryPoint = QEP(config) val networkStatusQuery = config.configMap("shrine.networkStatusQuery") ShrineConfig(isHub, hub, pmEndpoint, ontEndpoint, hiveCredentials, adapter, queryEntryPoint, networkStatusQuery) } } case class Endpoint(acceptAllCerts:Boolean, url:String, timeoutSeconds:Int) object Endpoint{ def apply(endpointType:String,parsedConfig:ParsedConfig):Endpoint = { val prefix = "shrine." + endpointType.toLowerCase + "Endpoint." val acceptAllCerts = parsedConfig.configMap.getOrElse(prefix + "acceptAllCerts", "") == "true" val url = parsedConfig.configMap.getOrElse(prefix + "url","") val timeoutSeconds = parsedConfig.configMap.getOrElse(prefix + "timeout.seconds", "0").toInt Endpoint(acceptAllCerts, url, timeoutSeconds) } } case class HiveCredentials(domain:String, username:String, password:String, crcProjectId:String, ontProjectId:String) object HiveCredentials{ def apply(parsedConfig:ParsedConfig):HiveCredentials = { val key = "shrine.hiveCredentials." val domain = parsedConfig.configMap.getOrElse(key + "domain","") val username = parsedConfig.configMap.getOrElse(key + "username","") val password = "REDACTED" val crcProjectId = parsedConfig.configMap.getOrElse(key + "crcProjectId","") val ontProjectId = parsedConfig.configMap.getOrElse(key + "ontProjectId","") HiveCredentials(domain, username, password, crcProjectId, ontProjectId) } } // -- hub only -- // //todo delete when the Dashboard front end can use the status service's hub method case class Hub(shouldQuerySelf:Boolean, create:Boolean, downstreamNodes:Iterable[DownstreamNode]) object Hub{ def apply(parsedConfig:ParsedConfig):Hub = { val shouldQuerySelf = parsedConfig.shouldQuerySelf val create = parsedConfig.isHub val downstreamNodes = DownstreamNode.create(parsedConfig.configMap) Hub(shouldQuerySelf, create, downstreamNodes) } } // -- adapter info -- // case class Adapter(crcEndpointUrl:String, setSizeObfuscation:Boolean, adapterLockoutAttemptsThreshold:Int, adapterMappingsFilename:String) object Adapter{ def apply(parsedConfig:ParsedConfig):Adapter = { val key = "shrine.adapter." val crcEndpointUrl = parsedConfig.configMap.getOrElse(key + "crcEndpoint.url","") val setSizeObfuscation = parsedConfig.configMap.getOrElse(key + "setSizeObfuscation","").toLowerCase == "true" val adapterLockoutAttemptsThreshold = parsedConfig.configMap.getOrElse(key + "adapterLockoutAttemptsThreshold", "0").toInt val adapterMappingsFileName = parsedConfig.configMap.getOrElse(key + "adapterMappingsFileName","") Adapter(crcEndpointUrl, setSizeObfuscation, adapterLockoutAttemptsThreshold, adapterMappingsFileName) } } case class Steward(qepUserName:String, stewardBaseUrl:String) object Steward { def apply (parsedConfig:ParsedConfig):Steward = { val key = "shrine.queryEntryPoint.shrineSteward." val qepUserName = parsedConfig.configMap.getOrElse(key + "qepUserName","") val stewardBaseUrl = parsedConfig.configMap.getOrElse(key + "stewardBaseUrl","") Steward(qepUserName, stewardBaseUrl) } } // -- if needed -- // case class TimeoutInfo (timeUnit:String, description:String) case class DatabaseInfo(createTablesOnStart:Boolean, dataSourceFrom:String, jndiDataSourceName:String, slickProfileClassName:String) case class Audit(database:DatabaseInfo, collectQepAudit:Boolean) object Audit{ def apply(parsedConfig:ParsedConfig):Audit = { val key = "shrine.queryEntryPoint.audit." val createTablesOnStart = parsedConfig.configMap.getOrElse(key + "database.createTablesOnStart","") == "true" val dataSourceFrom = parsedConfig.configMap.getOrElse(key + "database.dataSourceFrom","") val jndiDataSourceName = parsedConfig.configMap.getOrElse(key + "database.jndiDataSourceName","") val slickProfileClassName = parsedConfig.configMap.getOrElse(key + "database.slickProfileClassName","") val collectQepAudit = parsedConfig.configMap.getOrElse(key + "collectQepAudit","") == "true" val database = DatabaseInfo(createTablesOnStart, dataSourceFrom, jndiDataSourceName, slickProfileClassName) Audit(database, collectQepAudit) } } case class QEP( - maxQueryWaitTimeMinutes:Int, - create:Boolean, - attachSigningCert:Boolean, - authorizationType:String, - includeAggregateResults:Boolean, - authenticationType:String, - audit:Audit, - shrineSteward:Steward, - broadcasterServiceEndpointUrl:Option[String] -) + maxQueryWaitTimeMinutes:Int, + create:Boolean, + attachSigningCert:Boolean, + authorizationType:String, + includeAggregateResults:Boolean, + authenticationType:String, + audit:Audit, + shrineSteward:Steward, + broadcasterServiceEndpointUrl:Option[String] + ) object QEP{ val key = "shrine.queryEntryPoint." def apply(parsedConfig:ParsedConfig):QEP = QEP( maxQueryWaitTimeMinutes = parsedConfig.configMap.getOrElse(key + "maxQueryWaitTime.minutes", "0").toInt, create = parsedConfig.configMap.getOrElse(key + "create","") == "true", attachSigningCert = parsedConfig.configMap.getOrElse(key + "attachSigningCert","") == "true", authorizationType = parsedConfig.configMap.getOrElse(key + "authorizationType",""), includeAggregateResults = parsedConfig.configMap.getOrElse(key + "includeAggregateResults","") == "true", authenticationType = parsedConfig.configMap.getOrElse(key + "authenticationType", ""), audit = Audit(parsedConfig), shrineSteward = Steward(parsedConfig), broadcasterServiceEndpointUrl = parsedConfig.configMap.get(key + "broadcasterServiceEndpoint.url") ) } //adapted from https://gist.github.com/joseraya/176821d856b43b1cfe19 object gruntWatchCorsSupport extends Directive0 with RouteConcatenation { import spray.http.HttpHeaders.{`Access-Control-Allow-Methods`, `Access-Control-Max-Age`, `Access-Control-Allow-Headers`,`Access-Control-Allow-Origin`} import spray.routing.directives.RespondWithDirectives.respondWithHeaders import spray.routing.directives.MethodDirectives.options import spray.routing.directives.RouteDirectives.complete import spray.http.HttpMethods.{OPTIONS,GET,POST} import spray.http.AllOrigins private val allowOriginHeader = `Access-Control-Allow-Origin`(AllOrigins) private val optionsCorsHeaders = List( `Access-Control-Allow-Headers`("Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Accept-Language, Host, Referer, User-Agent, Authorization"), `Access-Control-Max-Age`(1728000)) //20 days val gruntWatch:Boolean = DashboardConfigSource.config.getBoolean("shrine.dashboard.gruntWatch") override def happly(f: (HNil) => Route): Route = { if(gruntWatch) { options { respondWithHeaders(`Access-Control-Allow-Methods`(OPTIONS, GET, POST) :: allowOriginHeader :: optionsCorsHeaders){ complete(StatusCodes.OK) } } ~ f(HNil) } else f(HNil) } } diff --git a/apps/steward-app/src/main/scala/net/shrine/steward/StewardService.scala b/apps/steward-app/src/main/scala/net/shrine/steward/StewardService.scala index 98bf63e4a..89cc0b751 100644 --- a/apps/steward-app/src/main/scala/net/shrine/steward/StewardService.scala +++ b/apps/steward-app/src/main/scala/net/shrine/steward/StewardService.scala @@ -1,366 +1,373 @@ package net.shrine.steward import akka.actor.Actor import akka.event.Logging import net.shrine.authentication.UserAuthenticator - -import net.shrine.authorization.steward.{TopicIdAndName, Date, TopicId, InboundTopicRequest, InboundShrineQuery, StewardsTopics, TopicState, OutboundUser, OutboundTopic, UserName} +import net.shrine.authorization.steward._ import net.shrine.i2b2.protocol.pm.User -import net.shrine.steward.db.{DetectedAttemptByWrongUserToChangeTopic, ApprovedTopicCanNotBeChanged, TopicDoesNotExist, SortOrder, QueryParameters, StewardDatabase} +import net.shrine.steward.db._ import net.shrine.steward.pmauth.Authorizer import shapeless.HNil - -import spray.http.{HttpResponse, HttpRequest, StatusCodes} +import spray.http.{HttpRequest, HttpResponse, StatusCodes} import spray.httpx.Json4sSupport import spray.routing.directives.LogEntry -import spray.routing.{AuthenticationFailedRejection, Rejected, RouteConcatenation, Directive0, Route, HttpService} - +import spray.routing._ import org.json4s.{DefaultFormats, Formats} import scala.concurrent.ExecutionContext.Implicits.global import scala.util.{Failure, Success, Try} // we don't implement our route structure directly in the service actor because // we want to be able to test it independently, without having to spin up an actor class StewardServiceActor extends Actor with StewardService { // the HttpService trait defines only one abstract member, which // connects the services environment to the enclosing actor or test def actorRefFactory = context // this actor only runs our route, but you could add // other things here, like request stream processing // or timeout handling def receive = runRoute(route) } // this trait defines our service behavior independently from the service actor trait StewardService extends HttpService with Json4sSupport { implicit def json4sFormats: Formats = DefaultFormats val userAuthenticator = UserAuthenticator(StewardConfigSource.config) //don't need to do anything special for unauthorized users, but they do need access to a static form. lazy val route:Route = gruntWatchCorsSupport{ requestLogRoute ~ fullLogRoute } lazy val requestLogRoute = logRequestResponse(logEntryForRequest _) { redirectToIndex ~ staticResources ~ makeTrouble ~ about } lazy val fullLogRoute = logRequestResponse(logEntryForRequestResponse _) { qepRoute ~ authenticatedInBrowser } // logs just the request method, uri and response at info level //logging is controlled by Akka's config, slf4j, and log4j config def logEntryForRequestResponse(req: HttpRequest): Any => Option[LogEntry] = { case res: HttpResponse => { Some(LogEntry(s"\n Request: $req \n Response: $res", Logging.InfoLevel)) } case _ => None // other kind of responses } // logs just the request method, uri and response status at info level def logEntryForRequest(req: HttpRequest): Any => Option[LogEntry] = { case res: HttpResponse => { Some(LogEntry(s"\n Request: $req \n Response status: ${res.status}", Logging.InfoLevel)) } case _ => None // other kind of responses } //pathPrefixTest shields the QEP code from the redirect. def authenticatedInBrowser: Route = pathPrefixTest("user"|"steward"|"researcher") { reportIfFailedToAuthenticate { authenticate(userAuthenticator.basicUserAuthenticator) { user => StewardDatabase.db.upsertUser(user) pathPrefix("user") {userRoute(user)} ~ pathPrefix("steward") {stewardRoute(user)} ~ pathPrefix("researcher") {researcherRoute(user)} } } } val reportIfFailedToAuthenticate = routeRouteResponse { case Rejected(List(AuthenticationFailedRejection(_,_))) => complete("AuthenticationFailed") } def makeTrouble = pathPrefix("makeTrouble") { complete(throw new IllegalStateException("fake trouble")) } lazy val redirectToIndex = pathEnd { redirect("steward/client/index.html", StatusCodes.PermanentRedirect) //todo pick up "steward" programatically } ~ ( path("index.html") | pathSingleSlash) { redirect("client/index.html", StatusCodes.PermanentRedirect) } lazy val staticResources = pathPrefix("client") { pathEnd { redirect("client/index.html", StatusCodes.PermanentRedirect) } ~ pathSingleSlash { redirect("index.html", StatusCodes.PermanentRedirect) } ~ { getFromResourceDirectory("client") } } lazy val about = pathPrefix("about") { path("createTopicsMode") { get { complete(StewardConfigSource.createTopicsInState.name) } } } def userRoute(user:User):Route = get { pathPrefix("whoami") { complete(OutboundUser.createFromUser(user)) } } def qepRoute:Route = pathPrefix("qep") { authenticate(userAuthenticator.basicUserAuthenticator) { user => StewardDatabase.db.upsertUser(user) authorize(Authorizer.authorizeQep(user)) { pathPrefix("requestQueryAccess") ( requestQueryAccess ) ~ pathPrefix("approvedTopics") ( getApprovedTopicsForUser ) } } } def requestQueryAccess:Route = post { requestQueryAccessWithTopic ~ requestQueryAccessWithoutTopic } def requestQueryAccessWithTopic:Route = path("user" /Segment/ "topic" / IntNumber) { (userId,topicId) => entity(as[InboundShrineQuery]) { shrineQuery:InboundShrineQuery => //todo really pull the user out of the shrine query and check vs the PM. If they aren't there, reject them for this new reason val result: (TopicState, Option[TopicIdAndName]) = StewardDatabase.db.logAndCheckQuery(userId,Some(topicId),shrineQuery) respondWithStatus(result._1.statusCode) { if(result._1.statusCode == StatusCodes.OK) complete (result._2.getOrElse("")) else complete(result._1.message) } } } def requestQueryAccessWithoutTopic:Route = path("user" /Segment) { userId => entity(as[InboundShrineQuery]) { shrineQuery:InboundShrineQuery => //todo really pull the user out of the shrine query and check vs the PM. If they aren't there, reject them for this new reason val result = StewardDatabase.db.logAndCheckQuery(userId,None,shrineQuery) respondWithStatus(result._1.statusCode) { if(result._1.statusCode == StatusCodes.OK) complete (result._2) else complete(result._1.message) } } } lazy val getApprovedTopicsForUser:Route = get { //todo change to "researcher" path("user" /Segment) { userId => //todo really pull the user out of the shrine query and check vs the PM. If they aren't there, reject them for this new reason val queryParameters = QueryParameters(researcherIdOption = Some(userId),stateOption = Some(TopicState.approved)) val researchersTopics = StewardDatabase.db.selectTopicsForResearcher(queryParameters) complete(researchersTopics) } } def researcherRoute(user:User):Route = authorize(Authorizer.authorizeResearcher(user)) { pathPrefix("topics") { getUserTopics(user.username) } ~ pathPrefix("queryHistory") { getUserQueryHistory(Some(user.username)) } ~ pathPrefix("requestTopicAccess") { requestTopicAccess(user) } ~ pathPrefix("editTopicRequest") { editTopicRequest(user) } } def getUserTopics(userId:UserName):Route = get { //lookup topics for this user in the db matchQueryParameters(Some(userId)){queryParameters:QueryParameters => val researchersTopics = StewardDatabase.db.selectTopicsForResearcher(queryParameters) complete(researchersTopics) } } def matchQueryParameters(userName: Option[UserName])(parameterRoute:QueryParameters => Route): Route = { parameters('state.?,'skip.as[Int].?,'limit.as[Int].?,'sortBy.as[String].?,'sortDirection.as[String].?,'minDate.as[Date].?,'maxDate.as[Date].?) { (stateStringOption,skipOption,limitOption,sortByOption,sortOption,minDate,maxDate) => val stateTry = TopicState.stateForStringOption(stateStringOption) stateTry match { case Success(stateOption) => val qp = QueryParameters(userName, stateOption, skipOption, limitOption, sortByOption, SortOrder.sortOrderForStringOption(sortOption), minDate, maxDate ) parameterRoute(qp) case Failure(ex) => badStateRoute(stateStringOption) } } } def badStateRoute(stateStringOption:Option[String]):Route = { respondWithStatus(StatusCodes.UnprocessableEntity) { complete(s"Topic state ${stateStringOption.getOrElse(s"$stateStringOption (stateStringOption should never be None at this point)")} unknown. Please specify one of ${TopicState.namesToStates.keySet}") } } def getUserQueryHistory(userIdOption:Option[UserName]):Route = get { - path("topic"/IntNumber) { topicId:TopicId => - getQueryHistoryForUserByTopic(userIdOption,Some(topicId)) - } ~ - getQueryHistoryForUserByTopic(userIdOption,None) + parameter('asJson.as[Boolean].?) { (asJson:Option[Boolean]) => + path("topic" / IntNumber) { topicId: TopicId => + getQueryHistoryForUserByTopic(userIdOption, Some(topicId), asJson) + } ~ + getQueryHistoryForUserByTopic(userIdOption, None, asJson) + } } - def getQueryHistoryForUserByTopic(userIdOption:Option[UserName],topicIdOption:Option[TopicId]) = get { + def getQueryHistoryForUserByTopic(userIdOption:Option[UserName],topicIdOption:Option[TopicId],jsonOption:Option[Boolean] = None) = get { matchQueryParameters(userIdOption) { queryParameters:QueryParameters => val queryHistory = StewardDatabase.db.selectQueryHistory(queryParameters, topicIdOption) - - complete(queryHistory) + if (jsonOption.getOrElse(false)) + complete(queryHistoryWithJson(queryHistory)) + else + complete(queryHistory) } } + def queryHistoryWithJson(history: QueryHistory):QueryHistory = { + history.copy(queryRecords = history.queryRecords.map((record: OutboundShrineQuery) => { + record.copy(queryContents = org.json4s.native.Serialization.write(scala.xml.XML.loadString(record.queryContents))) + })) + } + def requestTopicAccess(user:User):Route = post { entity(as[InboundTopicRequest]) { topicRequest: InboundTopicRequest => //todo notify the data stewards StewardDatabase.db.createRequestForTopicAccess(user,topicRequest) complete(StatusCodes.Accepted) } } def editTopicRequest(user:User):Route = post { path(IntNumber) { topicId => entity(as[InboundTopicRequest]) { topicRequest: InboundTopicRequest => //todo notify the data stewards val updatedTopicTry:Try[OutboundTopic] = StewardDatabase.db.updateRequestForTopicAccess(user, topicId, topicRequest) updatedTopicTry match { case Success(updatedTopic) => respondWithStatus(StatusCodes.Accepted) { complete(updatedTopic) } case Failure(x) => x match { case x:TopicDoesNotExist => respondWithStatus(StatusCodes.NotFound) { complete(x.getMessage) } case x:ApprovedTopicCanNotBeChanged => respondWithStatus(StatusCodes.Forbidden) { complete(x.getMessage) } case x:DetectedAttemptByWrongUserToChangeTopic => respondWithStatus(StatusCodes.Forbidden) { complete(x.getMessage) } case _ => throw x } } } } } def stewardRoute(user:User):Route = authorize(Authorizer.authorizeSteward(user)) { pathPrefix("queryHistory" / "user") {getUserQueryHistory } ~ pathPrefix("queryHistory") {getQueryHistory} ~ pathPrefix("topics" / "user")(getUserTopicsForSteward) ~ path("topics"){getTopicsForSteward} ~ pathPrefix("approveTopic")(approveTopicForUser(user)) ~ pathPrefix("rejectTopic")(rejectTopicForUser(user)) ~ pathPrefix("statistics"){getStatistics} } lazy val getUserQueryHistory:Route = pathPrefix(Segment) { userId => getUserQueryHistory(Some(userId)) } lazy val getQueryHistory:Route = getUserQueryHistory(None) lazy val getTopicsForSteward:Route = getTopicsForSteward(None) lazy val getUserTopicsForSteward:Route = path(Segment) { userId => getTopicsForSteward(Some(userId)) } def getTopicsForSteward(userIdOption:Option[UserName]):Route = get { //lookup topics for this user in the db matchQueryParameters(userIdOption) { queryParameters: QueryParameters => val stewardsTopics:StewardsTopics = StewardDatabase.db.selectTopicsForSteward(queryParameters) complete(stewardsTopics) } } def approveTopicForUser(user:User):Route = changeStateForTopic(TopicState.approved,user) def rejectTopicForUser(user:User):Route = changeStateForTopic(TopicState.rejected,user) def changeStateForTopic(state:TopicState,user:User):Route = post { path("topic" / IntNumber) { topicId => StewardDatabase.db.changeTopicState(topicId, state, user.username).fold(respondWithStatus(StatusCodes.UnprocessableEntity){ complete(s"No topic found for $topicId") })(topic => complete(StatusCodes.OK)) } } def getStatistics:Route = pathPrefix("queriesPerUser"){getQueriesPerUser} ~ pathPrefix("topicsPerState"){getTopicsPerState} def getQueriesPerUser:Route = get{ matchQueryParameters(None) { queryParameters: QueryParameters => val result = StewardDatabase.db.selectShrineQueryCountsPerUser(queryParameters) complete(result) } } def getTopicsPerState:Route = get{ matchQueryParameters(None) { queryParameters: QueryParameters => val result = StewardDatabase.db.selectTopicCountsPerState(queryParameters) complete(result) } } } //adapted from https://gist.github.com/joseraya/176821d856b43b1cfe19 object gruntWatchCorsSupport extends Directive0 with RouteConcatenation { import spray.http.HttpHeaders.{`Access-Control-Allow-Methods`, `Access-Control-Max-Age`, `Access-Control-Allow-Headers`,`Access-Control-Allow-Origin`} import spray.routing.directives.RespondWithDirectives.respondWithHeaders import spray.routing.directives.MethodDirectives.options import spray.routing.directives.RouteDirectives.complete import spray.http.HttpMethods.{OPTIONS,GET,POST} import spray.http.AllOrigins private val allowOriginHeader = `Access-Control-Allow-Origin`(AllOrigins) private val optionsCorsHeaders = List( `Access-Control-Allow-Headers`("Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Accept-Language, Host, Referer, User-Agent, Authorization"), `Access-Control-Max-Age`(1728000)) //20 days val gruntWatch:Boolean = StewardConfigSource.config.getBoolean("shrine.steward.gruntWatch") override def happly(f: (HNil) => Route): Route = { if(gruntWatch) { options { respondWithHeaders(`Access-Control-Allow-Methods`(OPTIONS, GET, POST) :: allowOriginHeader :: optionsCorsHeaders){ complete(StatusCodes.OK) } } ~ f(HNil) } else f(HNil) } } diff --git a/commons/util/src/main/scala/net/shrine/problem/Problem.scala b/commons/util/src/main/scala/net/shrine/problem/Problem.scala index 7f4672b6a..0a6928570 100644 --- a/commons/util/src/main/scala/net/shrine/problem/Problem.scala +++ b/commons/util/src/main/scala/net/shrine/problem/Problem.scala @@ -1,225 +1,216 @@ package net.shrine.problem import java.net.InetAddress import java.util.Date import java.util.concurrent.Executors import net.shrine.log.Loggable import net.shrine.serialization.{XmlMarshaller, XmlUnmarshaller} +import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.{ExecutionContext, Future, Promise} import scala.xml.{Elem, Node, NodeSeq} /** * Describes what information we have about a problem at the site in code where we discover it. * * @author david * @since 8/6/15 */ trait Problem { def summary:String def problemName = getClass.getName def throwable:Option[Throwable] = None def stamp:Stamp def description:String def exceptionXml(exception:Option[Throwable]): Option[Elem] = { exception.map{x => {x.getClass.getName} {x.getMessage} {x.getStackTrace.map(line => {line})}{exceptionXml(Option(x.getCause)).getOrElse("")} }} def throwableDetail: Option[Elem] = exceptionXml(throwable) def detailsXml: NodeSeq = NodeSeq.fromSeq(
{throwableDetail.getOrElse("")}
) def toDigest:ProblemDigest = ProblemDigest(problemName,stamp.pretty,summary,description,detailsXml,stamp.time) /** * Temporary replacement for onCreate, which will be released come Scala 2.13 * TODO: remove when Scala 2.13 releases */ def hackToHandleAfterInitialization(handler:ProblemHandler):Future[Unit] = { - import ProblemExecutionContext.ioThreadPool + import scala.concurrent.blocking Future { var continue = true while (continue) { try { - synchronized(handler.handleProblem(this)) + blocking(synchronized(handler.handleProblem(this))) continue = false } catch { case un:UninitializedFieldError => Thread.sleep(5) continue = true } } Unit } } } case class ProblemDigest(codec: String, stampText: String, summary: String, description: String, detailsXml: NodeSeq, epoch: Long) extends XmlMarshaller { override def toXml: Node = { {codec} {stampText} {summary} {description} {epoch} {detailsXml} } /** * Ignores detailXml. equals with scala.xml is impossible. See http://www.scala-lang.org/api/2.10.3/index.html#scala.xml.Equality$ */ override def equals(other: Any): Boolean = other match { case that: ProblemDigest => (that canEqual this) && codec == that.codec && stampText == that.stampText && summary == that.summary && description == that.description && epoch == that.epoch case _ => false } /** * Ignores detailXml */ override def hashCode: Int = { val prime = 67 codec.hashCode + prime * (stampText.hashCode + prime *(summary.hashCode + prime * (description.hashCode + prime * epoch.hashCode()))) } } object ProblemDigest extends XmlUnmarshaller[ProblemDigest] with Loggable { override def fromXml(xml: NodeSeq): ProblemDigest = { val problemNode = xml \ "problem" require(problemNode.nonEmpty,s"No problem tag in $xml") def extractText(tagName:String) = (problemNode \ tagName).text val codec = extractText("codec") val stampText = extractText("stamp") val summary = extractText("summary") val description = extractText("description") val detailsXml: NodeSeq = problemNode \ "details" val epoch = try { extractText("epoch").toLong } catch { case nx:NumberFormatException => error(s"While parsing xml representing a ProblemDigest, the epoch could not be parsed into a long", nx) 0 } ProblemDigest(codec,stampText,summary,description,detailsXml,epoch) } } case class Stamp(host:InetAddress,time:Long,source:ProblemSources.ProblemSource) { def pretty = s"${new Date(time)} on ${host.getHostName} ${source.pretty}" } object Stamp { //TODO: val dateFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")? //TODO: Currently the stamp text is locale specific, which can change depending on the jre/computer running it... def apply(source:ProblemSources.ProblemSource, timer: => Long): Stamp = Stamp(InetAddress.getLocalHost, timer, source) } abstract class AbstractProblem(source:ProblemSources.ProblemSource) extends Problem { def timer = System.currentTimeMillis override val stamp = Stamp(source, timer) private val config = ProblemConfigSource.config.getConfig("shrine.problem") hackToHandleAfterInitialization(ProblemConfigSource.getObject("problemHandler", config)) } trait ProblemHandler { def handleProblem(problem:Problem) } /** * An example problem handler */ object LoggingProblemHandler extends ProblemHandler with Loggable { override def handleProblem(problem: Problem): Unit = { problem.throwable.fold(error(problem.toString))(throwable => error(problem.toString,throwable) ) } } object DatabaseProblemHandler extends ProblemHandler { override def handleProblem(problem: Problem): Unit = { - Thread.sleep(10) Problems.DatabaseConnector.insertProblem(problem.toDigest) } } object ProblemSources{ sealed trait ProblemSource { def pretty = getClass.getSimpleName.dropRight(1) } case object Adapter extends ProblemSource case object Hub extends ProblemSource case object Qep extends ProblemSource case object Dsa extends ProblemSource case object Unknown extends ProblemSource def problemSources = Set(Adapter,Hub,Qep,Dsa,Unknown) } case class ProblemNotYetEncoded(internalSummary:String,t:Option[Throwable] = None) extends AbstractProblem(ProblemSources.Unknown){ override val summary = "An unanticipated problem encountered." override val throwable = { val rx = t.fold(new IllegalStateException(s"$summary"))( new IllegalStateException(s"$summary",_) ) rx.fillInStackTrace() Some(rx) } val reportedAtStackTrace = new IllegalStateException("Capture reporting stack trace.") override val description = "This problem is not yet classified in Shrine source code. Please report the details to the Shrine dev team." override val detailsXml: NodeSeq = NodeSeq.fromSeq(
{internalSummary} {throwableDetail.getOrElse("")}
) } object ProblemNotYetEncoded { def apply(summary:String,x:Throwable):ProblemNotYetEncoded = ProblemNotYetEncoded(summary,Some(x)) -} - -object ProblemExecutionContext { - - private val processes = Runtime.getRuntime.availableProcessors() - private val factor = 3 - private val threads = processes * factor - implicit val ioThreadPool: ExecutionContext = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(threads)) - } \ No newline at end of file diff --git a/integration/src/test/scala/net/shrine/integration/ProblemCreation.scala b/integration/src/test/scala/net/shrine/integration/ProblemCreation.scala index 6886f41af..cca8028a8 100644 --- a/integration/src/test/scala/net/shrine/integration/ProblemCreation.scala +++ b/integration/src/test/scala/net/shrine/integration/ProblemCreation.scala @@ -1,140 +1,140 @@ package net.shrine.integration import java.net.{URL, URLConnection, URLStreamHandler, URLStreamHandlerFactory} import java.sql.SQLException import net.shrine.adapter.AbstractQueryRetrievalTestCase.BogusRequest import net.shrine.adapter._ import net.shrine.adapter.client.{CouldNotParseXmlFromAdapter, HttpErrorCodeFromAdapter} import net.shrine.adapter.components.QueryNotInDatabase import net.shrine.adapter.dao.BotDetectedException import net.shrine.adapter.service.{CouldNotVerifySignature, UnknownRequestType} import net.shrine.aggregation._ import net.shrine.authentication.{NotAuthenticatedException, NotAuthenticatedProblem} import net.shrine.authorization.{CouldNotInterpretResponseFromPmCell, CouldNotReachPmCell, ErrorStatusFromDataStewardApp, MissingRequiredRoles} import net.shrine.broadcaster.CouldNotParseResultsException import net.shrine.client.HttpResponse import net.shrine.hms.authorization.HMSNotAuthenticatedProblem import net.shrine.problem._ import net.shrine.protocol.QueryResult.StatusType import net.shrine.protocol.query.QueryDefinition import net.shrine.protocol._ import net.shrine.qep.queries.QepDatabaseProblem import org.junit.runner.RunWith import org.scalatest.junit.JUnitRunner import org.scalatest.{FlatSpec, Matchers} import slick.driver.H2Driver.api._ import scala.concurrent.duration.FiniteDuration import scala.xml.{NodeSeq, SAXParseException} /** * Created by ty on 8/29/16. * Tests that we can successfully log every problem in the codebase. * Due to the time nature of logging problems, we create the succeed * early loop at the bottom to give every problem a chance at being * created on time. */ @RunWith(classOf[JUnitRunner]) class ProblemCreation extends FlatSpec with Matchers { val throwable = new IllegalArgumentException("Boo") val credential: Credential = Credential("string", isToken = true) val authInfo = AuthenticationInfo("domain", "username", credential) val authExecption = AdapterLockoutException(authInfo, "url") val bogus: ShrineRequest = new BogusRequest val seconds = new FiniteDuration(10, java.util.concurrent.TimeUnit.SECONDS) val queryDefinition = QueryDefinition("string", None) val runQueryRequest = new RunQueryRequest("id", seconds, authInfo, 10, None, None, Set(), queryDefinition) val saxxException: SAXParseException = new SAXParseException("hey", null) val xmlResponse: String = "" val someXml =
Heyo!
val teapot: HttpResponse = HttpResponse(418, "body") val nodeId: NodeId = NodeId("name") val couldNotParseException: CouldNotParseResultsException = CouldNotParseResultsException(5, "url", "body", throwable) val queryResult = QueryResult(5l, 5l, None, 5l, None, None, None, StatusType("name", isDone=false), None) val readyQueryResponse = ReadQueryResultResponse(5l, queryResult) val foo: NonI2b2ableResponse = new Foo() "Problems" should "all be successfully created and logged" in { URL.setURLStreamHandlerFactory(new BogusUrlFactory) val db = Problems.DatabaseConnector val queries = Problems.Queries val problemSize = () => db.runBlocking(queries.size.result) problemSize() shouldBe 0 val problems: Seq[AbstractProblem] = Seq( HttpErrorCodeFromAdapter("url", 5, "string response body"), CouldNotParseXmlFromAdapter("url", 6, "responseBody", saxxException), QueryNotFound(10l), QueryResultNotAvailable(10l), CouldNotRetrieveQueryFromCrc(10l, throwable), AdapterLockout(authInfo, authExecption), CrcCouldNotBeInvoked("crcUrl", bogus, CrcInvocationException("url", bogus, throwable)), AdapterMappingProblem(AdapterMappingException(runQueryRequest, "message", throwable)), AdapterDatabaseProblem(new SQLException("reason", "state", 5)), BotDetected(BotDetectedException("domain", "user", 5l, 5l, 5l)), CannotParseXmlFromCrc(saxxException, xmlResponse), ExceptionWhileLoadingCrcResponse(throwable, xmlResponse), ErrorFromCrcBreakdown(ErrorFromCrcException("message")), CannotInterpretCrcBreakdownXml(MissingCrCXmlResultException(someXml, throwable)), QueryNotInDatabase(I2b2AdminReadQueryDefinitionRequest("project", seconds, authInfo, 5l)), // Difficult to test, as I would have to pull it out of the defining code, // Change it from an object to a case class, and make sure that I'm not breaking // Any breakdown logic by doing so. // BreakdownFailure, CouldNotVerifySignature(BroadcastMessage(5l, authInfo, bogus)), UnknownRequestType(RequestType("apple", None)), NotAuthenticatedProblem(NotAuthenticatedException("string", "string", "message", throwable)), MissingRequiredRoles("pid", Set(), authInfo), CouldNotReachPmCell("url", authInfo, throwable), CouldNotInterpretResponseFromPmCell("url", authInfo, teapot, throwable), ErrorStatusFromDataStewardApp(spray.http.HttpResponse(), new URL("bogus", "host", 5, "file")), CouldNotConnectToAdapter(nodeId, throwable), TimedOutWithAdapter(nodeId), CouldNotParseResultsProblem(couldNotParseException), HttpErrorResponseProblem(couldNotParseException), NoValidResponsesToAggregate(), // Difficult to test since I can't grab private value: - // InvalidResultProblem(Invalid(None, "error")), + //InvalidResultProblem(Invalid(None, "error")), HMSNotAuthenticatedProblem(authInfo), ErrorStatusFromCrc(None, ""), QepDatabaseProblem(throwable), ProblemNotYetEncoded("summary", Some(throwable)), NoI2b2AnalogExists(foo.getClass), TestProblem() ) var count = 0 // give it up to 1 second to finish while(problemSize() != problems.length && count < 20) { Thread.sleep(50) count+=1 } problemSize() shouldBe problems.length db.runBlocking(queries.result) should contain theSameElementsAs problems.map(_.toDigest) } } class Foo extends ShrineResponse with NonI2b2ableResponse { override def toXml: NodeSeq =
Yay
} class BogusUrlFactory extends URLStreamHandlerFactory { override def createURLStreamHandler(protocol: String): URLStreamHandler = new BogusUrlHandler } class BogusUrlHandler extends URLStreamHandler { override def openConnection(u: URL): URLConnection = new BogusUrlConnection(u) } class BogusUrlConnection(u: URL) extends URLConnection(u) { override def connect(): Unit = {} } \ No newline at end of file