service-chassis

A scala chassis to get your applications and services bootstrapped quickly

INTRODUCTION

A slightly opinionated services chassis that lets you bootstrap your services and applications quickly by providing support for authentication, authorization, validation and I18N.

It uses


QUICK START

Add resolvers to build.sbt

NOTE you will need an s3 resolver plugin like sbt-s3-resolver

resolvers += "Service Chassis Snapshots" at "https://s3-ap-southeast-2.amazonaws.com/maven.allawala.com/service-chassis/snapshots"
resolvers += "Service Chassis Releases" at "https://s3-ap-southeast-2.amazonaws.com/maven.allawala.com/service-chassis/releases"

Add the dependency to build.sbt

"allawala" %% "service-chassis" % {serviceChassisVersion}

Replace the {serviceChassisVersion} with the current release or snapshot version

Extend the ChassisModule

class MyModule extends ChassisModule {
  override def configure(): Unit = {
    // IMPORTANT!!! always call super.configure
    super.configure()

    // Do service specific configuration
  }
}

Extend the Microservice trait

object MyApp extends Microservice with App {
  override def module: ChassisModule = new MyModule

  run()
}

Create a messages.properties file in the src/main/resources folder and copy the contents of the messages.properties file from the chassis’s src/main/resources folder

NOTE versions prior to 1.0.8 defined the resource as messages.txt. However .properties file plays a lot better with platforms like crowdin

Run the application

sbt run

Check that the application is running at

http://localhost:8080/health

Detailed health check

Depends On

Add the following to the build.sbt

enablePlugins(BuildInfoPlugin, GitVersioning)

// BuildInfo plugin Settings
buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, git.gitCurrentBranch, git.gitHeadCommit)
buildInfoPackage := "allawala"
buildInfoOptions += BuildInfoOption.BuildTime

Navigate to

http://localhost:8080/health/details

For a complete example, see test service


CONFIGURATION

The chassis defines the following environment enums

The environment that is used at runtime is controlled by the environment variable ENV and defaults to Local if not specified

With the exception of Local which looks for the application.conf, all other environments look for their respective configurations in application.{environment}.conf

eg.

ENV=dev

will look for application.dev.conf

The default configuration provided by the chassis is as follows

service {
  // base configuration common for all microservices that individual microservice can override as needed
  baseConfig {
    // Extending service should at the very least overwrite the name
    name = "service-chassis"
    name = ${?SERVICE_NAME}

    httpConfig {
      host = "0.0.0.0"
      host = ${?HOST}
      port = 8080
      port = ${?PORT}
    }

    languageConfig {
      header = "Accept-Language",
      parameter = "lang"
    }

    corsConfig {
      allowedOrigins: [
        "http://localhost:8080"
      ]
    }

    auth {
      expiration {
        expiry = "7 days"
        refreshTokenExpiry = "30 days"
        refreshTokenStrategy = "simple"
      }
      // using Asymmetric encryption
      // private key will be used to sign the JWT token and the public key can be used to verify the token returned to the service
      //
      // *************
      // * IMPORTANT *
      // *************
      // At the very least, microservice extending this chassis should overwrite these keys (preferrably using a vault or at a
      // minimum using environment variables)
      //
      // To generate a new public private key, Look at RSAKeyGenerator in the util package
      rsa {
        publicKey = ...  // default public key
        publicKey = ${?RSA_PUBLIC_KEY}
        privateKey = ... // default private key
        privateKey = ${?RSA_PRIVATE_KEY}
      }
    }

    awaitTermination = "30 seconds"
  }

  // configuration specific for the individual microservice
  config {
  }
}

Services extending the chassis can override any of the default values eg.

service {
  baseConfig {
    name = "my-test-service"

    corsConfig {
      allowedOrigins = [
        "https://my-test-service.com",
      ]
    }
  }
}

Additional service specific configuration can be provided within the config section

service {
  config {
    jdbc {
      url: ${?JDBC_URL}
      username: ${?JDBC_USER_NAME}
      password: ${?JDBC_PASSWORD}
    }
  }
}

