aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md17
-rw-r--r--src/main/scala/xyz/driver/core/app.scala16
-rw-r--r--src/main/scala/xyz/driver/core/auth.scala133
-rw-r--r--src/main/scala/xyz/driver/core/rest.scala131
-rw-r--r--src/test/scala/xyz/driver/core/AuthTest.scala36
5 files changed, 136 insertions, 197 deletions
diff --git a/README.md b/README.md
index 2bc5820..4c9e95a 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,8 @@ Core library is used to provide ways to implement practices established in [Driv
* `config` Contains method `loadDefaultConfig` with default way of providing config to the application,
* `messages` Localization messages supporting different locales and methods to read from config,
* `database` Method for database initialization from config, `Id` and `Name` mapping and schema lifecycle,
- * `rest` Wrapper over call to external REST API, does logging and stats call,
+ * `rest` Wrapper over call to external REST API, authorization, context headers, does logging and stats call,
* `json` Json formats for `Id`, `Name`, `Time`, `Revision` and converters for enums and value classes,
- * `auth` Permissions and roles, auth token definitions, `authorize` directive to verify auth token,
* `file` Stub for file storage web-service and implementations for S3 and FS `FileStorage`,
* `app` Base class for Driver service, which initializes swagger, app modules and its routes.
* `generators` Set of functions to prototype APIs. Combine with `faker` package,
@@ -100,16 +99,8 @@ For more examples check [project tests](https://github.com/drivergroup/driver-co
* Logging:
- * Custom akka-http directive to log and record stats for all incoming requests, use akka-http built-in `logRequestResult("log")` as example and probably extend that,
+ * Custom akka-http directive to extract tracking/correlation id from the requests and put to MDC (diagnostic context). Also custom MDC needs to be implemented (or found on github/stackoverflow) to pass context among different threads in asynchronous environment when next computations may occur in other thread,
- * Custom akka-http directive to extract tracking/correlation id [linkerd](https://linkerd.io) adds to the requests (perhaps, to header, [perhaps with zipkin](https://linkerd.io/doc/0.7.1/linkerd/tracer/)) and puts to MDC (diagnostic context). Also custom MDC needs to be implemented (or found on github/stackoverflow) to pass context among different threads in asynchronous environment when next compuatations may occur in other thread,
+ * Custom log appender with its own format, using diagnostic context with request tracking/correlation id from request,
- * Custom log appender with its own format, using diagnostic context with request tracking/correlation id from linkerd,
-
- * Custom akka-http `ExceptionHandler` for standard reactions to errors. Return tracking/correlation id, either from request, or if it wasn't there, generated from scratch, to corresond log records with erroneous response,
-
- * Custom akka-http directive to extract authentication credentials from request, and in case no valid token (macaroon) is presented, or it has no permissions (caveats) for this API method, to redirect user to [authentication service](https://docs.google.com/a/drivergrp.com/document/d/19a0BkZIlvYTpc9BKsf3oiAyHDmBQuKwCoTEzk9O9bUo/edit?usp=sharing_eid&ts=578824c7) (or respond with http-error code saying to present user authentication form). Macaroons documentation: [paper](http://static.googleusercontent.com/media/research.google.com/en//pubs/archive/41892.pdf), [hmac](https://en.wikipedia.org/wiki/Hash-based_message_authentication_code), [jmacaroons](https://github.com/nitram509/jmacaroons) (JVM implementation),
-
- * Wrap routes that modules provide to the `com.drivergrp.core.app.DriverApp` class with authentication, reading linkerd correlation id, logging, stats, and custom error handling,
-
- * Store possible common typesafe config parts in core library.
+ * Try to store possible common typesafe config parts in core library.
diff --git a/src/main/scala/xyz/driver/core/app.scala b/src/main/scala/xyz/driver/core/app.scala
index 9bc34f6..54d08d4 100644
--- a/src/main/scala/xyz/driver/core/app.scala
+++ b/src/main/scala/xyz/driver/core/app.scala
@@ -16,7 +16,8 @@ import org.slf4j.LoggerFactory
import spray.json.DefaultJsonProtocol
import xyz.driver.core
import xyz.driver.core.logging.{Logger, TypesafeScalaLogger}
-import xyz.driver.core.rest.{ContextHeaders, Swagger}
+import xyz.driver.core.rest.ServiceRequestContext.ContextHeaders
+import xyz.driver.core.rest.Swagger
import xyz.driver.core.stats.SystemStats
import xyz.driver.core.time.Time
import xyz.driver.core.time.provider.{SystemTimeProvider, TimeProvider}
@@ -68,7 +69,7 @@ object app {
val _ = Future {
http.bindAndHandle(route2HandlerFlow(handleExceptions(ExceptionHandler(exceptionHandler)) { ctx =>
- val trackingId = rest.extractTrackingId(ctx)
+ val trackingId = rest.ServiceRequestContext.extractTrackingId(ctx)
log.audit(s"Received request ${ctx.request} with tracking id $trackingId")
val contextWithTrackingId =
@@ -81,24 +82,29 @@ object app {
}
}
+ /**
+ * Override me for custom exception handling
+ *
+ * @return Exception handling route for exception type
+ */
protected def exceptionHandler = PartialFunction[Throwable, Route] {
case is: IllegalStateException =>
ctx =>
- val trackingId = rest.extractTrackingId(ctx)
+ val trackingId = rest.ServiceRequestContext.extractTrackingId(ctx)
log.debug(s"Request is not allowed to ${ctx.request.uri} ($trackingId)", is)
complete(HttpResponse(BadRequest, entity = is.getMessage))(ctx)
case cm: ConcurrentModificationException =>
ctx =>
- val trackingId = rest.extractTrackingId(ctx)
+ val trackingId = rest.ServiceRequestContext.extractTrackingId(ctx)
log.audit(s"Concurrent modification of the resource ${ctx.request.uri} ($trackingId)", cm)
complete(
HttpResponse(Conflict, entity = "Resource was changed concurrently, try requesting a newer version"))(ctx)
case t: Throwable =>
ctx =>
- val trackingId = rest.extractTrackingId(ctx)
+ val trackingId = rest.ServiceRequestContext.extractTrackingId(ctx)
log.error(s"Request to ${ctx.request.uri} could not be handled normally ($trackingId)", t)
complete(HttpResponse(InternalServerError, entity = t.getMessage))(ctx)
}
diff --git a/src/main/scala/xyz/driver/core/auth.scala b/src/main/scala/xyz/driver/core/auth.scala
deleted file mode 100644
index cede122..0000000
--- a/src/main/scala/xyz/driver/core/auth.scala
+++ /dev/null
@@ -1,133 +0,0 @@
-package xyz.driver.core
-
-import akka.http.scaladsl.model.headers.HttpChallenges
-import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected
-import xyz.driver.core.rest.ServiceRequestContext
-
-import scala.concurrent.Future
-import scala.util.{Failure, Success}
-import scalaz.OptionT
-
-object auth {
-
- sealed trait Permission
- case object CanSeeUser extends Permission
- case object CanSeeAssay extends Permission
- case object CanSeeReport extends Permission
- case object CanCreateReport extends Permission
- case object CanEditReport extends Permission
- case object CanReviewReport extends Permission
- case object CanEditReviewingReport extends Permission
- case object CanSignOutReport extends Permission
- case object CanAmendReport extends Permission
- case object CanShareReportWithPatient extends Permission
- case object CanAssignRoles extends Permission
-
- trait Role {
- val id: Id[Role]
- val name: Name[Role]
- val permissions: Set[Permission]
-
- def hasPermission(permission: Permission): Boolean = permissions.contains(permission)
- }
-
- object Role {
- def fromString(roleString: String): Option[Role] = roleString match {
- case "Observer" => Some(ObserverRole)
- case "Patient" => Some(PatientRole)
- case "Curator" => Some(CuratorRole)
- case "Pathologist" => Some(PathologistRole)
- case "Administrator" => Some(AdministratorRole)
- case "Physician" => Some(PhysicianRole)
- case "Relative" => Some(RelativeRole)
- case _ => None
- }
- }
-
- case object ObserverRole extends Role {
- val id = Id("1")
- val name = Name("observer")
- val permissions = Set[Permission](CanSeeUser, CanSeeAssay, CanSeeReport)
- }
-
- case object PatientRole extends Role {
- val id = Id("2")
- val name = Name("patient")
- val permissions = Set.empty[Permission]
- }
-
- case object CuratorRole extends Role {
- val id = Id("3")
- val name = Name("curator")
- val permissions = ObserverRole.permissions ++ Set[Permission](CanEditReport, CanReviewReport)
- }
-
- case object PathologistRole extends Role {
- val id = Id("4")
- val name = Name("pathologist")
- val permissions = ObserverRole.permissions ++
- Set[Permission](CanEditReport, CanSignOutReport, CanAmendReport, CanEditReviewingReport)
- }
-
- case object AdministratorRole extends Role {
- val id = Id("5")
- val name = Name("administrator")
- val permissions = CuratorRole.permissions ++
- Set[Permission](CanCreateReport, CanShareReportWithPatient, CanAssignRoles)
- }
-
- case object PhysicianRole extends Role {
- val id = Id("6")
- val name = Name("physician")
- val permissions = Set[Permission]()
- }
-
- case object RelativeRole extends Role {
- val id = Id("7")
- val name = Name("relative")
- val permissions = Set[Permission]()
- }
-
- trait User {
- def id: Id[User]
- def roles: Set[Role]
- def permissions: Set[Permission] = roles.flatMap(_.permissions)
- }
-
- final case class AuthToken(value: String)
-
- final case class PasswordHash(value: String)
-
- object AuthService {
- val AuthenticationTokenHeader = rest.ContextHeaders.AuthenticationTokenHeader
- val SetAuthenticationTokenHeader = "set-authorization"
- }
-
- trait AuthService[U <: User] {
-
- import akka.http.scaladsl.server._
- import Directives._
-
- protected def authStatus(context: ServiceRequestContext): OptionT[Future, U]
-
- def authorize(permissions: Permission*): Directive1[U] = {
- rest.serviceContext flatMap { ctx =>
- onComplete(authStatus(ctx).run).flatMap {
- case Success(Some(user)) =>
- if (permissions.forall(user.permissions.contains)) provide(user)
- else {
- val challenge =
- HttpChallenges.basic(s"User does not have the required permissions: ${permissions.mkString(", ")}")
- reject(AuthenticationFailedRejection(CredentialsRejected, challenge))
- }
-
- case Success(None) =>
- reject(ValidationRejection(s"Wasn't able to find authenticated user for the token provided"))
-
- case Failure(t) =>
- reject(ValidationRejection(s"Wasn't able to verify token for authenticated user", Some(t)))
- }
- }
- }
- }
-}
diff --git a/src/main/scala/xyz/driver/core/rest.scala b/src/main/scala/xyz/driver/core/rest.scala
index 554fe75..437df3c 100644
--- a/src/main/scala/xyz/driver/core/rest.scala
+++ b/src/main/scala/xyz/driver/core/rest.scala
@@ -3,14 +3,14 @@ package xyz.driver.core
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.model._
-import akka.http.scaladsl.model.headers.RawHeader
+import akka.http.scaladsl.model.headers.{HttpChallenges, RawHeader}
+import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected
import akka.http.scaladsl.unmarshalling.Unmarshal
import akka.stream.ActorMaterializer
import com.github.swagger.akka.model._
import com.github.swagger.akka.{HasActorSystem, SwaggerHttpService}
import com.typesafe.config.Config
import io.swagger.models.Scheme
-import xyz.driver.core.auth._
import xyz.driver.core.logging.Logger
import xyz.driver.core.stats.Stats
import xyz.driver.core.time.TimeRange
@@ -18,50 +18,119 @@ import xyz.driver.core.time.provider.TimeProvider
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
+import scalaz.OptionT
import scalaz.Scalaz.{Id => _, _}
object rest {
- object ContextHeaders {
- val AuthenticationTokenHeader = "WWW-Authenticate"
- val TrackingIdHeader = "X-Trace"
-
- object LinkerD {
- // https://linkerd.io/doc/0.7.4/linkerd/protocol-http/
- def isLinkerD(headerName: String) = headerName.startsWith("l5d-")
- }
- }
-
final case class ServiceRequestContext(
trackingId: String = generators.nextUuid().toString,
contextHeaders: Map[String, String] = Map.empty[String, String]) {
- def authToken: Option[AuthToken] = contextHeaders.get(AuthService.AuthenticationTokenHeader).map(AuthToken.apply)
+ def authToken: Option[Auth.AuthToken] =
+ contextHeaders.get(Auth.AuthProvider.AuthenticationTokenHeader).map(Auth.AuthToken.apply)
}
- import akka.http.scaladsl.server._
- import Directives._
+ object ServiceRequestContext {
- def serviceContext: Directive1[ServiceRequestContext] = extract(ctx => extractServiceContext(ctx))
+ object ContextHeaders {
+ val AuthenticationTokenHeader = "WWW-Authenticate"
+ val TrackingIdHeader = "X-Trace"
- def extractServiceContext(ctx: RequestContext): ServiceRequestContext =
- ServiceRequestContext(extractTrackingId(ctx), extractContextHeaders(ctx))
+ object LinkerD {
+ // https://linkerd.io/doc/0.7.4/linkerd/protocol-http/
+ def isLinkerD(headerName: String) = headerName.startsWith("l5d-")
+ }
+ }
- def extractTrackingId(ctx: RequestContext): String = {
- ctx.request.headers
- .find(_.name == ContextHeaders.TrackingIdHeader)
- .fold(java.util.UUID.randomUUID.toString)(_.value())
- }
+ import akka.http.scaladsl.server._
+ import Directives._
- def extractContextHeaders(ctx: RequestContext): Map[String, String] = {
- ctx.request.headers.filter { h =>
- h.name === ContextHeaders.AuthenticationTokenHeader || h.name === ContextHeaders.TrackingIdHeader
- // || ContextHeaders.LinkerD.isLinkerD(h.lowercaseName)
- } map { header =>
- header.name -> header.value
- } toMap
+ def serviceContext: Directive1[ServiceRequestContext] = extract(ctx => extractServiceContext(ctx))
+
+ def extractServiceContext(ctx: RequestContext): ServiceRequestContext =
+ ServiceRequestContext(extractTrackingId(ctx), extractContextHeaders(ctx))
+
+ def extractTrackingId(ctx: RequestContext): String = {
+ ctx.request.headers
+ .find(_.name == ContextHeaders.TrackingIdHeader)
+ .fold(java.util.UUID.randomUUID.toString)(_.value())
+ }
+
+ def extractContextHeaders(ctx: RequestContext): Map[String, String] = {
+ ctx.request.headers.filter { h =>
+ h.name === ContextHeaders.AuthenticationTokenHeader || h.name === ContextHeaders.TrackingIdHeader
+ // || ContextHeaders.LinkerD.isLinkerD(h.lowercaseName)
+ } map { header =>
+ header.name -> header.value
+ } toMap
+ }
}
+ object Auth {
+
+ trait Permission
+
+ trait Role {
+ val id: Id[Role]
+ val name: Name[Role]
+ val permissions: Set[Permission]
+
+ def hasPermission(permission: Permission): Boolean = permissions.contains(permission)
+ }
+
+ trait User {
+ def id: Id[User]
+ def roles: Set[Role]
+ def permissions: Set[Permission] = roles.flatMap(_.permissions)
+ }
+
+ final case class BasicUser(id: Id[User], roles: Set[Role]) extends User
+
+ final case class AuthToken(value: String)
+
+ final case class PasswordHash(value: String)
+
+ object AuthProvider {
+ val AuthenticationTokenHeader = ServiceRequestContext.ContextHeaders.AuthenticationTokenHeader
+ val SetAuthenticationTokenHeader = "set-authorization"
+ }
+
+ trait AuthProvider[U <: User] {
+
+ import akka.http.scaladsl.server._
+ import Directives._
+
+ /**
+ * Specific implementation on how to extract user from request context,
+ * can either need to do a network call to auth server or extract everything from self-contained token
+ *
+ * @param context set of request values which can be relevant to authenticate user
+ * @return authenticated user
+ */
+ protected def authenticatedUser(context: ServiceRequestContext): OptionT[Future, U]
+
+ def authorize(permissions: Permission*): Directive1[U] = {
+ ServiceRequestContext.serviceContext flatMap { ctx =>
+ onComplete(authenticatedUser(ctx).run).flatMap {
+ case Success(Some(user)) =>
+ if (permissions.forall(user.permissions.contains)) provide(user)
+ else {
+ val challenge =
+ HttpChallenges.basic(s"User does not have the required permissions: ${permissions.mkString(", ")}")
+ reject(AuthenticationFailedRejection(CredentialsRejected, challenge))
+ }
+
+ case Success(None) =>
+ reject(ValidationRejection(s"Wasn't able to find authenticated user for the token provided"))
+
+ case Failure(t) =>
+ reject(ValidationRejection(s"Wasn't able to verify token for authenticated user", Some(t)))
+ }
+ }
+ }
+ }
+ }
trait Service
@@ -86,7 +155,7 @@ object rest {
val requestTime = time.currentTime()
val request = requestStub
- .withHeaders(RawHeader(ContextHeaders.TrackingIdHeader, context.trackingId))
+ .withHeaders(RawHeader(ServiceRequestContext.ContextHeaders.TrackingIdHeader, context.trackingId))
.withHeaders(context.contextHeaders.toSeq.map { h => RawHeader(h._1, h._2): HttpHeader }: _*)
log.audit(s"Sending to ${request.uri} request $request with tracking id ${context.trackingId}")
diff --git a/src/test/scala/xyz/driver/core/AuthTest.scala b/src/test/scala/xyz/driver/core/AuthTest.scala
index f4d4d2a..57f79ff 100644
--- a/src/test/scala/xyz/driver/core/AuthTest.scala
+++ b/src/test/scala/xyz/driver/core/AuthTest.scala
@@ -7,7 +7,7 @@ import akka.http.scaladsl.model.headers.{HttpChallenges, RawHeader}
import akka.http.scaladsl.server.AuthenticationFailedRejection.CredentialsRejected
import org.scalatest.mock.MockitoSugar
import org.scalatest.{FlatSpec, Matchers}
-import xyz.driver.core.auth._
+import xyz.driver.core.rest.Auth._
import xyz.driver.core.rest.ServiceRequestContext
import scala.concurrent.Future
@@ -15,13 +15,19 @@ import scalaz.OptionT
class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRouteTest {
- val authStatusService: AuthService[User] = new AuthService[User] {
- override def authStatus(context: ServiceRequestContext): OptionT[Future, User] = OptionT.optionT[Future] {
- if (context.contextHeaders.keySet.contains(AuthService.AuthenticationTokenHeader)) {
- Future.successful(Some(new User {
- override def id: Id[User] = Id[User]("1")
- override def roles: Set[Role] = Set(PathologistRole)
- }: User))
+ case object TestRoleAllowedPermission extends Permission
+ case object TestRoleNotAllowedPermission extends Permission
+
+ case object TestRole extends Role {
+ val id = Id("1")
+ val name = Name("testRole")
+ val permissions = Set[Permission](TestRoleAllowedPermission)
+ }
+
+ val authStatusService: AuthProvider[User] = new AuthProvider[User] {
+ override def authenticatedUser(context: ServiceRequestContext): OptionT[Future, User] = OptionT.optionT[Future] {
+ if (context.contextHeaders.keySet.contains(AuthProvider.AuthenticationTokenHeader)) {
+ Future.successful(Some(BasicUser(Id[User]("1"), Set(TestRole))))
} else {
Future.successful(Option.empty[User])
}
@@ -33,7 +39,7 @@ class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRo
"'authorize' directive" should "throw error is auth token is not in the request" in {
Get("/naive/attempt") ~>
- authorize(CanSignOutReport) { user =>
+ authorize(TestRoleAllowedPermission) { user =>
complete("Never going to be here")
} ~>
check {
@@ -44,12 +50,12 @@ class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRo
it should "throw error is authorized user is not having the requested permission" in {
- val referenceAuthToken = AuthToken("I am a pathologist's token")
+ val referenceAuthToken = AuthToken("I am a test role's token")
Post("/administration/attempt").addHeader(
- RawHeader(AuthService.AuthenticationTokenHeader, referenceAuthToken.value)
+ RawHeader(AuthProvider.AuthenticationTokenHeader, referenceAuthToken.value)
) ~>
- authorize(CanAssignRoles) { user =>
+ authorize(TestRoleNotAllowedPermission) { user =>
complete("Never going to get here")
} ~>
check {
@@ -57,7 +63,7 @@ class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRo
rejections should contain(
AuthenticationFailedRejection(
CredentialsRejected,
- HttpChallenges.basic("User does not have the required permissions: CanAssignRoles")))
+ HttpChallenges.basic("User does not have the required permissions: TestRoleNotAllowedPermission")))
}
}
@@ -66,9 +72,9 @@ class AuthTest extends FlatSpec with Matchers with MockitoSugar with ScalatestRo
val referenceAuthToken = AuthToken("I am token")
Get("/valid/attempt/?a=2&b=5").addHeader(
- RawHeader(AuthService.AuthenticationTokenHeader, referenceAuthToken.value)
+ RawHeader(AuthProvider.AuthenticationTokenHeader, referenceAuthToken.value)
) ~>
- authorize(CanSignOutReport) { user =>
+ authorize(TestRoleAllowedPermission) { user =>
complete("Alright, user \"" + user.id + "\" is authorized")
} ~>
check {