diff --git a/apps/dashboard-app/src/main/js/src/app/diagnostic/sidebar/sidebar.tpl.html b/apps/dashboard-app/src/main/js/src/app/diagnostic/sidebar/sidebar.tpl.html index 365a6106b..ba9b32115 100755 --- a/apps/dashboard-app/src/main/js/src/app/diagnostic/sidebar/sidebar.tpl.html +++ b/apps/dashboard-app/src/main/js/src/app/diagnostic/sidebar/sidebar.tpl.html @@ -1,62 +1,62 @@ <div class="navbar-default sidebar" role="navigation" ng-show="vm.options"> <div> <div class="list-group panel" style=" max-height: 600px; overflow: auto;"> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic1" class="list-group-item shrine-off" ui-sref="diagnostic.summary" data-toggle="collapse" >Summary</a> </span> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic2" class="list-group-item shrine-off" ui-sref="diagnostic.i2b2-connections" data-toggle="collapse">i2b2 Connections</a> </span> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic3" class="list-group-item shrine-off" ui-sref="diagnostic.keystore" data-toggle="collapse">Keystore</a> </span> <span ui-sref-active="shrine-on" ng-if="vm.options.isHub"> <a href="#shrine-diagnostic4" class="list-group-item shrine-off" ui-sref="diagnostic.hub" data-toggle="collapse">Hub</a> </span> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic5" class="list-group-item shrine-off" ui-sref="diagnostic.adapter" data-toggle="collapse">Adapter</a> </span> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic6" class="list-group-item shrine-off" ui-sref="diagnostic.qep" data-toggle="collapse">QEP</a> </span> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic7" class="list-group-item shrine-off" - ui-sref="diagnostic.config" data-toggle="collapse">Config File</a> + ui-sref="diagnostic.config" data-toggle="collapse">Config</a> </span> <span ui-sref-active="shrine-on" ng-if="vm.options.isHub"> <a href="#shrine-diagnostic8" class="list-group-item shrine-off" ui-sref="diagnostic.dashboard" data-toggle="collapse">Remote Dashboards</a> </span> <span ui-sref-active="shrine-on"> <a href="#shrine-diagnostic9" class="list-group-item shrine-off" ui-sref="diagnostic.problems" data-toggle="collapse">Problem Log</a> </span> <!-- if hub and there are downstream nodes --> <!--span ui-sref-active="shrine-on"> <span ui-sref="diagnostic.downstream-nodes"> <a href="#shrine-diagnostic8" class="list-group-item shrine-off" data-toggle="collapse">Downstream Nodes<i class="fa fa-caret-down"></i></a> </span> </span> <div class="collapse" id="shrine-diagnostic8"> <span ng-repeat="node in sbVM.options.downstreamNodes"> <downstream-node node-index="{{$index}}" node-data="node"> </downstream-node> </span> </div--> </div> </div> </div> diff --git a/apps/dashboard-app/src/main/js/src/app/diagnostic/views/hub.tpl.html b/apps/dashboard-app/src/main/js/src/app/diagnostic/views/hub.tpl.html index 548a5d6e8..865ddf181 100644 --- a/apps/dashboard-app/src/main/js/src/app/diagnostic/views/hub.tpl.html +++ b/apps/dashboard-app/src/main/js/src/app/diagnostic/views/hub.tpl.html @@ -1,38 +1,38 @@ <div> <error-handler error="vm.hubError" error-name="'hub'"></error-handler> <div ng-hide="vm.hubError"> <table class="table table-striped"> <thead> <tr> <td colspan="2"> - These are the sites that this SHRINE hub will + Downstream Nodes </td> </tr> <tr> <td colspan="2"> - Downstream Nodes + These are the sites that this SHRINE hub will broadcast queries to </td> </tr> <tr> <td> Site </td> <td> URL </td> </tr> </thead> <tbody> <tr ng-repeat="node in vm.downstreamNodes track by $index"> <td> {{node.name}} </td> <td> {{node.url}} </td> </tr> </tbody> </table> </div> </div> \ No newline at end of file diff --git a/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.controller.js b/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.controller.js index a442d6c13..cbbc7be8e 100644 --- a/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.controller.js +++ b/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.controller.js @@ -1,128 +1,120 @@ (function () { 'use strict'; // -- register controller with angular -- // angular.module('shrine-tools') .controller('KeystoreController', KeystoreController); /** * * @type {string[]} */ KeystoreController.$inject = ['$app', '$log']; function KeystoreController ($app, $log) { var vm = this; var map = $app.model.map; vm.qepError = false; vm.keyStoreError = false; init(); /** * */ function init() { $app.model.getKeystore() .then(setKeystore, handleKeyStoreFailure); $app.model.getQep() .then(setQep, handleQepFailure) } function setQep(qep) { vm.trustModelIsHub = qep.trustModelIsHub } /** * * @param keystore */ function setKeystore (keystore) { vm.isHub = keystore.isHub; vm.keystore = { file: keystore.fileName, password: "REDACTED" }; vm.certificate = [ ['Alias', keystore.privateKeyAlias], ['Owner', keystore.owner], ['Issuer', keystore.issuer], - ['Expires', keystore.expires], + ['Expires', new Date(keystore.expires).toUTCString()], ['MD5 Signature', keystore.md5Signature], ['SHA256 Signature', keystore.sha256Signature] ]; vm.caCertificate = [ ['Alias', keystore.caTrustedAlias], ['MD5 Signature', keystore.caTrustedSignature] ]; vm.downStreamValidation = downStreamValidation(keystore); vm.peerCertValidation = peerCertValidation(keystore); vm.hubCertValidation = hubCertValidation(keystore); vm.keyStoreContents = keyStoreContents(keystore) } function keyStoreContents(keystore) { function handleEntry(entry) { return [entry.alias, entry.cn, entry.md5] } return map(handleEntry, keystore.abbreviatedEntries) } function downStreamValidation(keystore) { if (keystore.remoteSiteStatuses.length == 0) { return []; } var remoteSite = keystore.remoteSiteStatuses[0]; if (remoteSite.timeOutError) { return [['Timed Out', "Timed out while connecting to the hub"]] } else { return [ ["CA Certificate Matches Hub's?", remoteSite.haveTheirs? "Yes": "No"] ] } } function hubCertValidation(keystore) { function handleStatus(siteStatus) { if (siteStatus.timeOutError) { return [siteStatus.siteAlias, "Timed Out"] } else { return [siteStatus.siteAlias, siteStatus.theyHaveMine] } } - if (keystore.remoteSiteStatuses.length > 0 && keystore.remoteSiteStatuses[0].length == 2) { - return map(handleStatus, keystore.remoteSiteStatuses) - } else { - return [] - } + return map(handleStatus, keystore.remoteSiteStatuses) } function peerCertValidation(keystore) { function handleStatus(siteStatus) { if (siteStatus.timeOutError) { return [siteStatus.siteAlias, "Timed Out", "N/A"] } else { return [siteStatus.siteAlias, siteStatus.theyHaveMine, siteStatus.haveTheirs] } } - if (keystore.remoteSiteStatuses.length > 0 && keystore.remoteSiteStatuses[0].length == 3) { - return map(handleStatus, keystore.remoteSiteStatuses) - } else { - return []; - } + return map(handleStatus, keystore.remoteSiteStatuses) } function handleKeyStoreFailure(failure) { vm.keyStoreError = failure; } function handleQepFailure(failure) { vm.qepError = failure; } } })(); \ No newline at end of file diff --git a/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.tpl.html b/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.tpl.html index c086bf7da..6aaf95165 100644 --- a/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.tpl.html +++ b/apps/dashboard-app/src/main/js/src/app/diagnostic/views/keystore.tpl.html @@ -1,199 +1,207 @@ <div> <error-handler error="vm.keyStoreError" error-name="'keyStore'"></error-handler> <error-handler error="vm.qepError" error-name="'qep'"></error-handler> <div ng-hide="vm.keyStoreError || vm.qepError"> <table class="table table-striped"> <thead> <tr> <td colspan="2"> Keystore File </td> </tr> <tr> <td colspan="2"> This is the keystore SHRINE will use to sign and trust queries. </td> </tr> </thead> <tbody> <tr> <td> File </td> <td> {{vm.keystore.file}} </td> </tr> <tr> <td> Password </td> <td> {{vm.keystore.password}} </td> </tr> </tbody> </table> <table class="table table-striped"> <thead> <tr> <td colspan="2"> Query Signing Certificate </td> </tr> <tr> <td colspan="2"> This is the certificate that this SHRINE server will use for signing queries. </td> </tr> </thead> <tbody> <tr ng-repeat="certField in vm.certificate track by $index"> <td> {{certField[0]}} </td> <td> {{certField[1]}} </td> </tr> </tbody> </table> <table class="table table-striped" ng-show="vm.trustModelIsHub"> <thead> <tr> <td colspan="2"> CA Certificate Information </td> </tr> <tr> <td colspan="2"> This lists the certificates that this node recognizes as valid hub CA signatures. </td> </tr> </thead> <tbody> <tr ng-repeat="certField in vm.caCertificate track by $index"> <td> {{certField[0]}} </td> <td> {{certField[1]}} </td> </tr> </tbody> </table> <table class="table table-striped" ng-show="vm.trustModelIsHub && !vm.isHub"> <thead> <tr> <td colspan="2"> Certificate Validation with Hub </td> </tr> </thead> <tbody> <tr ng-repeat="certField in vm.downStreamValidation"> <td> {{certField[0]}} </td> <td> {{certField[1]}} </td> </tr> </tbody> </table> <table class="table table-striped" ng-hide="vm.trustModelIsHub"> <thead> <tr> <td colspan="3"> Certificate Validation </td> </tr> <tr> <td> Site </td> <td> Site contains local cert </td> <td> This node contains site cert </td> </tr> </thead> <tbody> <tr ng-repeat="certField in vm.peerCertValidation"> <td> {{certField[0]}} </td> <td> {{certField[1]}} </td> <td> {{certField[2]}} </td> </tr> </tbody> </table> <table class="table table-striped" ng-show="vm.isHub"> <thead> <tr> <td colspan="2"> Certificate Validation </td> </tr> <tr> <td> Site </td> <td> Site contains correct CA Cert </td> </tr> </thead> <tbody> <tr ng-repeat="certField in vm.hubCertValidation"> <td> {{certField[0]}} </td> <td> {{certField[1]}} </td> </tr> </tbody> </table> <table class="table table-striped"> <thead> <tr> - <td colspan="2"> + <td colspan="1"> Keystore Contents </td> + <td colspan="2"> + This is a list of all certificates in the SHRINE keystore + </td> + </tr> + <tr> + <td>Alias</td> + <td>CN</td> + <td>MD5 Signature</td> </tr> </thead> <tbody> <tr ng-repeat="certField in vm.keyStoreContents"> <td> {{certField[0]}} </td> <td> {{certField[1]}} </td> <td> {{certField[2]}} </td> </tr> </tbody> </table> </div> </div> \ No newline at end of file diff --git a/apps/dashboard-app/src/main/js/test/admin/status/i2b2.json b/apps/dashboard-app/src/main/js/test/admin/status/i2b2.json index d11d87897..4c8e9b6d3 100644 --- a/apps/dashboard-app/src/main/js/test/admin/status/i2b2.json +++ b/apps/dashboard-app/src/main/js/test/admin/status/i2b2.json @@ -1,2 +1,9 @@ -{"pmUrl":"http://services.i2b2.org/i2b2/rest/PMService/getServices","crcUrl":"http://services.i2b2.org/i2b2/rest/QueryToolService/","ontUrl":"","i2b2Domain":"HarvardDemo","username":"demo","crcProject":"Demo","ontProject":"SHRINE"} - +{ + "pmUrl": "http://services.i2b2.org/i2b2/rest/PMService/getServices", + "crcUrl": "http://services.i2b2.org/i2b2/rest/QueryToolService/", + "ontUrl": "http://services.i2b2.org/i2b2/rest/OntologyService", + "i2b2Domain": "HarvardDemo", + "username": "demo", + "crcProject": "Demo", + "ontProject": "SHRINE" +} \ No newline at end of file diff --git a/apps/dashboard-app/src/test/resources/teststatus/i2b2 b/apps/dashboard-app/src/test/resources/teststatus/i2b2 index 1ef1c5fda..4c8e9b6d3 100644 --- a/apps/dashboard-app/src/test/resources/teststatus/i2b2 +++ b/apps/dashboard-app/src/test/resources/teststatus/i2b2 @@ -1 +1,9 @@ -{"pmUrl":"http://services.i2b2.org/i2b2/rest/PMService/getServices","crcUrl":"http://services.i2b2.org/i2b2/rest/QueryToolService/","ontUrl":"","i2b2Domain":"HarvardDemo","username":"demo","crcProject":"Demo","ontProject":"SHRINE"} +{ + "pmUrl": "http://services.i2b2.org/i2b2/rest/PMService/getServices", + "crcUrl": "http://services.i2b2.org/i2b2/rest/QueryToolService/", + "ontUrl": "http://services.i2b2.org/i2b2/rest/OntologyService", + "i2b2Domain": "HarvardDemo", + "username": "demo", + "crcProject": "Demo", + "ontProject": "SHRINE" +} \ No newline at end of file diff --git a/apps/shrine-app/src/main/scala/net/shrine/status/StatusJaxrs.scala b/apps/shrine-app/src/main/scala/net/shrine/status/StatusJaxrs.scala index b81097c2f..eef39c150 100644 --- a/apps/shrine-app/src/main/scala/net/shrine/status/StatusJaxrs.scala +++ b/apps/shrine-app/src/main/scala/net/shrine/status/StatusJaxrs.scala @@ -1,607 +1,607 @@ package net.shrine.status import java.security.Security import java.security.cert.X509Certificate import java.util.Date import javax.net.ssl.{KeyManager, SSLContext, X509TrustManager} import javax.ws.rs.core.{MediaType, Response} import javax.ws.rs.{GET, Path, Produces, WebApplicationException} import akka.actor.ActorSystem import akka.io.IO import akka.util.Timeout import com.sun.jersey.spi.container.{ContainerRequest, ContainerRequestFilter} import com.typesafe.config.{ConfigFactory, Config => TsConfig} import net.shrine.authorization.{QueryAuthorizationService, StewardQueryAuthorizationService} import net.shrine.broadcaster._ import net.shrine.client.PosterOntClient import net.shrine.config.ConfigExtensions import net.shrine.crypto._ import net.shrine.log.{Log, Loggable} import net.shrine.ont.data.OntClientOntologyMetadata import net.shrine.problem.{AbstractProblem, ProblemSources} import net.shrine.protocol._ import net.shrine.protocol.query.{OccuranceLimited, QueryDefinition, Term} import net.shrine.serialization.NodeSeqSerializer import net.shrine.spray._ import net.shrine.util.{PeerToPeerModel, SingleHubModel, Versions} import net.shrine.wiring.ShrineOrchestrator import org.json4s.native.Serialization import org.json4s.{DefaultFormats, Formats} import spray.can.Http import spray.can.Http.{HostConnectorInfo, HostConnectorSetup} import spray.client.pipelining._ import spray.http.{ContentType, ContentTypes, FormData, HttpCharsets, HttpEntity, HttpHeaders, HttpRequest, HttpResponse, MediaTypes} import spray.io.{ClientSSLEngineProvider, PipelineContext, SSLContextProvider} import scala.collection.JavaConverters._ import scala.collection.immutable.{Map, Seq, Set} import scala.concurrent.duration.FiniteDuration import scala.concurrent.{Await, Future, duration} import scala.util.control.NonFatal import scala.util.{Failure, Success, Try} /** * A subservice that shares internal state of the shrine servlet. * * @author david * @since 12/2/15 */ @Path("/internalstatus") @Produces(Array(MediaType.APPLICATION_JSON)) case class StatusJaxrs(shrineConfig: TsConfig) extends Loggable { implicit def json4sFormats: Formats = DefaultFormats + new NodeSeqSerializer @GET @Path("version") def version: String = { val version = Version("changeMe") val versionString = Serialization.write(version) versionString } @GET @Path("config") def config: String = { //todo probably better to reach out and grab the config from ManuallyWiredShrineJaxrsResources once it is a singleton Serialization.write(Json4sConfig(shrineConfig)) } @GET @Path("summary") def summary: String = { val summary = Summary() Serialization.write(summary) } @GET @Path("i2b2") def i2b2: String = { val i2b2 = I2b2() Serialization.write(i2b2) } @GET @Path("optionalParts") def optionalParts: String = { val optionalParts = OptionalParts() Serialization.write(optionalParts) } @GET @Path("hub") def hub: String = { val hub = Hub() Serialization.write(hub) } @GET @Path("adapter") def adapter: String = { val adapter = Adapter() Serialization.write(adapter) } @GET @Path("qep") def qep: String = { val qep = Qep() Serialization.write(qep) } @GET @Path("keystore") def keystore: String = { Serialization.write(KeyStoreReport()) } } /* todo fill in later when you take the time to get the right parts in place SHRINE-1529 case class KeyStoreEntryReport( alias:String, commonName:String, md5Signature:String ) */ case class SiteStatus(siteAlias: String, theyHaveMine: Boolean, haveTheirs: Boolean, url: String, timeOutError: Boolean = false) extends DefaultJsonSupport case class AbbreviatedKeyStoreEntry(alias: String, cn: String, md5: String) extends DefaultJsonSupport case class KeyStoreReport( fileName: String, password: String = "REDACTED", privateKeyAlias: Option[String], owner: Option[String], issuer: Option[String], - expires: Date, + expires: Long, md5Signature: String, sha256Signature: String, caTrustedAlias: Option[String], caTrustedSignature: Option[String], remoteSiteStatuses: Seq[SiteStatus], isHub: Boolean, abbreviatedEntries: Seq[AbbreviatedKeyStoreEntry] // keyStoreContents:List[KeyStoreEntryReport] //todo SHRINE-1529 ) //todo build new API for the dashboard to use to check signatures object KeyStoreReport { def apply(): KeyStoreReport = { val keyStoreDescriptor: KeyStoreDescriptor = ShrineOrchestrator.keyStoreDescriptor val certCollection: BouncyKeyStoreCollection = ShrineOrchestrator.certCollection val maybeCaEntry: Option[KeyStoreEntry] = certCollection match { case DownStreamCertCollection(_, caEntry, _) => Some(caEntry) case HubCertCollection(_, caEntry, _) => Some(caEntry) case px: PeerCertCollection => None } val siteStatusesPreZip = ShaVerificationService(certCollection.remoteSites.toList) val siteStatuses = siteStatusesPreZip.zipWithIndex def sortFormat(input: String): Option[String] = { if (input.isEmpty) None else { def isLong(str: String) = str.split('=').headOption.getOrElse(str).length > 2 // Just an ugly sort for formatting purposes. I want The long key last, and otherwise just // Sort them lexicographically. Some(input.split(", ").sortBy(a => (isLong(a), a)).mkString(", ")) } } lazy val blockForSiteStatuses = siteStatuses.map(fut => Try(Await.result(fut._1, new FiniteDuration(5, duration.SECONDS))) match { case Success(Some(status)) => status case Success(None) => Log.warn("There was an issue with the verifySignature endpoint, check that we have network connectivity") SiteStatus(certCollection.remoteSites(fut._2).alias, false, false, "", true) case Failure(exc) => Log.warn("We timed out while trying to connect to the verifySignature endpoint, please check network connectivity") SiteStatus(certCollection.remoteSites(fut._2).alias, false, false, "", true) }) new KeyStoreReport( fileName = keyStoreDescriptor.file, privateKeyAlias = keyStoreDescriptor.privateKeyAlias, owner = sortFormat(certCollection.myEntry.cert.getSubjectDN.getName), issuer = sortFormat(certCollection.myEntry.cert.getIssuerDN.getName), - expires = certCollection.myEntry.cert.getNotAfter, + expires = certCollection.myEntry.cert.getNotAfter.getTime, md5Signature = UtilHasher.encodeCert(certCollection.myEntry.cert, "MD5"), sha256Signature = UtilHasher.encodeCert(certCollection.myEntry.cert, "SHA-256"), //todo sha1 signature if needed caTrustedAlias = maybeCaEntry.map(_.aliases.first), caTrustedSignature = maybeCaEntry.map(entry => UtilHasher.encodeCert(entry.cert, "MD5")), remoteSiteStatuses = blockForSiteStatuses, isHub = keyStoreDescriptor.trustModel == SingleHubModel(true), abbreviatedEntries = certCollection.allEntries.map(entry => AbbreviatedKeyStoreEntry( entry.aliases.first, entry.commonName.getOrElse("No common name"), UtilHasher.encodeCert(entry.cert, "MD5"))).toList // keyStoreContents = certCollection.caCerts.zipWithIndex.map((cert: ((Principal, X509Certificate), Int)) => KeyStoreEntryReport(keyStoreDescriptor.caCertAliases(cert._2),cert._1._1.getName,toMd5(cert._1._2))).to[List] ) } } case class I2b2(pmUrl: String, crcUrl: Option[String], ontUrl: String, i2b2Domain: String, username: String, crcProject: String, ontProject: String) object I2b2 { def apply(): I2b2 = new I2b2( pmUrl = ShrineOrchestrator.pmPoster.url, crcUrl = ShrineOrchestrator.adapterComponents.map(_.i2b2AdminService.crcUrl), - ontUrl = "", //todo. Grab from HiveConfigd? + ontUrl = ShrineOrchestrator.ontEndpoint.url.toString, i2b2Domain = ShrineOrchestrator.crcHiveCredentials.domain, username = ShrineOrchestrator.crcHiveCredentials.username, crcProject = ShrineOrchestrator.crcHiveCredentials.projectId, ontProject = ShrineOrchestrator.ontologyMetadata.client match { case client: PosterOntClient => client.hiveCredentials.projectId case _ => "" } ) } case class DownstreamNode(name: String, url: String) // Replaces StewardQueryAuthorizationService so that we never transmit a password case class Steward(stewardBaseUrl: String, qepUsername: String, password: String = "REDACTED") case class Qep( maxQueryWaitTimeMillis: Long, create: Boolean, attachSigningCert: Boolean, authorizationType: String, includeAggregateResults: Boolean, authenticationType: String, steward: Option[Steward], broadcasterUrl: Option[String], trustModel: String, trustModelIsHub: Boolean ) object Qep { val key = "shrine.queryEntryPoint." import ShrineOrchestrator.queryEntryPointComponents def apply(): Qep = new Qep( maxQueryWaitTimeMillis = queryEntryPointComponents.fold(0L)(_.i2b2Service.queryTimeout.toMicros), create = queryEntryPointComponents.isDefined, attachSigningCert = queryEntryPointComponents.fold(false)(_.i2b2Service.broadcastAndAggregationService.attachSigningCert), authorizationType = queryEntryPointComponents.fold("")(_.i2b2Service.authorizationService.getClass.getSimpleName), includeAggregateResults = queryEntryPointComponents.fold(false)(_.i2b2Service.includeAggregateResult), authenticationType = queryEntryPointComponents.fold("")(_.i2b2Service.authenticator.getClass.getSimpleName), steward = queryEntryPointComponents.flatMap(qec => checkStewardAuthorization(qec.shrineService.authorizationService)), broadcasterUrl = queryEntryPointComponents.flatMap(qec => checkBroadcasterUrl(qec.i2b2Service.broadcastAndAggregationService)), trustModel = ShrineOrchestrator.keyStoreDescriptor.trustModel.description, trustModelIsHub = ShrineOrchestrator.keyStoreDescriptor.trustModel match { case sh: SingleHubModel => true case PeerToPeerModel => false } ) def checkStewardAuthorization(auth: QueryAuthorizationService): Option[Steward] = auth match { case sa: StewardQueryAuthorizationService => Some(Steward(sa.stewardBaseUrl.toString, sa.qepUserName)) case _ => None } //TODO: Double check with Dave that this is the right url def checkBroadcasterUrl(broadcaster: BroadcastAndAggregationService): Option[String] = broadcaster match { case a: HubBroadcastAndAggregationService => a.broadcasterClient match { case PosterBroadcasterClient(poster, _) => Some(poster.url) case _ => None } case _ => None } } object DownstreamNodes { def get(): Seq[DownstreamNode] = { ShrineOrchestrator.hubComponents.fold(Seq.empty[DownstreamNode])(_.broadcastDestinations.map(DownstreamNode(_)).to[Seq]) } } object DownstreamNode { def apply(nodeHandle: NodeHandle): DownstreamNode = new DownstreamNode( nodeHandle.nodeId.name, nodeHandle.client.url.map(_.toString).getOrElse("not applicable")) } case class Adapter(crcEndpointUrl: String, setSizeObfuscation: Boolean, adapterLockoutAttemptsThreshold: Int, adapterMappingsFilename: Option[String], adapterMappingsDate: Option[Long] ) object Adapter { def apply(): Adapter = { val crcEndpointUrl = ShrineOrchestrator.adapterComponents.fold("")(_.i2b2AdminService.crcUrl) val setSizeObfuscation = ShrineOrchestrator.adapterComponents.fold(false)(_.i2b2AdminService.obfuscate) val adapterLockoutAttemptsThreshold = ShrineOrchestrator.adapterComponents.fold(0)(_.i2b2AdminService.adapterLockoutAttemptsThreshold) val adapterMappingsFileName = mappingFileInfo.map(_._1) val adapterMappingsFileDate = mappingFileInfo.map(_._2) Adapter(crcEndpointUrl, setSizeObfuscation, adapterLockoutAttemptsThreshold, adapterMappingsFileName, adapterMappingsFileDate) } def mappingFileInfo: Option[(String, Long, String)] = ShrineOrchestrator.adapterComponents.map(ac => (ac.adapterMappings.source, ac.lastModified, ac.adapterMappings.version)) } case class Hub(shouldQuerySelf: Boolean, //todo don't use this field any more. Drop it when possible create: Boolean, downstreamNodes: Seq[DownstreamNode]) object Hub { def apply(): Hub = { val shouldQuerySelf = false val create = ShrineOrchestrator.hubComponents.isDefined val downstreamNodes = DownstreamNodes.get() Hub(shouldQuerySelf, create, downstreamNodes) } } case class OptionalParts(isHub: Boolean, stewardEnabled: Boolean, shouldQuerySelf: Boolean, //todo don't use this field any more. Drop it when possible downstreamNodes: Seq[DownstreamNode]) object OptionalParts { def apply(): OptionalParts = { OptionalParts( ShrineOrchestrator.hubComponents.isDefined, ShrineOrchestrator.queryEntryPointComponents.fold(false)(_.shrineService.authorizationService.isInstanceOf[StewardQueryAuthorizationService]), shouldQuerySelf = false, DownstreamNodes.get() ) } } case class Summary( isHub: Boolean, shrineVersion: String, shrineBuildDate: String, ontologyVersion: String, ontologyVersionTerm: String, ontologyTerm: String, queryResult: Option[SingleNodeResult], adapterMappingsFileName: Option[String], adapterMappingsDate: Option[Long], adapterOk: Boolean, keystoreOk: Boolean, hubOk: Boolean, qepOk: Boolean ) object Summary { val term = Term(ShrineOrchestrator.shrineConfig.getString("networkStatusQuery")) def runQueryRequest: BroadcastMessage = { val domain = "happy" val username = "happy" val networkAuthn = AuthenticationInfo(domain, username, Credential("", isToken = false)) val queryDefinition = QueryDefinition("TestQuery", OccuranceLimited(1, term)) import scala.concurrent.duration._ val req = RunQueryRequest( "happyProject", 3.minutes, networkAuthn, None, None, Set(ResultOutputType.PATIENT_COUNT_XML), queryDefinition) ShrineOrchestrator.signerVerifier.sign(BroadcastMessage(req.networkQueryId, networkAuthn, req), SigningCertStrategy.Attach) } def apply(): Summary = { val message = runQueryRequest val queryResult: Option[SingleNodeResult] = ShrineOrchestrator.adapterService.map { adapterService => import scala.concurrent.duration._ val start = System.currentTimeMillis val resultAttempt: Try[Result] = Try(adapterService.handleRequest(message)) val end = System.currentTimeMillis val elapsed = (end - start).milliseconds resultAttempt match { case scala.util.Success(result) => result case scala.util.Failure(throwable) => FailureResult(NodeId("Local"), throwable) } } val adapterOk = queryResult.fold(true) { case r: Result => true case f: FailureResult => false } val hubOk = ShrineOrchestrator.hubComponents.fold(true) { hubComponents => val maxQueryWaitTime = hubComponents.broadcasterMultiplexerService.maxQueryWaitTime val broadcaster: Broadcaster = hubComponents.broadcasterMultiplexerService.broadcaster val message = runQueryRequest val triedMultiplexer = Try(broadcaster.broadcast(message)) //todo just use fold()() in scala 2.12 triedMultiplexer.toOption.fold(false) { multiplexer => val responses = Await.result(multiplexer.responses, maxQueryWaitTime).toSeq val failures = responses.collect { case f: FailureResult => f } val timeouts = responses.collect { case t: Timeout => t } val validResults = responses.collect { case r: Result => r } failures.isEmpty && timeouts.isEmpty && (validResults.size == broadcaster.destinations.size) } } val adapterMappingInfo = Adapter.mappingFileInfo val ontologyVersion = try { ShrineOrchestrator.ontologyMetadata.ontologyVersion } catch { case NonFatal(x) => Log.info("Problem while getting ontology version", x) s"Unavailable due to: ${x.getMessage}" } Summary( isHub = ShrineOrchestrator.hubComponents.isDefined, shrineVersion = Versions.version, shrineBuildDate = Versions.buildDate, //todo in scala 2.12, do better ontologyVersion = ontologyVersion, ontologyVersionTerm = OntClientOntologyMetadata.versionContainerTerm, ontologyTerm = term.value, queryResult = queryResult, adapterMappingsFileName = adapterMappingInfo.map(_._1), adapterMappingsDate = adapterMappingInfo.map(_._2), adapterOk = adapterOk, keystoreOk = true, //todo something for this hubOk = hubOk, qepOk = true //todo something for this ) } } case class Version(version: String) //todo SortedMap when possible case class Json4sConfig(keyValues: Map[String, String]) object Json4sConfig { def isPassword(key: String): Boolean = { if (key.toLowerCase.contains("password")) true else false } def apply(config: TsConfig): Json4sConfig = { val entries: Set[(String, String)] = config.entrySet.asScala.to[Set].map(x => (x.getKey, x.getValue.render())).filterNot(x => isPassword(x._1)) val sortedMap: Map[String, String] = entries.toMap Json4sConfig(sortedMap) } } class PermittedHostOnly extends ContainerRequestFilter { //todo generalize for happy, too //todo for tomcat 8 see https://jersey.java.net/documentation/latest/filters-and-interceptors.html for a cleaner version //shell code from http://stackoverflow.com/questions/17143514/how-to-add-custom-response-and-abort-request-in-jersey-1-11-filters //how to apply in http://stackoverflow.com/questions/4358213/how-does-one-intercept-a-request-during-the-jersey-lifecycle override def filter(requestContext: ContainerRequest): ContainerRequest = { val hostOfOrigin = requestContext.getBaseUri.getHost val shrineConfig: TsConfig = ShrineOrchestrator.config val permittedHostOfOrigin: String = shrineConfig.getOption("shrine.status.permittedHostOfOrigin", _.getString).getOrElse("localhost") val path = requestContext.getPath //happy and internalstatus API calls must come from the same host as tomcat is running on (hopefully the dashboard servlet). // todo access to the happy service permitted for SHRINE 1.21 per SHRINE-1366 // restrict access to happy service when database work resumes as part of SHRINE- // if ((path.contains("happy") || path.contains("internalstatus")) && (hostOfOrigin != permittedHostOfOrigin)) { if (path.contains("internalstatus") && (hostOfOrigin != permittedHostOfOrigin)) { val response = Response.status(Response.Status.UNAUTHORIZED).entity(s"Only available from $permittedHostOfOrigin, not $hostOfOrigin, controlled by shrine.status.permittedHostOfOrigin in shrine.conf").build() throw new WebApplicationException(response) } else requestContext } } object ShaVerificationService extends Loggable with DefaultJsonSupport { //todo: remove duplication with StewardQueryAuthorizationService import akka.pattern.ask import org.json4s.native.JsonMethods.parseOpt import system.dispatcher // execution context for futures implicit val system = ActorSystem("AuthorizationServiceActors", ConfigFactory.load("shrine")) //todo use shrine's config val certCollection = ShrineOrchestrator.certCollection def sendHttpRequest(httpRequest: HttpRequest): Future[HttpResponse] = { implicit val timeout: Timeout = Timeout.durationToTimeout(new FiniteDuration(5, duration.SECONDS)) //5 seconds implicit def json4sFormats: Formats = DefaultFormats implicit def trustfulSslContext: SSLContext = { object BlindFaithX509TrustManager extends X509TrustManager { def checkClientTrusted(chain: Array[X509Certificate], authType: String) = info(s"Client asked BlindFaithX509TrustManager to check $chain for $authType") def checkServerTrusted(chain: Array[X509Certificate], authType: String) = info(s"Server asked BlindFaithX509TrustManager to check $chain for $authType") def getAcceptedIssuers = Array[X509Certificate]() } val context = SSLContext.getInstance("TLS") context.init(Array[KeyManager](), Array(BlindFaithX509TrustManager), null) context } implicit def trustfulSslContextProvider: SSLContextProvider = { SSLContextProvider.forContext(trustfulSslContext) } class CustomClientSSLEngineProvider extends ClientSSLEngineProvider { def apply(pc: PipelineContext) = ClientSSLEngineProvider.default(trustfulSslContextProvider).apply(pc) } implicit def sslEngineProvider: ClientSSLEngineProvider = new CustomClientSSLEngineProvider val responseFuture: Future[HttpResponse] = for { HostConnectorInfo(hostConnector, _) <- { val hostConnectorSetup = new HostConnectorSetup(httpRequest.uri.authority.host.address, httpRequest.uri.authority.port, sslEncryption = httpRequest.uri.scheme == "https")( sslEngineProvider = sslEngineProvider) IO(Http) ask hostConnectorSetup } response <- sendReceive(hostConnector).apply(httpRequest) _ <- hostConnector ask Http.CloseAll } yield response responseFuture } type MaybeSiteStatus = Future[Option[SiteStatus]] def apply(sites: Seq[RemoteSite]): Seq[MaybeSiteStatus] = sites.map(curl) def curl(site: RemoteSite): MaybeSiteStatus = { val shaEntry = certCollection match { case HubCertCollection(_, caEntry, _) => caEntry case PeerCertCollection(my, _, _) => my case DownStreamCertCollection(_, caEntry, _) => caEntry } val sha256 = UtilHasher.encodeCert(shaEntry.cert, "SHA-256") implicit val formats = org.json4s.DefaultFormats val request = Post(s"https://${site.url}:${site.port}/shrine-dashboard/status/verifySignature") .withEntity( // For some reason, FormData isn't producing the correct HTTP call, so we do it manually HttpEntity.apply( ContentType( MediaTypes.`application/x-www-form-urlencoded`, HttpCharsets.`UTF-8`), s"sha256=$sha256")) for {response <- sendHttpRequest(request) rawResponse = new String(response.entity.data.toByteArray) status = parseOpt(rawResponse).fold(handleError(rawResponse))(_.extractOpt[ShaResponse] match { case Some(ShaResponse(ShaResponse.badFormat, false)) => error(s"Somehow, this client is sending an incorrectly formatted SHA256 signature to the dashboard. Offending sig: $sha256") None case Some(ShaResponse(sha, true)) => Some(SiteStatus(site.alias, theyHaveMine = true, haveTheirs = doWeHaveCert(sha), site.url)) case Some(ShaResponse(sha, false)) => Some(SiteStatus(site.alias, theyHaveMine = false, haveTheirs = doWeHaveCert(sha), site.url)) case None => InvalidVerifySignatureResponse(rawResponse) None })} yield status } def doWeHaveCert(sha256: String): Boolean = UtilHasher(certCollection).handleSig(sha256).found def handleError(response: String): Option[SiteStatus] = { InvalidVerifySignatureResponse(response) None } case class InvalidVerifySignatureResponse(response: String) extends AbstractProblem(ProblemSources.ShrineApp) { override def summary: String = "The client for handling certificate diagnostic across Dashboards in the Status Service received an invalid response from verifySignature" override def description: String = s"verifySig produced the invalid response `$response`" } } diff --git a/apps/shrine-app/src/main/scala/net/shrine/wiring/ShrineOrchestrator.scala b/apps/shrine-app/src/main/scala/net/shrine/wiring/ShrineOrchestrator.scala index fa075ef57..fc996ac96 100644 --- a/apps/shrine-app/src/main/scala/net/shrine/wiring/ShrineOrchestrator.scala +++ b/apps/shrine-app/src/main/scala/net/shrine/wiring/ShrineOrchestrator.scala @@ -1,157 +1,159 @@ package net.shrine.wiring import javax.sql.DataSource import com.typesafe.config.Config import net.shrine.adapter.AdapterComponents import net.shrine.adapter.dao.AdapterDao import net.shrine.adapter.service.{AdapterRequestHandler, AdapterResource, AdapterService, I2b2AdminResource, I2b2AdminService} import net.shrine.broadcaster.dao.HubDao import net.shrine.broadcaster.dao.squeryl.SquerylHubDao import net.shrine.broadcaster.service.HubComponents import net.shrine.client.{EndpointConfig, JerseyHttpClient, OntClient, Poster, PosterOntClient} import net.shrine.config.ConfigExtensions import net.shrine.config.mappings.AdapterMappings import net.shrine.crypto.{BouncyKeyStoreCollection, KeyStoreDescriptorParser, SignerVerifierAdapter, TrustParam} import net.shrine.dao.squeryl.{DataSourceSquerylInitializer, SquerylDbAdapterSelecter, SquerylInitializer} import net.shrine.happy.{HappyShrineResource, HappyShrineService} import net.shrine.log.Loggable import net.shrine.ont.data.OntClientOntologyMetadata import net.shrine.protocol.{HiveCredentials, NodeId, ResultOutputType, ResultOutputTypes} import net.shrine.qep.{I2b2BroadcastResource, QueryEntryPointComponents, ShrineResource} import net.shrine.slick.TestableDataSourceCreator import net.shrine.source.ConfigSource import net.shrine.status.StatusJaxrs import org.squeryl.internals.DatabaseAdapter /** * @author clint * @since Jan 14, 2014 * * Application wiring for Shrine. */ object ShrineOrchestrator extends ShrineJaxrsResources with Loggable { override def resources: Iterable[AnyRef] = { Seq(happyResource,statusJaxrs) ++ shrineResource ++ i2b2BroadcastResource ++ adapterResource ++ i2b2AdminResource ++ hubComponents.map(_.broadcasterMultiplexerResource) } //todo another pass to put things only used in one place into that place's apply(Config) //Load config from file on the classpath called "shrine.conf" lazy val config: Config = ConfigSource.config val shrineConfig = config.getConfig("shrine") protected lazy val nodeId: NodeId = NodeId(shrineConfig.getString("humanReadableNodeName")) //TODO: Don't assume keystore lives on the filesystem, could come from classpath, etc lazy val keyStoreDescriptor = KeyStoreDescriptorParser(shrineConfig.getConfig("keystore"), shrineConfig.getConfigOrEmpty("hub"), shrineConfig.getConfigOrEmpty("queryEntryPoint")) lazy val certCollection: BouncyKeyStoreCollection = BouncyKeyStoreCollection.fromFileRecoverWithClassPath(keyStoreDescriptor) protected lazy val keystoreTrustParam: TrustParam = TrustParam.BouncyKeyStore(certCollection) //todo used by the adapterServide and happyShrineService, but not by the QEP. maybe each can have its own signerVerivier lazy val signerVerifier = SignerVerifierAdapter(certCollection) protected lazy val dataSource: DataSource = TestableDataSourceCreator.dataSource(shrineConfig.getConfig("squerylDataSource.database")) protected lazy val squerylAdapter: DatabaseAdapter = SquerylDbAdapterSelecter.determineAdapter(shrineConfig.getString("shrineDatabaseType")) protected lazy val squerylInitializer: SquerylInitializer = new DataSourceSquerylInitializer(dataSource, squerylAdapter) //todo it'd be better for the adapter and qep to each have its own connection to the pm cell. private lazy val pmEndpoint: EndpointConfig = shrineConfig.getConfigured("pmEndpoint", EndpointConfig(_)) lazy val pmPoster: Poster = Poster(certCollection,pmEndpoint) protected lazy val breakdownTypes: Set[ResultOutputType] = shrineConfig.getOptionConfigured("breakdownResultOutputTypes", ResultOutputTypes.fromConfig).getOrElse(Set.empty) //todo why does the qep need a HubDao ? protected lazy val hubDao: HubDao = new SquerylHubDao(squerylInitializer, new net.shrine.broadcaster.dao.squeryl.tables.Tables) //todo really should be part of the adapter config, but is out in shrine's part of the name space lazy val crcHiveCredentials: HiveCredentials = shrineConfig.getConfigured("hiveCredentials", HiveCredentials(_, HiveCredentials.CRC)) val adapterComponents:Option[AdapterComponents] = shrineConfig.getOptionConfiguredIf("adapter", AdapterComponents( _, certCollection, squerylInitializer, breakdownTypes, crcHiveCredentials, signerVerifier, pmPoster, nodeId )) + if (adapterComponents.isEmpty) + warn("Adapter Components is improperly configured, please check the adapter section in shrine.conf") //todo maybe just break demeter too use this lazy val adapterService: Option[AdapterService] = adapterComponents.map(_.adapterService) //todo maybe just break demeter too use this lazy val i2b2AdminService: Option[I2b2AdminService] = adapterComponents.map(_.i2b2AdminService) //todo this is only used by happy lazy val adapterDao: Option[AdapterDao] = adapterComponents.map(_.adapterDao) //todo this is only used by happy lazy val adapterMappings: Option[AdapterMappings] = adapterComponents.map(_.adapterMappings) val shouldQuerySelf = "hub.shouldQuerySelf" lazy val localAdapterServiceOption: Option[AdapterRequestHandler] = if(shrineConfig.getOption(shouldQuerySelf,_.getBoolean).getOrElse(false)) { //todo give this a default value (of false) in the reference.conf for the Hub, and make it part of the Hub's apply(config) require(adapterService.isDefined, s"Self-querying requested because shrine.$shouldQuerySelf is true, but this node is not configured to have an adapter") adapterService } else None //todo eventually make this just another downstream node accessed via loopback lazy val hubComponents: Option[HubComponents] = shrineConfig.getOptionConfiguredIf("hub",HubComponents(_, keystoreTrustParam, nodeId, localAdapterServiceOption, breakdownTypes, hubDao )) //todo anything that requires qepConfig should be inside QueryEntryPointComponents's apply protected lazy val qepConfig = shrineConfig.getConfig("queryEntryPoint") lazy val queryEntryPointComponents:Option[QueryEntryPointComponents] = shrineConfig.getOptionConfiguredIf("queryEntryPoint", QueryEntryPointComponents(_, certCollection, breakdownTypes, hubComponents.map(_.broadcastDestinations), hubDao, //todo the QEP should not need the hub dao squerylInitializer, //todo could really have its own pmPoster //todo could really have its own )) protected lazy val pmUrlString: String = pmEndpoint.url.toString - private lazy val ontEndpoint: EndpointConfig = shrineConfig.getConfigured("ontEndpoint", EndpointConfig(_)) + private[shrine] lazy val ontEndpoint: EndpointConfig = shrineConfig.getConfigured("ontEndpoint", EndpointConfig(_)) protected lazy val ontPoster: Poster = Poster(certCollection,ontEndpoint) //todo only used by happy outside of here lazy val ontologyMetadata: OntClientOntologyMetadata = { import scala.concurrent.duration._ //TODO: XXX: Un-hard-code max wait time param val ontClient: OntClient = new PosterOntClient(shrineConfig.getConfigured("hiveCredentials", HiveCredentials(_, HiveCredentials.ONT)), 1.minute, ontPoster) new OntClientOntologyMetadata(ontClient) } protected lazy val happyResource: HappyShrineResource = new HappyShrineResource(HappyShrineService) protected lazy val statusJaxrs: StatusJaxrs = StatusJaxrs(config) protected lazy val shrineResource: Option[ShrineResource] = queryEntryPointComponents.map(x => ShrineResource(x.shrineService)) protected lazy val i2b2BroadcastResource: Option[I2b2BroadcastResource] = queryEntryPointComponents.map(x => new I2b2BroadcastResource(x.i2b2Service,breakdownTypes)) protected lazy val adapterResource: Option[AdapterResource] = adapterService.map(AdapterResource(_)) protected lazy val i2b2AdminResource: Option[I2b2AdminResource] = i2b2AdminService.map(I2b2AdminResource(_, breakdownTypes)) def poster(keystoreCertCollection: BouncyKeyStoreCollection)(endpoint: EndpointConfig): Poster = { val httpClient = JerseyHttpClient(keystoreCertCollection, endpoint) Poster(endpoint.url.toString, httpClient) } } diff --git a/apps/shrine-app/src/test/resources/shrine.conf b/apps/shrine-app/src/test/resources/shrine.conf index f43b7d88d..75e8f6248 100644 --- a/apps/shrine-app/src/test/resources/shrine.conf +++ b/apps/shrine-app/src/test/resources/shrine.conf @@ -1,94 +1,94 @@ shrine { problem { problemHandler = "net.shrine.problem.NoOpProblemHandler$" } ontEndpoint { - url = "http://example.com:9090/i2b2/rest/OntologyService/" + url = "http://services.i2b2.org/i2b2/rest/OntologyService" acceptAllCerts = true timeout { seconds = 1 } } keystore { file = "shrine.keystore" password = "justatestpassword" keyStoreType = "JKS" trustModelIsHub = true caCertAliases = [shrine-test-ca] isHub = false } queryEntryPoint { broadcasterServiceEndpoint { url = "https://shrine-dev1.catalyst:6443/shrine/testing" } audit { collectQepAudit = false database { slickProfileClassName = "slick.driver.H2Driver$" createTablesOnStart = true //for testing with H2 in memory, when not running unit tests. Set to false normally dataSourceFrom = "testDataSource" //Can be JNDI or testDataSource . Use testDataSource for tests, JNDI everywhere else testDataSource { driverClassName = "org.h2.Driver" url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" //H2 embedded in-memory for unit tests ;TRACE_LEVEL_SYSTEM_OUT=2 for H2's trace } } } trustModelIsHub = true authenticationType = "pm" //can be none, pm, or ecommons authorizationType = "shrine-steward" //can be none, shrine-steward, or hms-steward shrineSteward { qepUserName = "qep" qepPassword = "trustme" stewardBaseUrl = "https://localhost:6443" } } adapter { create = true audit { collectQepAudit = false database { slickProfileClassName = "slick.driver.H2Driver$" createTablesOnStart = true //for testing with H2 in memory, when not running unit tests. Set to false normally dataSourceFrom = "testDataSource" //Can be JNDI or testDataSource . Use testDataSource for tests, JNDI everywhere else testDataSource { driverClassName = "org.h2.Driver" url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" //H2 embedded in-memory for unit tests ;TRACE_LEVEL_SYSTEM_OUT=2 for H2's trace } } } } squerylDataSource { database { dataSourceFrom = "testDataSource" //Can be JNDI or testDataSource . Use testDataSource for tests, JNDI everywhere else testDataSource { driverClassName = "org.h2.Driver" url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" //H2 embedded in-memory for unit tests ;TRACE_LEVEL_SYSTEM_OUT=2 for H2's trace } } } // squerylDataSource { // database { // dataSourceFrom = "testDataSource" //Can be JNDI or testDataSource . Use testDataSource for tests, JNDI everywhere else // // testDataSource { // driverClassName = "org.h2.Driver" // url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" //H2 embedded in-memory for unit tests ;TRACE_LEVEL_SYSTEM_OUT=2 for H2's trace // } // } // } } \ No newline at end of file diff --git a/apps/shrine-app/src/test/scala/net/shrine/status/StatusJaxrsTest.scala b/apps/shrine-app/src/test/scala/net/shrine/status/StatusJaxrsTest.scala index f8fc71865..0fe325ac6 100644 --- a/apps/shrine-app/src/test/scala/net/shrine/status/StatusJaxrsTest.scala +++ b/apps/shrine-app/src/test/scala/net/shrine/status/StatusJaxrsTest.scala @@ -1,129 +1,129 @@ package net.shrine.status import com.typesafe.config.ConfigFactory import net.shrine.util.{ShouldMatchersForJUnit, SingleHubModel} import org.json4s.native.Serialization import org.json4s.{DefaultFormats, Formats} import org.junit.Test import scala.collection.immutable.Map /** * Tests for StatusJaxrs * * @author david * @since 12/2/15 */ class StatusJaxrsTest extends ShouldMatchersForJUnit { implicit def json4sFormats: Formats = DefaultFormats val expectedConfig = ConfigFactory.load("shrine") //new File("/Users/ty/shrine/apps/shrine-app/src/test/resources/shrine.conf")) val statusJaxrs = StatusJaxrs(expectedConfig) @Test def testVersion() = { val versionString = statusJaxrs.version val version = Serialization.read[Version](versionString) version should equal(Version("changeMe")) } @Test def testConfig() = { val expectedJson4sConfig = Json4sConfig(expectedConfig) val configString = statusJaxrs.config val config = Serialization.read[Json4sConfig](configString) config should equal(expectedJson4sConfig) val passwordKeys = config.keyValues.filter(x => Json4sConfig.isPassword(x._1)) passwordKeys should equal(Map.empty[String,String]) } @Test def testSummary() = { val summaryString = statusJaxrs.summary val summary = Serialization.read[Summary](summaryString) summary.isHub should be (true) summary.adapterMappingsFileName.isDefined should be (true) summary.adapterMappingsDate.isEmpty should be (false) summary.adapterOk should be (true) summary.keystoreOk should be (true) summary.hubOk should be (false) summary.qepOk should be (true) } @Test def testI2b2() = { val i2b2String = statusJaxrs.i2b2 - + println(i2b2String) val i2b2 = Serialization.read[I2b2](i2b2String) i2b2.crcUrl.isDefined should be (true) } @Test def testOptionalParts() = { val string = statusJaxrs.optionalParts val actual = Serialization.read[OptionalParts](string) actual.isHub should be (true) actual.stewardEnabled should be (true) actual.shouldQuerySelf should be (false) actual.downstreamNodes.size should be (4) } @Test def testHub() = { val string = statusJaxrs.hub val actual = Serialization.read[Hub](string) actual.create should be (true) actual.shouldQuerySelf should be (false) actual.downstreamNodes.size should be (4) } @Test def testQep() = { val string = statusJaxrs.qep val actual = Serialization.read[Qep](string) actual.create should be (true) actual.attachSigningCert should be (true) actual.authenticationType should be ("PmAuthenticator") actual.authorizationType should be ("StewardQueryAuthorizationService") actual.includeAggregateResults should be (false) actual.maxQueryWaitTimeMillis should be (300000000L) actual.trustModel should be (SingleHubModel(true).description) actual.trustModelIsHub should be (true) } @Test def testAdapter() = { val string = statusJaxrs.adapter val actual = Serialization.read[Adapter](string) actual.adapterLockoutAttemptsThreshold should be (10) } @Test def testKeyStore() = { val string = statusJaxrs.keystore println(string) val actual = Serialization.read[KeyStoreReport](string) } } diff --git a/apps/steward-app/src/main/js/app/assets/css/shrine.css b/apps/steward-app/src/main/js/app/assets/css/shrine.css index c0eacebb6..072517588 100644 --- a/apps/steward-app/src/main/js/app/assets/css/shrine.css +++ b/apps/steward-app/src/main/js/app/assets/css/shrine.css @@ -1,1145 +1,1147 @@ @charset "UTF-8"; /* import timeline.scss */ @font-face { font-family: 'Nexa'; src: url("../fnt/nexa/nexa-light-webfont.eot"); src: url("../fnt/nexa/nexa-light-webfont.eot?#iefix") format("embedded-opentype"), url("../fnt/nexa/nexa-light-webfont.woff2") format("woff2"), url("../fnt/nexa/nexa-light-webfont.woff") format("woff"), url("../fnt/nexa/nexa-light-webfont.ttf") format("truetype"), url("../fnt/nexa/nexa-light-webfont.svg#nexa_lightregular") format("svg"); font-weight: normal; font-style: normal; } @font-face { font-family: 'Nexa Bold'; src: url("../fnt/nexa/nexa-bold-webfont.eot"); src: url("../fnt/nexa/nexa-bold-webfont.eot?#iefix") format("embedded-opentype"), url("../fnt/nexa/nexa-bold-webfont.woff2") format("woff2"), url("../fnt/nexa/nexa-bold-webfont.woff") format("woff"), url("../fnt/nexa/nexa-bold-webfont.ttf") format("truetype"), url("../fnt/nexa/nexa-bold-webfont.svg#nexa_boldregular") format("svg"); font-weight: normal; font-style: normal; } @font-face { font-family: 'Open Sans Regular'; src: url("../fnt/open-sans/opensans-regular-webfont.eot"); src: url("../fnt/open-sans/opensans-regular-webfont.eot?#iefix") format("embedded-opentype"), url("../fnt/open-sans/opensans-regular-webfont.woff2") format("woff2"), url("../fnt/open-sans/opensans-regular-webfont.woff") format("woff"), url("../fnt/open-sans/opensans-regular-webfont.ttf") format("truetype"), url("../fnt/open-sans/opensans-regular-webfont.svg#open_sansregular") format("svg"); font-weight: normal; font-style: normal; } @font-face { font-family: 'Open Sans Semibold'; src: url("../fnt/open-sans/opensans-semibold-webfont.eot"); src: url("../fnt/open-sans/opensans-semibold-webfont.eot?#iefix") format("embedded-opentype"), url("../fnt/open-sans/opensans-semibold-webfont.woff2") format("woff2"), url("../fnt/open-sans/opensans-semibold-webfont.woff") format("woff"), url("../fnt/open-sans/opensans-semibold-webfont.ttf") format("truetype"), url("../fnt/open-sans/opensans-semibold-webfont.svg#open_sanssemibold") format("svg"); font-weight: normal; font-style: normal; } @font-face { font-family: 'Roboto Bold'; src: url("../fnt/roboto/roboto-bold-webfont.eot"); src: url("../fnt/roboto/roboto-bold-webfont.eot?#iefix") format("embedded-opentype"), url("../fnt/roboto/roboto-bold-webfont.woff2") format("woff2"), url("../fnt/roboto/roboto-bold-webfont.woff") format("woff"), url("../fnt/roboto/roboto-bold-webfont.ttf") format("truetype"), url("../fnt/roboto/roboto-bold-webfont.svg#robotobold") format("svg"); font-weight: normal; font-style: normal; } @font-face { font-family: 'Roboto Regular'; src: url("../fnt/roboto/roboto-regular-webfont.eot"); src: url("../fnt/roboto/roboto-regular-webfont.eot?#iefix") format("embedded-opentype"), url("../fnt/roboto/roboto-regular-webfont.woff2") format("woff2"), url("../fnt/roboto/roboto-regular-webfont.woff") format("woff"), url("../fnt/roboto/roboto-regular-webfont.ttf") format("truetype"), url("../fnt/roboto/roboto-regular-webfont.svg#robotoregular") format("svg"); font-weight: normal; font-style: normal; } .main-app { background-image: url("../img/Background_Shrine.jpg"); background-size: cover; background-color: transparent; background-attachment: fixed; width: 100%; height: 100%; width: calc(100vw); height: calc(100vh); min-width: 100%; min-height: 100%; } .shrine-navbar { background-color: rgba(255, 255, 255, 0.62); border-color: transparent; font-family: "Nexa"; color: #5d5d5d; min-height: 60px; width: 100%; height: 4.8em; } .shrine-brand { float: left; padding: 22px 15px; font-size: 30px; line-height: 30px; height: 30px; } .shrine-brand strong { font-family: "Nexa Bold"; color: #2c5566; } .shrine-navbar .shrine-institution-logo { background-image: url("/static/logo.png"); background-size: contain; background-color: transparent; background-color: rgba(255, 255, 255, 0.1); background-repeat: no-repeat; background-position: right top; margin-right: 2%; margin-top: 5px; width: 4em; height: 4em; max-height: 4em; max-width: 4em; } .shrine-button { cursor: pointer !important; background-color: transparent; border: none; } .shrine-button span { position: relative; bottom: 5px; } .shrine-btn-default { margin-right: 6px; border: none; } .shrine-btn-on { padding: 12px 12px; border-radius: 0; font-family: "Roboto Bold" !important; color: #FFFFFF !important; background: linear-gradient(rgba(4, 141, 190, 0.8), rgba(2, 89, 120, 0.8)), url("../img/bckg_diagonal_lines_no_border.png") !important; } .shrine-btn-off { padding: 6px 12px !important; border-radius: 4px !important; font-family: "Open Sans Semibold" !important; background-color: #8896A4 !important; color: #FFFFFF !important; } .shrine-on, .shrine-on a { font-family: "Roboto Bold" !important; color: #FFFFFF !important; background: linear-gradient(#048DBE, #025978) !important; } .shrine-off { font-family: "Roboto Regular" !important; color: #2C5566 !important; background-color: #ECEEEE !important; } .shrine-button.disabled, .shrine-button[disabled] { cursor: default !important; opacity: 0.2 !important; } .shrine-copy-bold { font-family: "Nexa Bold"; color: #64818e; } .shrine-copy { font-family: "Nexa"; color: #000000; } .row { margin-right: 0; margin-left: 0; } td.error, span.error { color: red; } td.error a, td.error a:hover, span.error a, span.error a:hover { color: inherit; text-decoration: underline !important; cursor: pointer; } td.ok, span.ok { color: green; } .form-group span { font-family: "Open Sans Semibold"; color: #2c5566; } fieldset button { color: #2E5366; } fieldset button:hover, form a:hover { color: #008CBA; text-decoration: none; cursor: pointer; } form a { font-family: "Open Sans Regular"; color: #647d8d; text-decoration: none; } footer img { margin-left: 10px; margin-top: 2px; } footer { background-color: rgba(50, 62, 74, 0.48); position: fixed; bottom: 0; left: 0; width: 100%; height: 83px; min-height: 83px; max-width: 100%; } table { background-image: url("../img/bckg_diagonal_lines_no_border.png"); border: 1px solid #CCD8DF; } .table tr > td:first-child { width: 20%; min-width: 140px; } .table tr > td.thin-col { width: 6%; min-width: 35px; } .table-striped > tbody > tr:nth-of-type(odd) { background-color: #EFF6F9; } .table-striped > tbody > tr:nth-of-type(even) { background-color: #FFFFFF; } thead tr { border: 1px solid #CCD8DF; } td { border-right: 1px solid #CCD8DF; overflow: hidden; max-width: 450px; word-break: break-all; } thead tr td, thead tr td label, tfoot tr td span { font-family: "Open Sans Semibold"; color: #003153; } td a, td a:hover { text-decoration: none !important; cursor: pointer; font-family: "Open Sans Semibold"; color: #003153; } .shrine-panel { background-image: url("../img/bckg_diagonal_lines.png"); background-size: 100% 100%; padding-right: 20px; padding-left: 20px; padding-top: 30px; padding-bottom: 30px; } /*! * Start Bootstrap - SB Admin 2 Bootstrap Admin Theme (http://startbootstrap.com) * Code licensed under the Apache License v2.0. * For details, see http://www.apache.org/licenses/LICENSE-2.0. */ body { background-image: url("../img/Background_Shrine.jpg"); background-repeat: no-repeat; background-size: 100% 100%; } #wrapper { width: 100%; padding-bottom: 83px; /* clearance space for footer at bottom of long page */ } .login-wrapper { margin-top: 51px; margin-right: 20px; } #page-wrapper { background-color: transparent; margin-top: 54px; margin-right: 20px; } .navbar-top-links li { display: inline-block; } .navbar-top-links li:last-child { margin-right: 15px; } .navbar-top-links li a { padding: 15px; min-height: 50px; font-family: "Open Sans Semibold"; color: #2c5566; } .navbar-top-links .dropdown-menu li { font-family: "Open Sans Semibold"; color: #2c5566; display: block; } .navbar-top-links .dropdown-menu li:last-child { margin-right: 0; } .navbar-top-links .dropdown-menu li a { padding: 3px 20px; min-height: 0; } .navbar-top-links .dropdown-menu li a div { white-space: normal; } .navbar-top-links .dropdown-messages, .navbar-top-links .dropdown-tasks, .navbar-top-links .dropdown-alerts { width: 310px; min-width: 0; } .navbar-top-links .dropdown-messages { margin-left: 5px; } .navbar-top-links .dropdown-tasks { margin-left: -59px; } .navbar-top-links .dropdown-alerts { margin-left: -123px; } .navbar-top-links { right: 0; left: auto; } .sidebar .sidebar-nav.navbar-collapse { padding-right: 0; padding-left: 0; } .sidebar .sidebar-search { padding: 15px; } .sidebar ul li { border-bottom: 1px solid #e7e7e7; } .sidebar ul li a.active { background-color: #eee; } .sidebar .arrow { float: right; } .sidebar .fa.arrow:before { content: "\f104"; } .sidebar .active > a > .fa.arrow:before { content: "\f107"; } .sidebar .nav-second-level li, .sidebar .nav-third-level li { border-bottom: 0 !important; } .sidebar .nav-second-level li a { padding-left: 37px; } .sidebar .nav-third-level li a { padding-left: 52px; } @media (min-width: 768px) { .sidebar { z-index: 1; margin-top: 51px; } .navbar-top-links .dropdown-messages, .navbar-top-links .dropdown-tasks, .navbar-top-links .dropdown-alerts { margin-left: auto; } } .btn-outline { color: inherit; background-color: transparent; transition: all .5s; } .btn-primary.btn-outline { color: #428bca; } .btn-success.btn-outline { color: #5cb85c; } .btn-info.btn-outline { color: #5bc0de; } .btn-warning.btn-outline { color: #f0ad4e; } .btn-danger.btn-outline { color: #d9534f; } .btn-primary.btn-outline:hover, .btn-success.btn-outline:hover, .btn-info.btn-outline:hover, .btn-warning.btn-outline:hover, .btn-danger.btn-outline:hover { color: #fff; } .chat { margin: 0; padding: 0; list-style: none; } .chat li { margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px dotted #999; } .chat li.left .chat-body { margin-left: 60px; } .chat li.right .chat-body { margin-right: 60px; } .chat li .chat-body p { margin: 0; } .panel .slidedown .glyphicon, .chat .glyphicon { margin-right: 5px; } .chat-panel .panel-body { height: 350px; overflow-y: scroll; } .login-panel { margin-top: 25%; } .flot-chart { display: block; height: 400px; } .flot-chart-content { width: 100%; height: 100%; } .dataTables_wrapper { position: relative; clear: both; } table.dataTable thead .sorting, table.dataTable thead .sorting_asc, table.dataTable thead .sorting_desc, table.dataTable thead .sorting_asc_disabled, table.dataTable thead .sorting_desc_disabled { background: 0 0; } table.dataTable thead .sorting_asc:after { content: "\f0de"; float: right; font-family: fontawesome; } table.dataTable thead .sorting_desc:after { content: "\f0dd"; float: right; font-family: fontawesome; } table.dataTable thead .sorting:after { content: "\f0dc"; float: right; font-family: fontawesome; color: rgba(50, 50, 50, 0.5); } .btn-circle { width: 30px; height: 30px; padding: 6px 0; border-radius: 15px; text-align: center; font-size: 12px; line-height: 1.428571429; } .btn-circle.btn-lg { width: 50px; height: 50px; padding: 10px 16px; border-radius: 25px; font-size: 18px; line-height: 1.33; } .btn-circle.btn-xl { width: 70px; height: 70px; padding: 10px 16px; border-radius: 35px; font-size: 24px; line-height: 1.33; } .show-grid [class^=col-] { padding-top: 10px; padding-bottom: 10px; border: 1px solid #ddd; background-color: #eee !important; } .show-grid { margin: 15px 0; } .huge { font-size: 40px; } .panel-green { border-color: #5cb85c; } .panel-green .panel-heading { border-color: #5cb85c; color: #fff; background-color: #5cb85c; } .panel-green a { color: #5cb85c; } .panel-green a:hover { color: #3d8b3d; } .panel-red { border-color: #d9534f; } .panel-red .panel-heading { border-color: #d9534f; color: #fff; background-color: #d9534f; } .panel-red a { color: #d9534f; } .panel-red a:hover { color: #b52b27; } .panel-yellow { border-color: #f0ad4e; } .panel-yellow .panel-heading { border-color: #f0ad4e; color: #fff; background-color: #f0ad4e; } .panel-yellow a { color: #f0ad4e; } .panel-yellow a:hover { color: #df8a13; } .modal-content { border: none; } .shrine-modal { background-color: white; border: 1px solid #2c5566; font-family: "Open Sans Semibold"; color: #2e5366; padding: 15px; } .shrine-modal form div.col-sm-12 { border: 1px solid rgba(2, 89, 120, 0.8); } .shrine-modal input, .shrine-modal textarea { border-radius: 0px; border: 1px solid #2c5566; } .shrine-modal span { font-family: "Nexa Bold"; color: #2e5366; } .shrine-modal span:hover { font-family: "Nexa Bold"; color: #008CBA; } .shrine-modal button { background-color: white; border: none; font-family: "Nexa Bold"; color: #2e5366; } .shrine-modal button span { position: relative; bottom: 6px; } .shrine-modal button:hover, .btn-success { font-family: "Nexa Bold"; color: #008CBA; background-color: transparent; border: none; } .shrine-login { margin-top: 8%; margin-left: 1%; } .shrine-content { overflow: auto; } /*Fix for resizeable text area.*/ textarea { resize: none; } @media (min-width: 768px) { .shrine-content { padding: 0; } } .shrine-calendar-input { margin-right: 1px; max-width: 50%; } i.shrine-close { float: right; margin-top: -40px; margin-right: -40px; cursor: pointer; color: #fff; border: 2px solid #C8CED1; border-radius: 30px; background: #8896a4; font-size: 31px; font-weight: normal; display: inline-block; line-height: 0px; padding: 11px 3px; font-style: normal; } i.shrine-close:hover { background: #008cba; } .shrine-close:before { content: "×"; } .timeline { position: relative; padding: 20px 0 20px; list-style: none; } .timeline:before { content: " "; position: absolute; top: 0; bottom: 0; left: 50%; width: 3px; margin-left: -1.5px; background-color: #eeeeee; } .timeline > li { position: relative; margin-bottom: 20px; } .timeline > li:before, .timeline > li:after { content: " "; display: table; } .timeline > li:after { clear: both; } .timeline > li:before, .timeline > li:after { content: " "; display: table; } .timeline > li:after { clear: both; } .timeline > li > .timeline-panel { float: left; position: relative; width: 46%; padding: 20px; border: 1px solid #d4d4d4; border-radius: 2px; -webkit-box-shadow: 0 1px 6px rgba(0, 0, 0, 0.175); box-shadow: 0 1px 6px rgba(0, 0, 0, 0.175); } .timeline > li > .timeline-panel:before { content: " "; display: inline-block; position: absolute; top: 26px; right: -15px; border-top: 15px solid transparent; border-right: 0 solid #ccc; border-bottom: 15px solid transparent; border-left: 15px solid #ccc; } .timeline > li > .timeline-panel:after { content: " "; display: inline-block; position: absolute; top: 27px; right: -14px; border-top: 14px solid transparent; border-right: 0 solid #fff; border-bottom: 14px solid transparent; border-left: 14px solid #fff; } .timeline > li > .timeline-badge { z-index: 100; position: absolute; top: 16px; left: 50%; width: 50px; height: 50px; margin-left: -25px; border-radius: 50% 50% 50% 50%; text-align: center; font-size: 1.4em; line-height: 50px; color: #fff; background-color: #999999; } .timeline > li.timeline-inverted > .timeline-panel { float: right; } .timeline > li.timeline-inverted > .timeline-panel:before { right: auto; left: -15px; border-right-width: 15px; border-left-width: 0; } .timeline > li.timeline-inverted > .timeline-panel:after { right: auto; left: -14px; border-right-width: 14px; border-left-width: 0; } .timeline-badge.primary { background-color: #2e6da4 !important; } .timeline-badge.success { background-color: #3f903f !important; } .timeline-badge.warning { background-color: #f0ad4e !important; } .timeline-badge.danger { background-color: #d9534f !important; } .timeline-badge.info { background-color: #5bc0de !important; } .timeline-title { margin-top: 0; color: inherit; } .timeline-body > p, .timeline-body > ul { margin-bottom: 0; } .timeline-body > p + p { margin-top: 5px; } @media (max-width: 767px) { ul.timeline:before { left: 40px; } ul.timeline > li > .timeline-panel { width: calc(100% - 90px); width: -moz-calc(100% - 90px); width: -webkit-calc(100% - 90px); } ul.timeline > li > .timeline-badge { top: 16px; left: 15px; margin-left: 0; } ul.timeline > li > .timeline-panel { float: right; } ul.timeline > li > .timeline-panel:before { right: auto; left: -15px; border-right-width: 15px; border-left-width: 0; } ul.timeline > li > .timeline-panel:after { right: auto; left: -14px; border-right-width: 14px; border-left-width: 0; } } /* -- stats-graph styles -- */ .stats-minimized, .stats-graph-minimized, .stats-ontology-minimized { cursor: pointer; opacity: .7; } .stats-minimized:hover, .stats-graph-minimized:hover, .stats-ontology-minimized:hover { border: 1px solid white; opacity: 1; } .stats-graph-minimized { transform: translate(60%, -20%) scale(0.1, 0.25); } .stats-ontology-minimized { transform: translate(60%, 0%) scale(0.1, 0.25); } .stats-stage-base, .stats-graph-container, .stats-ontology-container { padding-bottom: 0px; padding-top: 0px; color: white; margin: 0 auto; width: 100%; } .stats-graph-container { border-bottom: none; /*border-left: 1px solid #125c8a;;*/ overflow: auto; padding-top: .6em; /*background: linear-gradient(#048DBE, #025978) !important;*/ } .stats-ontology-container { overflow: auto; height: 100vh; } .stats-graph-container .bar-bg { background-color: white; height: 30px; margin: 0 auto 10px auto; line-height: 30px; font-size: 16px; font-family: Roboto, sans-serif; font-weight: 100; color: white; padding: 0 0 0 10px; position: relative; } .stats-graph-container .bar-bg .user { position: absolute; z-index: 4; left: .2em; } .stats-graph-container .bar-bg .bar { opacity: .8; z-index: 1; background-color: #125c8a; border: 1px solid #1c8ed6; height: 30px; transition: 0.7s; width: 100%; display: block; animation: stats-bar-before 1 1.8s; position: absolute; top: 0; left: 0; z-index: 2; padding-right: 8px; text-align: right; } .stats-graph-container .bar-bg .bar:hover { background: rgba(0, 0, 0, 0.5); cursor: pointer; } @keyframes stats-bar-before { 0% { width: 0px; } 100% { width: 100%; } } .digest ul { top: 15rem; left: 5rem; float: left; clear: left; margin: .25em; padding: 0; } .digest ul::before { display: none; } .digest ul li { display: block; position: relative; float: left; clear: both; right: auto; padding-left: 1em; width: auto; text-align: center; color: white; animation: showterm .25s; } .digest ul li a { display: block; position: relative; float: left; z-index: 4; margin: .25em; padding: .25em; background: white; color: #C0C0C0; border: 2px solid #94a0b4; border-radius: .5rem; transition: all .75s; font-weight: 300; font-family: sans-serif; } .digest .expandable { position: absolute; z-index: 4; top: 0; left: -.5em; margin: .65em; padding: 0; width: .8em; height: .8em; text-align: center; line-height: .6em; font-size: 1em; background-color: white; color: #C0C0C0; border: 1px solid #94a0b4; border-radius: .25rem; } .digest > ul { position: relative; font-family: "Georgia"; } .digest > ul:before { left: .5em; } .digest > ul:after { display: none; } @keyframes showterm { 0% { top: -.5rem; opacity: 0; } 100% { top: 0rem; opacity: 1; } } ul:after { color: blue; border: solid gray 1px; border-radius: .1em; } ul > li > a { color: blue; background: white; } .digest li span.expandable:hover, .digest li span.expandable:hover + a, .digest li span.expandable:hover + a + ul li a, .digest li a:hover, .digest li a:hover + ul li a { background: rgba(50, 130, 136, 0) !important; color: #fff; cursor: pointer; z-index: 1000; text-decoration: none; } .digest li a:hover + ul li::after, .digest li a:hover + ul li::before, .digest li a:hover + ul::before, .digest li a:hover + ul ul::before { border-color: #99C794 !important; } /* color settings */ a { font-family: "Roboto", sans-serif; font-size: 14px; font-weight: 400; line-height: 1.5em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #000000; } .red { background: rgba(158, 56, 68, 0.5) !important; border: 1px solid #9e3844 !important; } .dark-red { background: rgba(236, 95, 103, 0.5) !important; border: 1px solid #EC5F67 !important; } .dark-blue { background: rgba(53, 105, 151, 0.75) !important; border: 1px solid #6699CC !important; } .blue { background: rgba(102, 153, 204, 0.25) !important; border: 1px solid #6699CC !important; } .purple { background: rgba(197, 148, 197, 0.5) !important; border: 1px solid #C594C0 !important; } .ont-hidden { margin: auto; position: fixed; top: 100px; left: 0; right: 0; width: 50%; max-width: 630px; min-width: 320px; opacity: 0; height: auto; z-index: 2000; visibility: hidden; -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } .ont-overlay { position: fixed; width: 100%; height: 100%; visibility: visible; top: 0; left: 0; z-index: 2000; opacity: 1; background: rgba(0, 0, 0, 0.9); -webkit-transition: all 0.3s; -moz-transition: all 0.3s; transition: all 0.1s; } button.ont-close { display: block; background: transparent; color: #c0c0c0; right: 2%; top: 2%; position: absolute; border-radius: 9px; font-size: 20px; cursor: pointer; border: none; } button.ont-close:hover { color: #fff; } .tooltip-toggle { cursor: pointer; position: relative; } .tooltip-toggle::before { border: 1px solid rgba(255, 255, 255, 0.8); position: absolute; top: -5rem; left: -3rem; background-color: rgba(0, 0, 0, 0.8); border-radius: 5px; color: #fff; content: attr(aria-label); padding: 1rem; text-transform: none; -webkit-transition: all 2s; transition: all 2s; width: 160px; } .tooltip-toggle::after { position: absolute; top: -.8rem; left: 5rem; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 5px solid white; content: " "; font-size: 0; line-height: 0; margin-left: -5px; width: 0; } .tooltip-toggle::before, .tooltip-toggle::after { color: #efefef; font-family: monospace; font-size: 16px; opacity: 0; pointer-events: none; text-align: center; } .tooltip-toggle:focus::before, .tooltip-toggle:focus::after, .tooltip-toggle:hover::before, .tooltip-toggle:hover::after { opacity: 1; -webkit-transition: all 2s; transition: all 2s; } .topic-dropdown { z-index: 20000; margin: 4rem; left: 0%; color: rgba(0, 0, 0, 0.87); font-family: "Roboto", sans-serif; font-size: 14px; font-weight: 300; line-height: 1.5em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; position: fixed; } .topic-dropdown div.title { text-transform: uppercase; font-weight: 500; font-size: 1.3em; color: #63BCF8; } .topic-dropdown .tdd-btn { max-width: 1000px; outline: 0; /* takes care of blue outline */ display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; display: inline-flex; -webkit-box-align: center; -webkit-align-items: center; -ms-flex-align: center; align-items: center; -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; background: rgba(0, 0, 0, 0.75); border: 1px solid #63BCF8 !important; min-width: 400px; border: 0; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-sizing: border-box; padding: 16px 20px; color: #FFFFFF; font-size: 12px; font-weight: 300; letter-spacing: 1.2px; text-transform: uppercase; overflow: hidden; cursor: pointer; } .topic-dropdown .tdd-btn:hover { cursor: pointer; } .topic-dropdown .tdd-btn .tdd-list { position: absolute; top: 100%; left: 0px; background: rgba(0, 0, 0, 0.75); border: 1px solid #63BCF8 !important; width: 100%; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); text-align: left; opacity: 0; visibility: hidden; -webkit-transition: 0.3s ease; - transition: 0.3s ease; } + transition: 0.3s ease; + max-height: 600px; + overflow: auto; } .topic-dropdown .tdd-btn .tdd-list a { display: block; border-bottom: 1px solid rgba(0, 0, 0, 0.05); padding: 16px 0; color: inherit; text-decoration: none; background: none !important; } .topic-dropdown .tdd-btn .tdd-list:before { content: ''; position: absolute; top: -6px; left: 20px; width: 0; height: 0; box-shadow: 2px -2px 6px rgba(0, 0, 0, 0.05); border-top: 6px solid #63BCF8; border-right: 6px solid #63BCF8; border-bottom: 6px solid transparent; border-left: 6px solid transparent; -webkit-transform: rotate(-45deg); transform: rotate(-45deg); mix-blend-mode: multiple; } .topic-dropdown .tdd-btn .tdd-list li { z-index: 100; position: relative; padding: 0 20px; } .topic-dropdown .tdd-btn .tdd-list li.active { color: #5380F7; } .topic-dropdown .tdd-btn .tdd-list li:first-child { border-radius: 4px 4px 0 0; } .topic-dropdown .tdd-btn .tdd-list li:last-child { border-radius: 0 0 4px 4px; } .topic-dropdown .tdd-btn .tdd-list li:last-child a { border-bottom: 0; } .topic-dropdown .tdd-btn:focus .tdd-list, .topic-dropdown .tdd-btn:active .tdd-list { -webkit-transform: translate(0, 20px); transform: translate(0, 20px); opacity: 1; visibility: visible; } .dropdown-arrow { content: ''; opacity: .8; position: absolute; right: 10px; top: 43px; width: 0; height: 0; box-shadow: 2px -2px 6px rgba(0, 0, 0, 0.05); border-top: 4px solid #63BCF8; border-right: 4px solid #63BCF8; border-bottom: 4px solid transparent; border-left: 4px solid transparent; -webkit-transform: rotate(-45deg); transform: rotate(135deg); } diff --git a/apps/steward-app/src/main/js/app/assets/css/topic-dropdown.css b/apps/steward-app/src/main/js/app/assets/css/topic-dropdown.css index 9d047e8c3..72007a386 100644 --- a/apps/steward-app/src/main/js/app/assets/css/topic-dropdown.css +++ b/apps/steward-app/src/main/js/app/assets/css/topic-dropdown.css @@ -1,103 +1,105 @@ .topic-dropdown { z-index: 20000; margin: 4rem; left: 0%; color: rgba(0, 0, 0, 0.87); font-family: "Roboto", sans-serif; font-size: 14px; font-weight: 300; line-height: 1.5em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; position: fixed; } .topic-dropdown div.title { text-transform: uppercase; font-weight: 500; font-size: 1.3em; color: #63BCF8; } .topic-dropdown .tdd-btn { max-width: 1000px; outline: 0; /* takes care of blue outline */ display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; display: inline-flex; -webkit-box-align: center; -webkit-align-items: center; -ms-flex-align: center; align-items: center; -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; background: rgba(0, 0, 0, 0.75); border: 1px solid #63BCF8 !important; min-width: 400px; border: 0; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-sizing: border-box; padding: 16px 20px; color: #FFFFFF; font-size: 12px; font-weight: 300; letter-spacing: 1.2px; text-transform: uppercase; overflow: hidden; cursor: pointer; } .topic-dropdown .tdd-btn:hover { cursor: pointer; } .topic-dropdown .tdd-btn .tdd-list { position: absolute; top: 100%; left: 0px; background: rgba(0, 0, 0, 0.75); border: 1px solid #63BCF8 !important; width: 100%; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); text-align: left; opacity: 0; visibility: hidden; -webkit-transition: 0.3s ease; - transition: 0.3s ease; } + transition: 0.3s ease; + max-height: 600px; + overflow: auto; } .topic-dropdown .tdd-btn .tdd-list a { display: block; border-bottom: 1px solid rgba(0, 0, 0, 0.05); padding: 16px 0; color: inherit; text-decoration: none; background: none !important; } .topic-dropdown .tdd-btn .tdd-list:before { content: ''; position: absolute; top: -6px; left: 20px; width: 0; height: 0; box-shadow: 2px -2px 6px rgba(0, 0, 0, 0.05); border-top: 6px solid #63BCF8; border-right: 6px solid #63BCF8; border-bottom: 6px solid transparent; border-left: 6px solid transparent; -webkit-transform: rotate(-45deg); transform: rotate(-45deg); mix-blend-mode: multiple; } .topic-dropdown .tdd-btn .tdd-list li { z-index: 100; position: relative; padding: 0 20px; } .topic-dropdown .tdd-btn .tdd-list li.active { color: #5380F7; } .topic-dropdown .tdd-btn .tdd-list li:first-child { border-radius: 4px 4px 0 0; } .topic-dropdown .tdd-btn .tdd-list li:last-child { border-radius: 0 0 4px 4px; } .topic-dropdown .tdd-btn .tdd-list li:last-child a { border-bottom: 0; } .topic-dropdown .tdd-btn:focus .tdd-list, .topic-dropdown .tdd-btn:active .tdd-list { -webkit-transform: translate(0, 20px); transform: translate(0, 20px); opacity: 1; visibility: visible; } diff --git a/apps/steward-app/src/main/js/app/client/statistics/query-digest/topic-dropdown.tpl.html b/apps/steward-app/src/main/js/app/client/statistics/query-digest/topic-dropdown.tpl.html index a578603a8..cbdef6191 100644 --- a/apps/steward-app/src/main/js/app/client/statistics/query-digest/topic-dropdown.tpl.html +++ b/apps/steward-app/src/main/js/app/client/statistics/query-digest/topic-dropdown.tpl.html @@ -1,16 +1,16 @@ <div class="topic-dropdown"> <div class="title"> Filter By Topic: <div> <button class="tdd-btn"> - <span ng-init="dropdown.selected={title: 'All'}">{{dropdown.selected.title}}</span> <span class="dropdown-arrow"></span> + <span ng-init="dropdown.selected={title: 'All'}" style="overflow: hidden;">{{dropdown.selected.title}}</span> <span class="dropdown-arrow"></span> <ul class="tdd-list"> <li ng-class="{'active': dropdown.selected.id === undefined}" ng-click="dropdown.selected={title: 'All'}; topicSelected()"> <a href="" >All</a> </li> <li ng-repeat="topic in dropdown.topics" ng-class="{'active': dropdown.selected.id === topic.id}" ng-click="dropdown.selected={title: topic.name, id: topic.id}; topicSelected({topicId: topic.id})"> <a href="" >{{topic.name}}</a> </li> </ul> </button> </div> \ No newline at end of file diff --git a/apps/steward-app/src/main/js/app/sass/topic-dropdown.scss b/apps/steward-app/src/main/js/app/sass/topic-dropdown.scss index e8f6edab6..ce4be178b 100644 --- a/apps/steward-app/src/main/js/app/sass/topic-dropdown.scss +++ b/apps/steward-app/src/main/js/app/sass/topic-dropdown.scss @@ -1,126 +1,128 @@ .topic-dropdown { div.title{ text-transform: uppercase; font-weight: 500; font-size: 1.3em; color: #63BCF8; } z-index: 20000; margin: 4rem; left: 0%; color: rgba(0, 0, 0, 0.87); font-family: "Roboto", sans-serif; font-size: 14px; font-weight: 300; line-height: 1.5em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; position: fixed; .tdd-btn { &:hover{ cursor: pointer; } max-width: 1000px; outline: 0; /* takes care of blue outline */ display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; display: inline-flex; -webkit-box-align: center; -webkit-align-items: center; -ms-flex-align: center; align-items: center; -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; background: rgba(0,0,0, .75); border: 1px solid #63BCF8 !important; min-width: 400px; border: 0; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-sizing: border-box; padding: 16px 20px; color: #FFFFFF; font-size: 12px; font-weight: 300; letter-spacing: 1.2px; text-transform: uppercase; overflow: hidden; cursor: pointer; .tdd-list { position: absolute; top: 100%; left: 0px; background: rgba(0,0,0, .75); border: 1px solid #63BCF8 !important; width: 100%; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); text-align: left; opacity: 0; visibility: hidden; -webkit-transition: 0.3s ease; transition: 0.3s ease; + max-height: 600px; + overflow: auto; a { display: block; border-bottom: 1px solid rgba(0, 0, 0, 0.05); padding: 16px 0; color: inherit; text-decoration: none; background: none !important; }; &:before { content: ''; position: absolute; top: -6px; left: 20px; width: 0; height: 0; box-shadow: 2px -2px 6px rgba(0, 0, 0, 0.05); border-top: 6px solid #63BCF8;; border-right: 6px solid #63BCF8;; border-bottom: 6px solid transparent; border-left: 6px solid transparent; -webkit-transform: rotate(-45deg); transform: rotate(-45deg); mix-blend-mode: multiple; }; li { z-index: 100; position: relative; padding: 0 20px; &.active { color: #5380F7; } &:first-child { border-radius: 4px 4px 0 0; } &:last-child { border-radius: 0 0 4px 4px; } &:last-child a { border-bottom: 0; } }; }; &:focus .tdd-list, &:active .tdd-list { -webkit-transform: translate(0, 20px); transform: translate(0, 20px); opacity: 1; visibility: visible; }; }; } 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 5695c2a4d..2ddc70292 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,372 +1,375 @@ package net.shrine.steward import akka.actor.Actor import akka.event.Logging import net.shrine.authentication.UserAuthenticator import net.shrine.authorization.steward._ import net.shrine.i2b2.protocol.pm.User import net.shrine.serialization.NodeSeqSerializer import net.shrine.source.ConfigSource import net.shrine.steward.db._ import net.shrine.steward.pmauth.Authorizer import org.json4s.native.Serialization import shapeless.HNil import spray.http.{HttpRequest, HttpResponse, StatusCodes} import spray.httpx.Json4sSupport import spray.routing.directives.LogEntry import spray.routing._ import org.json4s.{DefaultFormats, DefaultJsonFormats, Formats, Serialization} 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 + new NodeSeqSerializer val userAuthenticator = UserAuthenticator(ConfigSource.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(CreateTopicsMode.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 { parameter('asJson.as[Boolean].?) { asJson => path("topic" / IntNumber) { topicId: TopicId => getQueryHistoryForUserByTopic(userIdOption, Some(topicId), asJson) } ~ getQueryHistoryForUserByTopic(userIdOption, None, asJson) } } - def getQueryHistoryForUserByTopic(userIdOption:Option[UserName],topicIdOption:Option[TopicId], asJson: Option[Boolean]) = get { - matchQueryParameters(userIdOption) { queryParameters:QueryParameters => - val queryHistory = StewardDatabase.db.selectQueryHistory(queryParameters, topicIdOption) - - if (asJson.getOrElse(false)) - complete(queryHistory.convertToJson) - else - complete(queryHistory) + def getQueryHistoryForUserByTopic(userIdOption: Option[UserName], + topicIdOption: Option[TopicId], + asJson: Option[Boolean]) = + get { + matchQueryParameters(userIdOption) { queryParameters: QueryParameters => + val queryHistory = StewardDatabase.db.selectQueryHistory(queryParameters, topicIdOption) + + if (asJson.getOrElse(false)) + complete(queryHistory.convertToJson) + else + complete(queryHistory) + } } - } 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 = ConfigSource.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/apps/steward-app/src/main/scala/net/shrine/steward/db/StewardDatabase.scala b/apps/steward-app/src/main/scala/net/shrine/steward/db/StewardDatabase.scala index 5e57550a0..a94f3c75c 100644 --- a/apps/steward-app/src/main/scala/net/shrine/steward/db/StewardDatabase.scala +++ b/apps/steward-app/src/main/scala/net/shrine/steward/db/StewardDatabase.scala @@ -1,738 +1,740 @@ package net.shrine.steward.db import java.sql.SQLException import java.util.concurrent.atomic.AtomicInteger import javax.sql.DataSource import com.typesafe.config.Config import net.shrine.authorization.steward.{Date, ExternalQueryId, InboundShrineQuery, InboundTopicRequest, OutboundShrineQuery, OutboundTopic, OutboundUser, QueriesPerUser, QueryContents, QueryHistory, ResearcherToAudit, ResearchersTopics, StewardQueryId, StewardsTopics, TopicId, TopicIdAndName, TopicState, TopicStateName, TopicsPerState, UserName, researcherRole, stewardRole} import net.shrine.i2b2.protocol.pm.User import net.shrine.log.Loggable import net.shrine.slick.{NeedsWarmUp, TestableDataSourceCreator} import net.shrine.source.ConfigSource import net.shrine.steward.CreateTopicsMode import slick.dbio.Effect.Read import slick.driver.JdbcProfile import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.{Duration, DurationInt} import scala.concurrent.{Await, Future, blocking} import scala.language.postfixOps import scala.util.Try /** * Database access code for the data steward service. * * I'm not letting Slick handle foreign key resolution for now. I want to keep that logic separate to handle dirty data with some grace. * * @author dwalend * @since 1.19 */ case class StewardDatabase(schemaDef:StewardSchema,dataSource: DataSource) extends Loggable { import schemaDef._ import jdbcProfile.api._ val database = Database.forDataSource(dataSource) def createTables() = schemaDef.createTables(database) def dropTables() = schemaDef.dropTables(database) def dbRun[R](action: DBIOAction[R, NoStream, Nothing]):R = { val future: Future[R] = database.run(action) blocking { Await.result(future, 10 seconds) } } def warmUp = { dbRun(allUserQuery.size.result) } def selectUsers:Seq[UserRecord] = { dbRun(allUserQuery.result) } // todo use whenever a shrine query is logged def upsertUser(user:User):Unit = { val userRecord = UserRecord(user) dbRun(allUserQuery.insertOrUpdate(userRecord)) } def createRequestForTopicAccess(user:User,topicRequest:InboundTopicRequest):TopicRecord = { val createInState = CreateTopicsMode.createTopicsInState val now = System.currentTimeMillis() val topicRecord = TopicRecord(Some(nextTopicId.getAndIncrement),topicRequest.name,topicRequest.description,user.username,now,createInState.topicState) val userTopicRecord = UserTopicRecord(user.username,topicRecord.id.get,TopicState.approved,user.username,now) dbRun(for{ _ <- allTopicQuery += topicRecord _ <- allUserTopicQuery += userTopicRecord } yield topicRecord) } def updateRequestForTopicAccess(user:User,topicId:TopicId,topicRequest:InboundTopicRequest):Try[OutboundTopic] = Try { dbRun(mostRecentTopicQuery.filter(_.id === topicId).result.headOption.flatMap{ option => val oldTopicRecord = option.getOrElse(throw TopicDoesNotExist(topicId = topicId)) if(user.username != oldTopicRecord.createdBy) throw DetectedAttemptByWrongUserToChangeTopic(topicId,user.username,oldTopicRecord.createdBy) if(oldTopicRecord.state == TopicState.approved) throw ApprovedTopicCanNotBeChanged(topicId) val updatedTopic = oldTopicRecord.copy(name = topicRequest.name, description = topicRequest.description, changedBy = user.username, changeDate = System.currentTimeMillis()) (allTopicQuery += updatedTopic).flatMap{_ => outboundUsersForNamesAction(Set(updatedTopic.createdBy,updatedTopic.changedBy)).map(updatedTopic.toOutboundTopic) } } ) } def selectTopicsForResearcher(parameters:QueryParameters):ResearchersTopics = { require(parameters.researcherIdOption.isDefined,"A researcher's parameters must supply a user id") val (count,topics,userNamesToOutboundUsers) = dbRun( for{ count <- topicCountQuery(parameters).length.result topics <- topicSelectQuery(parameters).result userNamesToOutboundUsers <- outboundUsersForNamesAction((topics.map(_.createdBy) ++ topics.map(_.changedBy)).to[Set]) } yield (count, topics,userNamesToOutboundUsers)) ResearchersTopics(parameters.researcherIdOption.get, count, parameters.skipOption.getOrElse(0), topics.map(_.toOutboundTopic(userNamesToOutboundUsers))) } //treat as private (currently used in test) def selectTopics(queryParameters: QueryParameters):Seq[TopicRecord] = { dbRun(topicSelectQuery(queryParameters).result) } def selectTopicsForSteward(queryParameters: QueryParameters):StewardsTopics = { val (count,topics,userNamesToOutboundUsers) = dbRun{ for{ count <- topicCountQuery(queryParameters).length.result topics <- topicSelectQuery(queryParameters).result userNamesToOutboundUsers <- outboundUsersForNamesAction((topics.map(_.createdBy) ++ topics.map(_.changedBy)).to[Set]) } yield (count,topics,userNamesToOutboundUsers) } StewardsTopics(count, queryParameters.skipOption.getOrElse(0), topics.map(_.toOutboundTopic(userNamesToOutboundUsers))) } private def topicSelectQuery(queryParameters: QueryParameters):Query[TopicTable, TopicTable#TableElementType, Seq] = { val countFilter = topicCountQuery(queryParameters) //todo is there some way to do something with a map from column names to columns that I don't have to update? I couldn't find one. // val orderByQuery = queryParameters.sortByOption.fold(countFilter)( // columnName => limitFilter.sortBy(x => queryParameters.sortOrder.orderForColumn(countFilter.columnForName(columnName)))) val orderByQuery = queryParameters.sortByOption.fold(countFilter)( columnName => countFilter.sortBy(x => queryParameters.sortOrder.orderForColumn(columnName match { case "id" => x.id case "name" => x.name case "description" => x.description case "createdBy" => x.createdBy case "createDate" => x.createDate case "state" => x.state case "changedBy" => x.changedBy case "changeDate" => x.changeDate }))) val skipFilter = queryParameters.skipOption.fold(orderByQuery)(skip => orderByQuery.drop(skip)) val limitFilter = queryParameters.limitOption.fold(skipFilter)(limit => skipFilter.take(limit)) limitFilter } private def topicCountQuery(queryParameters: QueryParameters):Query[TopicTable, TopicTable#TableElementType, Seq] = { val allTopics:Query[TopicTable, TopicTable#TableElementType, Seq] = mostRecentTopicQuery val researcherFilter = queryParameters.researcherIdOption.fold(allTopics)(userId => allTopics.filter(_.createdBy === userId)) val stateFilter = queryParameters.stateOption.fold(researcherFilter)(state => researcherFilter.filter(_.state === state.name)) val minDateFilter = queryParameters.minDate.fold(stateFilter)(minDate => stateFilter.filter(_.changeDate >= minDate)) val maxDateFilter = queryParameters.maxDate.fold(minDateFilter)(maxDate => minDateFilter.filter(_.changeDate <= maxDate)) maxDateFilter } def changeTopicState(topicId:TopicId,state:TopicState,userId:UserName):Option[TopicRecord] = { val noTopicRecord:Option[TopicRecord] = None val noOpDBIO:DBIOAction[Option[TopicRecord], NoStream, Effect.Write] = DBIO.successful(noTopicRecord) dbRun(mostRecentTopicQuery.filter(_.id === topicId).result.headOption.flatMap( _.fold(noOpDBIO){ originalTopic => val updatedTopic = originalTopic.copy(state = state, changedBy = userId, changeDate = System.currentTimeMillis()) (allTopicQuery += updatedTopic).map(_ => Option(updatedTopic)) } )) } def selectTopicCountsPerState(queryParameters: QueryParameters):TopicsPerState = { dbRun(for{ totalTopics <- topicCountQuery(queryParameters).length.result topicsPerStateName <- topicCountsPerState(queryParameters).result } yield TopicsPerState(totalTopics,topicsPerStateName)) } private def topicCountsPerState(queryParameters: QueryParameters): Query[(Rep[TopicStateName], Rep[Int]), (TopicStateName, Int), Seq] = { val groupedByState = topicCountQuery(queryParameters).groupBy(topicRecord => topicRecord.state) groupedByState.map{case (state,result) => (state,result.length)} } def logAndCheckQuery(userId:UserName,topicId:Option[TopicId],shrineQuery:InboundShrineQuery):(TopicState,Option[TopicIdAndName]) = { //todo upsertUser(user) when the info is available from the PM val noOpDBIOForState: DBIOAction[TopicState, NoStream, Effect.Read] = DBIO.successful { if (CreateTopicsMode.createTopicsInState == CreateTopicsMode.TopicsIgnoredJustLog) TopicState.approved else TopicState.createTopicsModeRequiresTopic } val noOpDBIOForTopicName: DBIOAction[Option[String], NoStream, Read] = DBIO.successful{None} val (state,topicName) = dbRun(for{ state <- topicId.fold(noOpDBIOForState)( someTopicId => mostRecentTopicQuery.filter(_.id === someTopicId).filter(_.createdBy === userId).map(_.state).result.headOption.map( _.fold(TopicState.unknownForUser)(state => TopicState.namesToStates(state))) ) topicName <- topicId.fold(noOpDBIOForTopicName)( someTopicId => mostRecentTopicQuery.filter(_.id === someTopicId).filter(_.createdBy === userId).map(_.name).result.headOption ) _ <- allQueryTable += ShrineQueryRecord(userId,topicId,shrineQuery,state) } yield (state,topicName)) val topicIdAndName:Option[TopicIdAndName] = (topicId,topicName) match { case (Some(id),Some(name)) => Option(TopicIdAndName(id.toString,name)) case (None,None) => None case (Some(id),None) => if(state == TopicState.unknownForUser) None else throw new IllegalStateException(s"How did you get here for $userId with $id and $state for $shrineQuery") case (None,Some(name)) => if(state == TopicState.unknownForUser) None else throw new IllegalStateException(s"How did you get here for $userId with no topic id but a topic name of $name and $state for $shrineQuery") } (state,topicIdAndName) } - def selectQueryHistory(queryParameters: QueryParameters,topicParameter:Option[TopicId]):QueryHistory = { + def selectQueryHistory(queryParameters: QueryParameters, topicParameter:Option[TopicId]): + QueryHistory = { - val (count,shrineQueries,topics,userNamesToOutboundUsers) = dbRun(for { - count <- shrineQueryCountQuery(queryParameters,topicParameter).length.result + val topicQuery = for { + count <- shrineQueryCountQuery(queryParameters, topicParameter).length.result shrineQueries <- shrineQuerySelectQuery(queryParameters, topicParameter).result topics <- mostRecentTopicQuery.filter(_.id.inSet(shrineQueries.map(_.topicId).to[Set].flatten)).result userNamesToOutboundUsers <- outboundUsersForNamesAction(shrineQueries.map(_.userId).to[Set] ++ (topics.map(_.createdBy) ++ topics.map(_.changedBy)).to[Set]) - } yield (count,shrineQueries,topics,userNamesToOutboundUsers)) + } yield (count, shrineQueries, topics, userNamesToOutboundUsers) + + val (count, shrineQueries, topics, userNamesToOutboundUsers) = dbRun(topicQuery) val topicIdsToTopics: Map[Option[TopicId], TopicRecord] = topics.map(x => (x.id, x)).toMap def toOutboundShrineQuery(queryRecord: ShrineQueryRecord): OutboundShrineQuery = { val topic = topicIdsToTopics.get(queryRecord.topicId) val outboundTopic: Option[OutboundTopic] = topic.map(_.toOutboundTopic(userNamesToOutboundUsers)) val outboundUserOption = userNamesToOutboundUsers.get(queryRecord.userId) //todo if a user is unknown and the system is in a mode that requires everyone to log into the data steward notify the data steward val outboundUser: OutboundUser = outboundUserOption.getOrElse(OutboundUser.createUnknownUser(queryRecord.userId)) queryRecord.createOutboundShrineQuery(outboundTopic, outboundUser) } - val result = QueryHistory(count,queryParameters.skipOption.getOrElse(0),shrineQueries.map(toOutboundShrineQuery)) - result + QueryHistory(count, queryParameters.skipOption.getOrElse(0), shrineQueries.map(toOutboundShrineQuery)) } private def outboundUsersForNamesAction(userNames:Set[UserName]):DBIOAction[Map[UserName, OutboundUser], NoStream, Read] = { allUserQuery.filter(_.userName.inSet(userNames)).result.map(_.map(x => (x.userName,x.asOutboundUser)).toMap) } private def shrineQuerySelectQuery(queryParameters: QueryParameters,topicParameter:Option[TopicId]):Query[QueryTable, QueryTable#TableElementType, Seq] = { val countQuery = shrineQueryCountQuery(queryParameters,topicParameter) //todo is there some way to do something with a map from column names to columns that I don't have to update? I couldn't find one. // val orderByQuery = queryParameters.sortByOption.fold(limitFilter)( // columnName => limitFilter.sortBy(x => queryParameters.sortOrder.orderForColumn(allQueryTable.columnForName(columnName)))) val orderByQuery = queryParameters.sortByOption.fold(countQuery) { case "topicName" => val joined = countQuery.join(mostRecentTopicQuery).on(_.topicId === _.id) joined.sortBy(x => queryParameters.sortOrder.orderForColumn(x._2.name)).map(x => x._1) case columnName => countQuery.sortBy(x => queryParameters.sortOrder.orderForColumn(columnName match { case "stewardId" => x.stewardId case "externalId" => x.externalId case "researcherId" => x.researcherId case "name" => x.name case "topic" => x.topicId case "queryContents" => x.queryContents case "stewardResponse" => x.stewardResponse case "date" => x.date })) } val skipFilter = queryParameters.skipOption.fold(orderByQuery)(skip => orderByQuery.drop(skip)) val limitFilter = queryParameters.limitOption.fold(skipFilter)(limit => skipFilter.take(limit)) limitFilter } private def shrineQueryCountQuery(queryParameters: QueryParameters,topicParameter:Option[TopicId]):Query[QueryTable, QueryTable#TableElementType, Seq] = { val allShrineQueries:Query[QueryTable, QueryTable#TableElementType, Seq] = allQueryTable val topicFilter:Query[QueryTable, QueryTable#TableElementType, Seq] = topicParameter.fold(allShrineQueries)(topicId => allShrineQueries.filter(_.topicId === topicId)) val researcherFilter:Query[QueryTable, QueryTable#TableElementType, Seq] = queryParameters.researcherIdOption.fold(topicFilter)(researcherId => topicFilter.filter(_.researcherId === researcherId)) //todo this is probably a binary Approved/Not approved val stateFilter:Query[QueryTable, QueryTable#TableElementType, Seq] = queryParameters.stateOption.fold(researcherFilter)(stewardResponse => researcherFilter.filter(_.stewardResponse === stewardResponse.name)) val minDateFilter = queryParameters.minDate.fold(stateFilter)(minDate => stateFilter.filter(_.date >= minDate)) val maxDateFilter = queryParameters.maxDate.fold(minDateFilter)(maxDate => minDateFilter.filter(_.date <= maxDate)) maxDateFilter } def selectShrineQueryCountsPerUser(queryParameters: QueryParameters):QueriesPerUser = { val (totalQueries,queriesPerUser,userNamesToOutboundUsers) = dbRun(for { totalQueries <- shrineQueryCountQuery(queryParameters,None).length.result queriesPerUser <- shrineQueryCountsPerResearcher(queryParameters).result userNamesToOutboundUsers <- outboundUsersForNamesAction(queriesPerUser.map(x => x._1).to[Set]) } yield (totalQueries,queriesPerUser,userNamesToOutboundUsers)) val queriesPerOutboundUser:Seq[(OutboundUser,Int)] = queriesPerUser.map(x => (userNamesToOutboundUsers(x._1),x._2)) QueriesPerUser(totalQueries,queriesPerOutboundUser) } private def shrineQueryCountsPerResearcher(queryParameters: QueryParameters): Query[(Rep[UserName],Rep[Int]),(UserName,Int),Seq] = { val filteredShrineQueries:Query[QueryTable, QueryTable#TableElementType, Seq] = shrineQueryCountQuery(queryParameters,None) val groupedByResearcher = filteredShrineQueries.groupBy(shrineQuery => shrineQuery.researcherId) groupedByResearcher.map{case (researcher,result) => (researcher,result.length)} } lazy val nextTopicId:AtomicInteger = new AtomicInteger({ dbRun(allTopicQuery.map(_.id).max.result).getOrElse(0) + 1 }) def selectAllAuditRequests: Seq[UserAuditRecord] = { dbRun(allUserAudits.result) } def selectMostRecentAuditRequests: Seq[UserAuditRecord] = { dbRun(mostRecentUserAudits.result) } def selectResearchersToAudit(maxQueryCountBetweenAudits:Int,minTimeBetweenAudits:Duration,now:Date):Seq[ResearcherToAudit] = { //todo one round with the db instead of O(researchers) //for each researcher //horizon = if the researcher has had an audit // date of last audit // else if no audit yet // date of first query val researchersToHorizons: Map[UserName, Date] = dbRun(for{ dateOfFirstQuery: Seq[(UserName, Date)] <- leastRecentUserQuery.map(record => record.researcherId -> record.date).result mostRecentAudit: Seq[(UserName, Date)] <- mostRecentUserAudits.map(record => record.researcher -> record.changeDate).result } yield { dateOfFirstQuery.toMap ++ mostRecentAudit.toMap }) val researchersToHorizonsAndCounts = researchersToHorizons.map{ researcherDate => val queryParameters = QueryParameters(researcherIdOption = Some(researcherDate._1), minDate = Some(researcherDate._2)) val count:Int = dbRun(shrineQueryCountQuery(queryParameters,None).length.result) (researcherDate._1,(researcherDate._2,count)) } //audit if oldest query within the horizon is >= minTimeBetweenAudits in the past and the researcher has run at least one query since. val oldestAllowed = System.currentTimeMillis() - minTimeBetweenAudits.toMillis val timeBasedAudit = researchersToHorizonsAndCounts.filter(x => x._2._2 > 0 && x._2._1 <= oldestAllowed) //audit if the researcher has run >= maxQueryCountBetweenAudits queries since horizon? val queryBasedAudit = researchersToHorizonsAndCounts.filter(x => x._2._2 >= maxQueryCountBetweenAudits) val toAudit = timeBasedAudit ++ queryBasedAudit val namesToOutboundUsers: Map[UserName, OutboundUser] = dbRun(outboundUsersForNamesAction(toAudit.keySet)) toAudit.map(x => ResearcherToAudit(namesToOutboundUsers(x._1),x._2._2,x._2._1,now)).to[Seq] } def logAuditRequests(auditRequests:Seq[ResearcherToAudit],now:Date) { dbRun{ allUserAudits ++= auditRequests.map(x => UserAuditRecord(researcher = x.researcher.userName, queryCount = x.count, changeDate = now )) } } } /** * Separate class to support schema generation without actually connecting to the database. * * @param jdbcProfile Database profile to use for the schema */ case class StewardSchema(jdbcProfile: JdbcProfile) extends Loggable { import jdbcProfile.api._ def ddlForAllTables = { allUserQuery.schema ++ allTopicQuery.schema ++ allQueryTable.schema ++ allUserTopicQuery.schema ++ allUserAudits.schema } //to get the schema, use the REPL //println(StewardSchema.schema.ddlForAllTables.createStatements.mkString(";\n")) def createTables(database:Database) = { try { val future = database.run(ddlForAllTables.create) Await.result(future,10 seconds) } catch { //I'd prefer to check and create schema only if absent. No way to do that with Oracle. case x:SQLException => info("Caught exception while creating tables. Recover by assuming the tables already exist.",x) } } def dropTables(database:Database) = { val future = database.run(ddlForAllTables.drop) //Really wait forever for the cleanup Await.result(future,Duration.Inf) } class UserTable(tag:Tag) extends Table[UserRecord](tag,"users") { def userName = column[UserName]("userName",O.PrimaryKey) def fullName = column[String]("fullName") def isSteward = column[Boolean]("isSteward") def * = (userName,fullName,isSteward) <> (UserRecord.tupled,UserRecord.unapply) } class TopicTable(tag:Tag) extends Table[TopicRecord](tag,"topics") { def id = column[TopicId]("id") def name = column[String]("name") def description = column[String]("description") def createdBy = column[UserName]("createdBy") def createDate = column[Date]("createDate") def state = column[TopicStateName]("state") def changedBy = column[UserName]("changedBy") def changeDate = column[Date]("changeDate") def idIndex = index("idIndex",id,unique = false) def topicNameIndex = index("topicNameIndex",name,unique = false) def createdByIndex = index("createdByIndex",createdBy,unique = false) def createDateIndex = index("createDateIndex",createDate,unique = false) def stateIndex = index("stateIndex",state,unique = false) def changedByIndex = index("changedByIndex",changedBy,unique = false) def changeDateIndex = index("changeDateIndex",changeDate,unique = false) def * = (id.?, name, description, createdBy, createDate, state, changedBy, changeDate) <> (fromRow, toRow) //(TopicRecord.tupled,TopicRecord.unapply) def fromRow = (fromParams _).tupled def fromParams(id:Option[TopicId] = None, name:String, description:String, createdBy:UserName, createDate:Date, stateName:String, changedBy:UserName, changeDate:Date): TopicRecord = { TopicRecord(id, name, description, createdBy, createDate, TopicState.namesToStates(stateName), changedBy, changeDate) } def toRow(topicRecord: TopicRecord) = Some((topicRecord.id, topicRecord.name, topicRecord.description, topicRecord.createdBy, topicRecord.createDate, topicRecord.state.name, topicRecord.changedBy, topicRecord.changeDate )) } class UserTopicTable(tag:Tag) extends Table[UserTopicRecord](tag,"userTopic") { def researcher = column[UserName]("researcher") def topicId = column[TopicId]("topicId") def state = column[TopicStateName]("state") def changedBy = column[UserName]("changedBy") def changeDate = column[Date]("changeDate") def researcherTopicIdIndex = index("researcherTopicIdIndex",(researcher,topicId),unique = true) def * = (researcher, topicId, state, changedBy, changeDate) <> (fromRow, toRow) def fromRow = (fromParams _).tupled def fromParams(researcher:UserName, topicId:TopicId, stateName:String, changedBy:UserName, changeDate:Date): UserTopicRecord = { UserTopicRecord(researcher,topicId,TopicState.namesToStates(stateName), changedBy, changeDate) } def toRow(userTopicRecord: UserTopicRecord):Option[(UserName,TopicId,String,UserName,Date)] = Some((userTopicRecord.researcher, userTopicRecord.topicId, userTopicRecord.state.name, userTopicRecord.changedBy, userTopicRecord.changeDate )) } class UserAuditTable(tag:Tag) extends Table[UserAuditRecord](tag,"userAudit") { def researcher = column[UserName]("researcher") def queryCount = column[Int]("queryCount") def changeDate = column[Date]("changeDate") def * = (researcher, queryCount, changeDate) <> (fromRow, toRow) def fromRow = (fromParams _).tupled def fromParams(researcher:UserName, queryCount:Int, changeDate:Date): UserAuditRecord = { UserAuditRecord(researcher,queryCount, changeDate) } def toRow(record: UserAuditRecord):Option[(UserName,Int,Date)] = Some((record.researcher, record.queryCount, record.changeDate )) } class QueryTable(tag:Tag) extends Table[ShrineQueryRecord](tag,"queries") { def stewardId = column[StewardQueryId]("stewardId",O.PrimaryKey,O.AutoInc) def externalId = column[ExternalQueryId]("id") def name = column[String]("name") def researcherId = column[UserName]("researcher") def topicId = column[Option[TopicId]]("topic") def queryContents = column[QueryContents]("queryContents") def stewardResponse = column[String]("stewardResponse") def date = column[Date]("date") def externalIdIndex = index("externalIdIndex",externalId,unique = false) def queryNameIndex = index("queryNameIndex",name,unique = false) def researcherIdIndex = index("researcherIdIndex",stewardId,unique = false) def topicIdIndex = index("topicIdIndex",topicId,unique = false) def stewardResponseIndex = index("stewardResponseIndex",stewardResponse,unique = false) def dateIndex = index("dateIndex",date,unique = false) def * = (stewardId.?,externalId,name,researcherId,topicId,queryContents,stewardResponse,date) <> (fromRow,toRow) def fromRow = (fromParams _).tupled def fromParams(stewardId:Option[StewardQueryId], externalId:ExternalQueryId, name:String, userId:UserName, topicId:Option[TopicId], queryContents: QueryContents, stewardResponse:String, date:Date): ShrineQueryRecord = { ShrineQueryRecord(stewardId,externalId, name, userId, topicId, queryContents,TopicState.namesToStates(stewardResponse),date) } def toRow(queryRecord: ShrineQueryRecord):Option[( Option[StewardQueryId], ExternalQueryId, String, UserName, Option[TopicId], QueryContents, String, Date )] = Some((queryRecord.stewardId, queryRecord.externalId, queryRecord.name, queryRecord.userId, queryRecord.topicId, queryRecord.queryContents, queryRecord.stewardResponse.name, queryRecord.date) ) } val allUserQuery = TableQuery[UserTable] val allTopicQuery = TableQuery[TopicTable] val allQueryTable = TableQuery[QueryTable] val allUserTopicQuery = TableQuery[UserTopicTable] val allUserAudits = TableQuery[UserAuditTable] val mostRecentTopicQuery: Query[TopicTable, TopicRecord, Seq] = for( topic <- allTopicQuery if !allTopicQuery.filter(_.id === topic.id).filter(_.changeDate > topic.changeDate).exists ) yield topic val mostRecentUserAudits: Query[UserAuditTable, UserAuditRecord, Seq] = for( record <- allUserAudits if !allUserAudits.filter(_.researcher === record.researcher).filter(_.changeDate > record.changeDate).exists ) yield record val leastRecentUserQuery: Query[QueryTable, ShrineQueryRecord, Seq] = for( record <- allQueryTable if !allQueryTable.filter(_.researcherId === record.researcherId).filter(_.date < record.date).exists ) yield record } object StewardSchema { val allConfig:Config = ConfigSource.config val config:Config = allConfig.getConfig("shrine.steward.database") val slickProfile:JdbcProfile = ConfigSource.getObject("slickProfileClassName", config) val schema = StewardSchema(slickProfile) } object StewardDatabase extends NeedsWarmUp { val dataSource:DataSource = TestableDataSourceCreator.dataSource(StewardSchema.config) val db = StewardDatabase(StewardSchema.schema,dataSource) val createTablesOnStart = StewardSchema.config.getBoolean("createTablesOnStart") if(createTablesOnStart) StewardDatabase.db.createTables() override def warmUp() = StewardDatabase.db.warmUp } //API help sealed case class SortOrder(name:String){ import slick.lifted.ColumnOrdered def orderForColumn[T](column:ColumnOrdered[T]):ColumnOrdered[T] = { if(this == SortOrder.ascending) column.asc else column.desc } } object SortOrder { val ascending = SortOrder("ascending") val descending = SortOrder("descending") val sortOrders = Seq(ascending,descending) val namesToSortOrders = sortOrders.map(x => (x.name,x)).toMap def sortOrderForStringOption(option:Option[String]) = option.fold(ascending)(namesToSortOrders(_)) } case class QueryParameters(researcherIdOption:Option[UserName] = None, stateOption:Option[TopicState] = None, skipOption:Option[Int] = None, limitOption:Option[Int] = None, sortByOption:Option[String] = None, sortOrder:SortOrder = SortOrder.ascending, minDate:Option[Date] = None, maxDate:Option[Date] = None ) //DAO case classes, exposed for testing only case class ShrineQueryRecord(stewardId: Option[StewardQueryId], externalId:ExternalQueryId, name:String, userId:UserName, topicId:Option[TopicId], queryContents: QueryContents, stewardResponse:TopicState, date:Date) { def createOutboundShrineQuery(outboundTopic:Option[OutboundTopic],outboundUser:OutboundUser): OutboundShrineQuery = { OutboundShrineQuery(stewardId.get,externalId,name,outboundUser,outboundTopic,queryContents,stewardResponse.name,date) } } object ShrineQueryRecord extends ((Option[StewardQueryId],ExternalQueryId,String,UserName,Option[TopicId],QueryContents,TopicState,Date) => ShrineQueryRecord) { def apply(userId:UserName,topicId:Option[TopicId],shrineQuery: InboundShrineQuery,stewardResponse:TopicState): ShrineQueryRecord = { ShrineQueryRecord( None, shrineQuery.externalId, shrineQuery.name, userId, topicId, shrineQuery.queryContents, stewardResponse, System.currentTimeMillis()) } } case class UserRecord(userName:UserName,fullName:String,isSteward:Boolean) { lazy val asOutboundUser:OutboundUser = OutboundUser(userName,fullName,if(isSteward) Set(stewardRole,researcherRole) else Set(researcherRole)) } object UserRecord extends ((UserName,String,Boolean) => UserRecord) { def apply(user:User):UserRecord = UserRecord(user.username,user.fullName,user.params.toList.contains((stewardRole,"true"))) } case class TopicRecord(id:Option[TopicId] = None, name:String, description:String, createdBy:UserName, createDate:Date, state:TopicState, changedBy:UserName, changeDate:Date) { def toOutboundTopic(userNamesToOutboundUsers: Map[UserName, OutboundUser]): OutboundTopic = { OutboundTopic(id.get, name, description, userNamesToOutboundUsers(createdBy), createDate, state.name, userNamesToOutboundUsers(changedBy), changeDate) } } object TopicRecord { def apply(id:Option[TopicId], name:String, description:String, createdBy:UserName, createDate:Date, state:TopicState ):TopicRecord = TopicRecord(id, name, description, createdBy, createDate, state, createdBy, createDate) } case class UserTopicRecord(researcher:UserName, topicId:TopicId, state:TopicState, changedBy:UserName, changeDate:Date) case class UserAuditRecord(researcher:UserName, queryCount:Int, changeDate:Date) { def sameExceptForTimes(userAuditRecord: UserAuditRecord):Boolean = { (researcher == userAuditRecord.researcher) && (queryCount == userAuditRecord.queryCount) } } case class TopicDoesNotExist(topicId:TopicId) extends IllegalArgumentException(s"No topic for id $topicId") case class ApprovedTopicCanNotBeChanged(topicId:TopicId) extends IllegalStateException(s"Topic $topicId has been ${TopicState.approved}") case class DetectedAttemptByWrongUserToChangeTopic(topicId:TopicId,userId:UserName,ownerId:UserName) extends IllegalArgumentException(s"$userId does not own $topicId; $ownerId owns it.") \ No newline at end of file diff --git a/commons/protocol/src/main/scala/net/shrine/i2b2/protocol/pm/HiveConfig.scala b/commons/protocol/src/main/scala/net/shrine/i2b2/protocol/pm/HiveConfig.scala index ed5ef77eb..e4f59f054 100644 --- a/commons/protocol/src/main/scala/net/shrine/i2b2/protocol/pm/HiveConfig.scala +++ b/commons/protocol/src/main/scala/net/shrine/i2b2/protocol/pm/HiveConfig.scala @@ -1,43 +1,44 @@ package net.shrine.i2b2.protocol.pm import net.shrine.util.XmlUtil import net.shrine.serialization.{ I2b2Unmarshaller, XmlMarshaller } import xml.NodeSeq /** * @author Bill Simons * @date 3/5/12 * @link http://cbmi.med.harvard.edu * @link http://chip.org * <p/> * NOTICE: This software comes with NO guarantees whatsoever and is * licensed as Lgpl Open Source * @link http://www.gnu.org/licenses/lgpl.html */ +//TODO: Do we use this anymore? If it's only in happy, it can be removed final case class HiveConfig(val crcUrl: String, val ontologyUrl: String) extends XmlMarshaller { override def toXml = XmlUtil.stripWhitespace { <hiveConfig> <crcUrl>{ crcUrl }</crcUrl> <ontUrl>{ ontologyUrl }</ontUrl> </hiveConfig> } } object HiveConfig extends I2b2Unmarshaller[HiveConfig] { override def fromI2b2(nodeSeq: NodeSeq) = { val cellDataSeq = nodeSeq \ "message_body" \ "configure" \ "cell_datas" \ "cell_data" def hasId(id: String): NodeSeq => Boolean = { xml => (xml \\ "@id").text == id } def findUrlById(id: String): String = (cellDataSeq.find(hasId(id)).toSeq \ "url").text //TODO review for error handling - if given ID isn't found, urls will be empty strings. Should we fail loudly instead? val crcUrl = findUrlById("CRC") val ontUrl = findUrlById("ONT") HiveConfig(crcUrl, ontUrl) } } \ No newline at end of file diff --git a/commons/util/src/test/resources/shrine.conf b/commons/util/src/test/resources/shrine.conf index a6c6913d3..6ab34572d 100644 --- a/commons/util/src/test/resources/shrine.conf +++ b/commons/util/src/test/resources/shrine.conf @@ -1,17 +1,20 @@ shrine{ + problem { + problemHandler = "net.shrine.problem.DatabaseProblemHandler$" + } dashboard { database { dataSourceFrom = "testDataSource" slickProfileClassName = "slick.driver.H2Driver$" createTablesOnStart = true // For testing without JNDI testDataSource { //typical test settings for unit tests driverClassName = "org.h2.Driver" url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" } } } } \ No newline at end of file