Service Specific Configuration

Service specific configuration can be provided in the config section

service {
  config {
    servicesMocked = true
  }
}
case class ServiceConfig(servicesMocked: Boolean)

create a module

import ArbitraryTypeReader._
import Ficus._

class MyConfigModule extends ConfigModule {
  override def configure(): Unit = {
    // IMPORTANT
    super.configure()
  }

  @Provides
  @Singleton
  def getServiceConfig(config: Config): ServiceConfig = {
    config.as[ServiceConfig]("service.config")
  }
}

In the main module

class MyModule extends ChassisModule {
  override def configure(): Unit = {
    // IMPORTANT!!! always call super.configure
    super.configure()

    // Do service specific configuration
  }

  override protected def bindConfigModule(): Unit = {
    install(new MyConfigModule)
  }

}

NOTE you will also need to define the desired akka configuration in your application’s configuration file.


LOGGING

By default the logging framework looks for logback.groovy

To use different configurations for different environments


ROUTES

To create a route, extend the HasRoute trait

class MyRoute extends HasRoute {
    // provide implementation for this
    override def route: Route = ???
}

All the individual routes from classes that extend the HasRoute trait will be automatically concatenated to form the full route hierarchy

NOTE Out of the box, the chassis provides support for method signatures of the form Future[Either[DomainException, A]] where A is the successful return type. Aliased as ResponseFE[A]

Hence It is recommended to extend the RouteSupport trait which will enhance the route to handle

These topics will be covered in more detail in later sections.

Extending RouteSupport requires the I18nService to be injected into the route. For most cases using the default implementation provided by the chassis should be sufficient

class UserPublicRoute @Inject() (
                                  override val i18nService: I18nService,
                                  userService: UserService
                                ) extends HasRoute with RouteSupport {

  override def route: Route = pathPrefix("v1" / "public") {
    register ~
    login
  }

  def register: Route = path("users" / "register") {
    post {
      entity(as[Registration]) { registration =>
        // Follows the akka naming convention where completeEither expects an Either[DomainException, A] and onCompleteEither expects Future[Either[DomainException, A]] where A must provide an implicit ToEntityMarshaller
        onCompleteEither {
          userService.register(registration)
        }
      }
    }
  }

  def login: Route = ???
}

The new routes and services will need to be configured and bound

A nice modular way to do this is to create individual modules based on packaging, domain or functionality eg

class UserModule extends AbstractModule with ScalaModule {
  override def configure(): Unit = {
    bind[UserPublicRoute].asEagerSingleton()
    bind[UserService].to[UserServiceImpl].asEagerSingleton()
  }
}

and then install his module in the main module that extends the ChassisModule

class MyModule extends ChassisModule {
  override def configure(): Unit = {
    // IMPORTANT!!! always call super.configure
    super.configure()

    install(new UserModule)
  }
}

NOTE

Each request will automatically be associated with a new correlation ID. If you wish to propagate an existing correlation ID instead, pass it in via the X-CORRELATION-ID request header

This correlation ID is set in the Mapped Diagnostic Context (MDC) so that it can be used in logging.

Since scala requests can propagate between different execution contexts and threads, the MDC is also propagated. see MDCPropagatingDispatcherConfigurator


EXCEPTIONS

Following good domain driven practices of not exposing any internal, third party or driver specific exceptions, chassis provides a DomainException trait which is handled seamlessly when the request fails

trait DomainException extends Exception {
  def statusCode: StatusCode

  def errorType: ErrorType

  def cause: Throwable

  def errorCode: String

  /**
    *
    * messageParameters: values to be substituted in the messages file
    */
  def messageParameters: Seq[AnyRef] = Seq.empty

  /**
    *   logMap: any key value pair that need to be logged as part of the [[allawala.chassis.core.model.HttpErrorLog]] but is not required to be part of the
    *   error response in the [[allawala.chassis.core.model.ErrorEnvelope]]
    */
  def logMap: Map[String, AnyRef] = Map.empty[String, AnyRef]

  /*
   For the most part, exceptions will be logged globally at the outer edges where the logging thread will most likely be the
   dispatcher thread. However, the actual failure might have occurred on a different thread. Hence we capture this information
   as it might be useful in debugging errors.
  */
  val thread: Option[String] = Some(Thread.currentThread().getName)

  override def getMessage: String = Option(cause).map(_.getMessage).getOrElse(errorCode)

  override def getCause: Throwable = cause
}

eg.

class UserServiceImpl extends UserService {
  override def register(registration: Registration): ResponseFE[User] = {
    Future.successful(Left(ServerException("email.already.in.use", messageParameters = Seq(registration.email))))
  }
}

and in the messages.properties file

email.already.in.use=email {0} is already in use, please use a different email

As mentioned previously, the RouteSupport can handle services returning a Future[Either[DomainException, A]] by using the onCompleteEither method

If the result is a Future[Left[_ <: DomainException]] or if its a failed Future, then when the request is completed

Example response on a failure

{
    "errorType": "ServerError",
    "correlationId": "dacc1f06-4dbe-4b5c-95a1-768f13f4ff26",
    "errorCode": "email.already.in.use",
    "errorMessage": "email user@test.com is already in use, please use a different email",
    "details": {}
}

NOTE language or locale specific messages files are only necessary if the I18N is being handled by the server side. If it will be handled on the client side, only the default messages.properties file is needed. The client side can handle the I18N using the returned errorCode in the payload

Currently chassis provides the following concrete DomainException implementations

NOTE When writing a custom directive that needs to reject a request and still be able to reuse the DomainException wiring for logging and standard error response

import allawala.chassis.core.rejection.DomainRejection._

reject(ValidationException(e))

As mentioned earlier that on Exceptions, failed requests are automatically logged

However, if a service wishes to log a DomainException explicitly

class MyClass extends LogWrapper {
  def doSomething() = {
    ...
    logIt(ServerException("email.already.in.use", messageParameters = Seq(registration.email)))
  }
}

VALIDATION

A service should always validate the payload of an incoming request even if there is client side validation

The chassis uses circe for encoding/decoding json. Circe will automatically fail decoding if any required fields are missing in the json payload. However the default error as a result of this is a bit cryptic and does not conform the the domain ValidationException

To handle this, the chassis has a circeRejectHandler which tries its best to translate the circe error into a ValidationException automatically

eg.

@JsonCodec
case class Registration(email: String, password: String, firstName: String, lastName: String)

NOTE Using the @JsonCodec instead of auto derivation cuts down on the compile time significantly. It requires the scala macroparadise to be enabled in build.sbt

val macroParadiseVersion = "2.1.0"

addCompilerPlugin("org.scalamacros" % "paradise" % macroParadiseVersion cross CrossVersion.full)

Alternatively, explicit encoders/decoders can be defined if auto or semi auto derivation is not preferred

Eg. we attempt decode the incoming request to the Registration case class

class UserPublicRoute @Inject() (
                                  override val i18nService: I18nService,
                                  userService: UserService
                                ) extends HasRoute with RouteSupport {
  override def route: Route = pathPrefix("v1" / "public") {
    register
  }

  def register: Route = path("users" / "register") {
    post {
      entity(as[Registration]) { registration =>
        onCompleteEither {
          userService.register(registration)
        }
      }
    }
  }
}

If the incoming request does not provide the email field in the payload, the response should look like

{
    "errorType": "ValidationError",
    "correlationId": "96e48efb-260a-4226-992a-fbf7eac4a900",
    "errorCode": "validation.error",
    "errorMessage": "validation failure",
    "details": {
        // name of the field that failed validation
        "email": [
            // list of validation errors
            {
                "key": "validation.error.required",
                "message": "required"
            }
        ]
    }
}

The client side can view the “errorCode”/”errorMessage” as a global message for the UI being displayed while it can use the details to show the validation errors at the individual field level

NOTE If the client side wants to handle the I18N, it can use the “errorCode” from the main payload and the “key” from the details section to provide thea appropriate messages. The server side then only needs to provide the default messages.properties file

To define custom validation, first define a class extending the ValidationError trait

trait ValidationError {
  def field: String
  def code: String
  def parameters: Seq[AnyRef] = Seq.empty
}

Eg.

import allawala.chassis.core.validation.ValidationError

final case class EmailError(field: String) extends ValidationError {
  override val code: String = "validation.error.email"
}

Then create a trait with a method does the actual validation

import allawala.ValidationResult
import cats.implicits._

trait ValidateEmail {
  // Email regex, see RFC2822
  val EmailRegex = "(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])"

  protected def email(name: String, value: String): ValidationResult[String] = {
    if (value.matches(EmailRegex)) value.validNel else EmailError(name).invalidNel
  }
}

In the messages.properties file add

validation.error.email=invalid email

Apply the validation

trait UserValidator extends ValidateEmail {
  def validateRegistration(registration: Registration): ValidationResult[Registration]
}
import allawala.ValidationResult
import allawala.chassis.core.validation.ValidationError
import cats.implicits._

class UserValidatorImpl extends UserValidator {

  override def validateRegistration(registration: Registration): ValidationResult[Registration] = {
    (
      email("email", registration.email),
      notBlank("firstName", registration.firstName),
      notBlank("lastName", registration.lastName),
      minLength("password", registration.password, 8)
    ) mapN {
      case _ => registration
    }
  }
}

HINT, you may in some cases want to perform pre and post transformation prior to and post validation. eg transforming empty string back to a None for an optional field

To hook the validation into the route, extend the ValidationDirective trait

class UserPublicRoute @Inject() (
                                  override val i18nService: I18nService,
                                  userService: UserService,
                                  userValidator: UserValidator
                                ) extends HasRoute with RouteSupport with ValidationDirective {
  override def route: Route = pathPrefix("v1" / "public") {
    register
  }

  def register: Route = path("users" / "register") {
    post {
      // call the validator
      model(as[Registration])(userValidator.validateRegistration) { validatedRegistration =>
        onCompleteEither {
          userService.register(validatedRegistration)
        }
      }
    }
  }
}

Example of custom validation that uses templated parameters for messages

final case class EqualError[T](override val field: String, expected: T) extends ValidationError {
  override val code = "validation.error.not.equal"
  override val parameters: Seq[AnyRef] = Seq(expected.toString)
}
trait ValidateEqual {
  protected def equal[T](name: String, value: T, expected: T): ValidationResult[T] =
    if (value != expected) EqualError(name, expected).invalidNel else value.validNel

  protected def equal[T](name: String, value: Option[T], expected: T): ValidationResult[Option[T]] = value match {
    case Some(v) => equal(name, v, expected).map(_ => value)
    case None => EqualError(name, expected).invalidNel
  }

  protected def equalIgnoreCase(name: String, value: String, expected: String): ValidationResult[String] =
    if (value.toLowerCase != expected.toLowerCase) EqualError(name, expected).invalidNel else value.validNel
}

and in the messages.properties

validation.error.not.equal=must be equal to {0}

The chassis provides a few validations out of the box eg required, notBlank, unexpected. These can be brought into scope by extending the Validate trait

Notice, that these follow the pattern defining the validation for optional fields

trait ValidateRequired {
  protected def required[T](name: String, value: Option[T]): ValidationResult[T] = value match {
    case Some(v) => v.validNel
    case None => RequiredField(name).invalidNel
  }

  protected def requiredString(name: String, value: Option[String]): ValidationResult[String] =
    required[String](name, value)
}

This is to cater for applications that reuse the same case class for different actions like create/update where a field might be optional on create (and will be defaulted if missing) as opposed to being required on update.


AUTH TOKENS

In a typical web application, a user normally logs in providing credentials in the form of a username/password. This login results in a JWT token to be generated and returned to the user which the user then provides as credentials in subsequent requests. The user continues to use this token until the token expires, at which point the user is prompted to login with the username/password again

By default, the chassis uses the RS512 algorithm to generate the token and a RSA private key to sign the token. When this token is provided as a credential, the chassis decodes it using the same algorithm and the matching RSA public key

the default private and public keys are defined in the chassis’s configuration and each service MUST provide a new set of keys for the service to be secured properly.

It is HIGHLY RECOMMENDED that different keys are provided for each environment and that these keys are passed in via the environment variable instead of being set in the configuration files In fact, these keys should be stored in some place like the ansible vault or Amazon’s Secrets Manager and be injected as environment variables from there

rsa {
  publicKey = .. // default public key that must be overridden
  publicKey = ${?RSA_PUBLIC_KEY}
  privateKey = .. // default private key that must be overridden
  privateKey = ${?RSA_PRIVATE_KEY}
}

The generated token will have a payload

{
  "iat": 1534826113,
  "exp": 1535430913,
  "sub": "test@test.com", // This can also be a uuid or any other piece of information that identifies the subject
  "typ": "user",
  "rnd": 1534826113348
}

NOTE one of the improvements planned for the future is to allow arbitrary data to be added to the generated token

In most applications, there are usually two types of interactions, user initiated or calls from another service. The distinction can be specified in the typ field of the JWT token payload. Currently the chassis recognizes the following token typ values

TIP

As recommended, If the public/private keys are stored externally, the service token may be generated externally (using the same algorithm). This would allow the service token expiration to be decoupled from the expiration semantics that are driven by the rememberMe flag as in the case of user tokens, thus allowing for shorter lived tokens and a different rotation policy. One way to achieve this would be to use the combination of AWS Lambda and AWS API Gateway.

See https://engineroom.beamwallet.com/product/2018/5/31/jwt-token-generation-using-aws-lambda

You can still use the chassis to generate the service tokens but you would have to use the JWTTokenService directly. All the provided route directives currently only cater for user tokens

The chassis provides the RouteSecurity trait that the routes can extend that allows for seamless integration with the chassis’s authentication and authorization mechanisms and jwt token generation and authorization.

class UserPublicRoute @Inject()(
                                 override val i18nService: I18nService,
                                 override val authService: ShiroAuthService,
                                 userService: UserService,
                                 userValidator: UserValidator
                               ) extends HasRoute with RouteSupport with ValidationDirective with RouteSecurity {
  override def route: Route = pathPrefix("v1" / "public") {
    register ~
      login
  }

  def register: ???

  def login: Route = path("users" / "login") {
    post {
      model(as[Login])(userValidator.validateLogin) { validatedLogin =>
        onAuthenticateWithFailureHandling(validatedLogin.email, validatedLogin.password, validatedLogin.rememberMe.getOrElse(false)) {
          userService.loginFailed(validatedLogin.email)
        } { subject =>
          onCompleteEither {
            userService.login(subject.getPrincipal.asInstanceOf[String])
          }
        }
      }
    }
  }
}

NOTE requires the ShiroAuthService to be injected, For most cases the default implementation should be sufficient.

IMPORTANT see the section on Authentication to learn how to provide the Shiro realm implementations that provide the actual authentication logic

The variant onAuthenticateWithFailureHandling shown in the example allows hooks for both successful and failed authentication. This is useful as one might want to track consecutive failed login attempts and for security purposes lock down the user’s account if it surpasses some threshold. This also means that on a successful login, the consecutive failed attempts counts would need to be reset.

An Authorization : “Bearer token” header is automatically added to the response headers, which the client side can store and send in subsequent requests

Any route that requires an authenticated user to proceed, ie a valid JWT token, it can use the onAuthenticated directive

  def getUser: Route = path("users" / Segment) { uuid =>
    get {
      onAuthenticated { subject =>
        onCompleteEither {
          userService.getUser(uuid)
        }
      }
    }
  }

IMPORTANT see the section on Authentication to learn how to provide the Shiro realm implementations that provide the actual authentication logic

TOKEN EXPIRATION

The expiration for the issued JWT tokens depends on the following configuration that can be overridden (see reference.conf)

  expiration {
    expiry = "7 days"
    refreshTokenExpiry = "30 days"
    refreshTokenStrategy = "simple"
  }

Chassis caters for two refresh strategies

REFRESH TOKEN

It’s simply a string of the form selector:validator Typically the application stores a hash of the validator along with the selector, the expiration and the accompanying JWT token. This is handled via the TokenStorage discussed in the next section

When the chassis encounters a refresh token and a JWT token

In our example, the original refresh token will have an expiration of ‘30 days’. What about the subsequent rotated refresh tokens?

By default each new refresh token generated and passed to the storage service will have an expiration of 30 days from the current date. However, the application may choose only to update and store the new tokens and leave the original expiration intact.

So if the stored expiration is updated on token rotation, it will result in a infinite sliding window for tokens to be rotated. Only caveat being if the user lapses and the refresh token expires before it can be rotated

If the stored expiration for the tokens is not updated on token rotation, then the JWT and refresh tokens will only get reissued until that original 30 days expire. After which the user will be forced to log in again

All these durations can be overridden in the configuration file

TOKEN STORAGE

Regardless of the refresh strategy, the TokenStorage service hooks are called during token generation, validation and rotation.

The ‘Full’ refresh strategy requires the refresh token information to be stored. However, even when using the Simple strategy, just because the token is valid does not mean we should blindly allow it. Tokens can get compromised, a user’s account may have been deactivated by administration or a user may wish to log out of all their sessions. Storing tokens regardless of strategy allows the authentication logic to cater for these cases

trait TokenStorageService {
  def storeTokens(
                    principalType: PrincipalType, principal: String, jwtToken: String, refreshToken: Option[RefreshToken]
                  ): ResponseFE[Unit]

  /*
    Lookup the jwtToken and its associated refresh token
   */
  def lookupTokens(selector: String): ResponseFE[(String, RefreshToken)]

  /*
    Rotating tokens mean that refresh tokens must be involved
   */
  def rotateTokens(
                     principalType: PrincipalType, principal: String,
                     oldJwtToken: String, jwtToken: String,
                     oldRefreshToken: RefreshToken, refreshToken: RefreshToken
                  ): ResponseFE[Unit]

  def removeTokens(
                    principalType: PrincipalType, principal: String, jwtToken: String, refreshTokenSelector: Option[String]
                  ): ResponseFE[Unit]

  def removeAllTokens(principalType: PrincipalType, principal: String): ResponseFE[Unit]
}

storeTokens

This is called when the user is authenticated and a new JWT token and possibly a new refresh token is generated

removeTokens

This is called either when an expired token iss encountered or when the user explicitly logs out of the current active session using the onInvalidateSession directive

 def logout: Route = path("users" / Segment / "logout") { _ =>
  post {
    onInvalidateSession {
      complete(StatusCodes.OK)
    }
  }
 }

removeAllTokens

This is called when the user wishes to log out of all their active sessions by using the onInvalidateAllSessions directive

  def logoutAllSessions: Route = path("users" / Segment / "logout-all-sessions") { _ =>
    post {
      onInvalidateAllSessions {
        complete(StatusCodes.OK)
      }
    }
  }

rotateTokens

This is called when JWT and refresh tokens are reissued automatically when using the refresh strategy full

lookupTokens

This is called to get the refresh token information and its associated JWT token so that the chassis can determine if new tokens can be issued automatically or not


AUTHENTICATION

As outlined in the JWT TOKEN section, the chassis provides onAuthenticate and onAuthenticated directives to authenticate username/password and JWT token credentials respectively.

The actual authentication is application specific and since the chassis uses Apache Shiro, each service must provide the appropriate implementation for the realms

The service-chassis supports two realms

IMPORTANT In line with building stateless applications, shiro session storage is disabled by default.

The default implementations for these two realms in the chassis are extremely permissive and each service MUST provide a more secure implementation

Authenticating using username/password

Authenticating using JWT Token

Putting it all together


AUTHORIZATION

Authentiction is the mechanism whereby a user’s credentials are verified to be valid. Authorization on the other hand is checking whether the authenticated user has the appropriate permissions to perform the requested action

Just like authentication, the authorization logic is handled in the realm, specifically the JWTRealm

To hook the authorization into the route the chassis provides the authorized and onAuthorized directives

def getUser: Route = path("users" / Segment) { uuid =>
  get {
    onAuthenticated { subject =>
      authorized(subject, s"user:view:$uuid") {
        onCompleteEither {
          userService.getUser(uuid)
        }
      }
    }
  }
}

NOTE since scala requests can span different execution contexts, we never lookup the Shiro subject from the ThreadLocal. The subject is passed around explicitly as required

This authorized directive will call the doGetAuthorizationInfo in the JWT realm(s). If permitted, the request proceeds, if not its rejected with the appropriate error code

IMPORTANT In the route, you should always check the most fine grained permissions, even if the actual permissions defined for the user are more coarse grained

There are also variants such as authorizedAny, onAuthorizedAny, authorizedAll, onAuthorizedAll

For the realm to determine whether this action is authorized or not, it needs to be able to look up the permissions for that logged in user.

So assuming we are now storing the user permissions along with the user.

/*
  You may want to use enums for resource and action types to make them strongly typed
*/
case class Permission(resource: String, action: String, instances: Set[String]) {
  val permissionString = s"$resource:$action:${instances.mkString(",")}"
}

eg the permission might actually be “stores:view:*” which means that user is allowed to view all stores

case class UserEntity(
                       uuid: String,
                       ... // other fields
                       permissions: Set[Permission] = Set.empty[Permission]
                     )

In the class that we defined earlier that extended the JWTRealm we override the doGetAuthorizationInfo method

class JWTAuthRealm @Inject() (userTokenRepository: UserTokenRepository, userRepository: UserRepository) extends JWTRealm {
  private val AllPermissions = "*:*:*"

  override def doGetAuthenticationInfo(authenticationToken: AuthenticationToken): AuthenticationInfo = {
    ...
  }

  override def doGetAuthorizationInfo(principals: PrincipalCollection): AuthorizationInfo = {
    val principal = principals.getPrimaryPrincipal.asInstanceOf[Principal]

    // In this example we allow a service to perform any action, you may want to limit as needed
    val (roleNames, permissions) = if (principal.principalType == PrincipalType.Service) {
      (Set.empty[String], Set(AllPermissions))
    } else {
      userRepository.getByEmailOpt(principal.principal) match {
        case Some(user) => (user.roles, user.permissions.map(_.permissionString))
        case None => throw new AuthenticationException("user not found")
      }
    }
    val info = new SimpleAuthorizationInfo(roleNames.asJava)
    info.setStringPermissions(permissions.asJava)
    info
  }
}

LIFECYCLE

The chassis provides the following life cycle hooks

Underlying services can provide implementation for these methods by either

IMPORTANT There can be any number of classes that extend the LifecycleAware trait or the BaseLifecycleAware. Each of these life cycle implementations will be run in parallel. Hence, the logic in one listener should not conflict with logic in another listener.

IMPORTANT If the preStart or the postStart methods fail, either implicitly through an uncaught exception that causes the future to fail or explicitly via the implementation returning a Left, the startup will aborted and the application will be shut down calling preStop in the process

NOTE since the lifecycle actions happen in the context of a Future, its best to make sure that these complete quickly. If the intended tasks take a long amount of time to complete, its probably better to run them async from the hooks and let the hook complete


I18N

Internationalization support is currently only hooked into the responses returned in case of errors. Logging uses the values from default language which at the moment is english.

Language specific messages are in the messages_XXX.properties files

The file selection is driven by the configuration

languageConfig {
  header = "Accept-Language",
  parameter = "lang"
}

Using the config, the chassis will go through the following steps until it succeeds

I18nService can also directly be used to get translated messages from property files via the various api calls, which also allow you to specify custom property file names

NOTE one of the improvements planned for the future is to allow default language to be configured


MISC

Environment

If you need to do logic based on the environment, you can inject it in

class MyClass @Inject() (environment: Environment) {
...
}

CORS SUPPORT

Allowed origins can be configured in the configuration

service {
  baseConfig {
    corsConfig {
      allowedOrigins = [
        "https://api.dev.youdomain.com"
      ]
    }
  }
}

Enhancing actor system configuration programmatically

if the service is deployed inside a auto scaling cluster of ec2 instances, and there is need for a cluster singleton akka actor, then the additional configuration can be specified by overriding the loadConfig method

class MyConfigModule extends ConfigModule {
  override def configure(): Unit = {
    // IMPORTANT
    super.configure()
  }

  override protected def loadConfig(environment: Environment): Config = {
    // Get the port, host and seeds for the ec2 instances
    val host = ???
    val port = ???
    val seeds = ???
    ConfigFactory.empty()
      .withValue("akka.remote.netty.tcp.bind-hostname", ConfigValueFactory.fromAnyRef("0.0.0.0"))
      .withValue("akka.remote.netty.tcp.bind-port", ConfigValueFactory.fromAnyRef(port))
      .withValue("akka.remote.netty.tcp.hostname", ConfigValueFactory.fromAnyRef(host))
      .withValue("akka.remote.netty.tcp.port", ConfigValueFactory.fromAnyRef(port))
      .withValue("akka.cluster.seed-nodes", ConfigValueFactory.fromIterable(seeds.asJava))
      .withFallback(defaultConfig)
  }

}

SWAGGER

In the build.sbt

val swaggerVersion = "1.5.16"
val swaggerAkkaVersion = "0.11.0"

"io.swagger" % "swagger-jaxrs" % swaggerVersion,
"com.github.swagger-akka-http" %% "swagger-akka-http" % swaggerAkkaVersion,

Create a route that extends the SwaggerHttpService and override the defaults as needed. By default the docs will be available at the relative path ../api-docs/swagger.json

class SwaggerRoute extends SwaggerHttpService with HasRoute {
  override val apiClasses = Set(
    classOf[UserPublicRoute]
  )

  override val info = Info(version = "1.0")
  override val securitySchemeDefinitions = Map("apiKey" -> new ApiKeyAuthDefinition("Authorization", In.HEADER))
  override val schemes = List(Scheme.HTTPS, Scheme.HTTP)

  override def route = super.routes

}
@Api(value = "/users", produces = "application/json")
@Path("/v1/public/users")
class UserPublicRoute @Inject() (
                                  override val i18nService: I18nService,
                                  userService: UserService
                                ) extends HasRoute with RouteSupport {

  override def route: Route = pathPrefix("v1" / "public") {
    register ~
    login
  }

  @ApiOperation(value = "Register user", httpMethod = "POST", response = classOf[User], authorizations = Array(new Authorization(value = "apiKey")))
  @ApiImplicitParams(Array(
    new ApiImplicitParam(name = "body", value = "The user's registration details", dataTypeClass = classOf[Registration], required = true, paramType = "body")
  ))
  @Path("/register")
  def register: Route = path("users" / "register") {
    post {
      entity(as[Registration]) { registration =>
        // Follows the akka naming convention where completeEither expects an Either[DomainException, A] and onCompleteEither expects Future[Either[DomainException, A]] where A must provide an implicit ToEntityMarshaller
        onCompleteEither {
          userService.register(registration)
        }
      }
    }
  }

  def login: Route = ???
}

Create the module

class SwaggerModule extends AbstractModule with ScalaModule {

  override def configure(): Unit = {
    bind[SwaggerRoute].asEagerSingleton()
  }
}

Install this module in the main project module


ACKNOWLEDGEMENTS

MDCPropagatingDispatcherConfigurator

http://yanns.github.io/blog/2014/05/04/slf4j-mapped-diagnostic-context-mdc-with-play-framework/

https://github.com/jroper/thread-local-context-propagation/

Refresh token hashing, constant time comparison

https://github.com/softwaremill/akka-http-session

EvenMoreSugar.scala for unit testing

BeamWallet


LICENSE

Licensed under the Apache License, Version 2.0 (the “License”);

you may not use this file except in compliance with the License.

You may obtain a copy of the License at

Apache-License-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

See the License for the specific language governing permissions and limitations under the License.