Skip to content
This repository has been archived by the owner on Sep 27, 2021. It is now read-only.

Commit

Permalink
Refactored rejections (#183)
Browse files Browse the repository at this point in the history
* Refactored rejection and error handling

* Updated iam client to discriminate between 401 and 403

* Updated identity regexes to accept any scheme

* Removed endpoint env
  • Loading branch information
bogdanromanx committed Jan 17, 2019
1 parent 239482f commit 9e2435c
Show file tree
Hide file tree
Showing 36 changed files with 706 additions and 649 deletions.
3 changes: 0 additions & 3 deletions Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ pipeline {
options {
timeout(time: 30, unit: 'MINUTES')
}
environment {
ENDPOINT = sh(script: 'oc env statefulset/iam -n bbp-nexus-dev --list | grep SERVICE_DESCRIPTION_URI', returnStdout: true).split('=')[1].trim()
}
stages {
stage("Review") {
when {
Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ scalafmt: {
// Dependency versions
val rdfVersion = "0.2.29"
val commonsVersion = "0.10.41"
val serviceVersion = "0.10.23"
val serviceVersion = "0.10.27"
val sourcingVersion = "0.12.2"
val akkaVersion = "2.5.19"
val akkaCorsVersion = "0.3.3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,39 @@ package ch.epfl.bluebrain.nexus.iam.client
import akka.actor.ActorSystem
import akka.http.scaladsl.client.RequestBuilding._
import akka.http.scaladsl.model.Uri.Query
import akka.http.scaladsl.model._
import akka.http.scaladsl.model.headers.OAuth2BearerToken
import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpResponse, StatusCodes}
import akka.stream.ActorMaterializer
import akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller
import akka.stream.{ActorMaterializer, Materializer}
import cats.MonadError
import cats.effect.LiftIO
import cats.effect.{IO, LiftIO}
import cats.syntax.applicativeError._
import cats.syntax.apply._
import cats.syntax.flatMap._
import cats.syntax.functor._
import ch.epfl.bluebrain.nexus.commons.http.HttpClient
import ch.epfl.bluebrain.nexus.commons.http.HttpClient._
import ch.epfl.bluebrain.nexus.commons.http.JsonLdCirceSupport._
import ch.epfl.bluebrain.nexus.commons.http.{HttpClient, UnexpectedUnsuccessfulHttpResponse}
import ch.epfl.bluebrain.nexus.commons.types.Err
import ch.epfl.bluebrain.nexus.commons.types.HttpRejection.UnauthorizedAccess
import ch.epfl.bluebrain.nexus.iam.client.IamClientError.{Forbidden, Unauthorized, UnknownError, UnmarshallingError}
import ch.epfl.bluebrain.nexus.iam.client.config.IamClientConfig
import ch.epfl.bluebrain.nexus.iam.client.types._
import ch.epfl.bluebrain.nexus.rdf.Iri.{AbsoluteIri, Path}
import ch.epfl.bluebrain.nexus.rdf.syntax.akka._
import io.circe.generic.auto._
import io.circe.syntax._
import io.circe.{DecodingFailure, Json, ParsingFailure}
import journal.Logger

import scala.concurrent.ExecutionContextExecutor
import scala.concurrent.{ExecutionContext, ExecutionContextExecutor}
import scala.reflect.ClassTag

class IamClient[F[_]] private[client] (config: IamClientConfig,
aclsClient: HttpClient[F, AccessControlLists],
callerClient: HttpClient[F, Caller],
permissionsClient: HttpClient[F, Permissions],
httpClient: UntypedHttpClient[F])(implicit F: MonadError[F, Throwable]) {

private val log = Logger[this.type]
class IamClient[F[_]] private[client] (
config: IamClientConfig,
aclsClient: HttpClient[F, AccessControlLists],
callerClient: HttpClient[F, Caller],
permissionsClient: HttpClient[F, Permissions],
jsonClient: HttpClient[F, Json]
)(implicit F: MonadError[F, Throwable]) {

/**
* Retrieve the current ''acls'' for some particular ''path''.
Expand All @@ -47,7 +49,7 @@ class IamClient[F[_]] private[client] (config: IamClientConfig,
implicit credentials: Option[AuthToken]): F[AccessControlLists] = {
val endpoint = config.aclsIri + path
val req = requestFrom(endpoint, Query("ancestors" -> ancestors.toString, "self" -> self.toString))
aclsClient(req).recoverWith { case e => recover(e, endpoint) }
aclsClient(req)
}

/**
Expand All @@ -56,8 +58,7 @@ class IamClient[F[_]] private[client] (config: IamClientConfig,
*/
def identities(implicit credentials: Option[AuthToken]): F[Caller] = {
credentials
.map(_ =>
callerClient(requestFrom(config.identitiesIri)).recoverWith { case e => recover(e, config.identitiesIri) })
.map(_ => callerClient(requestFrom(config.identitiesIri)))
.getOrElse(F.pure(Caller.anonymous))
}

Expand All @@ -68,11 +69,7 @@ class IamClient[F[_]] private[client] (config: IamClientConfig,
* @return available permissions
*/
def permissions(implicit credentials: Option[AuthToken]): F[Set[Permission]] =
permissionsClient(requestFrom(config.permissionsIri))
.map(_.permissions)
.recoverWith {
case e => recover(e, config.permissionsIri)
}
permissionsClient(requestFrom(config.permissionsIri)).map(_.permissions)

/**
* Replace ACL at a given path.
Expand All @@ -91,14 +88,7 @@ class IamClient[F[_]] private[client] (config: IamClientConfig,
val request = Put(endpoint.toAkkaUri.withQuery(query), entity)
val requestWithCredentials =
credentials.map(token => request.addCredentials(OAuth2BearerToken(token.value))).getOrElse(request)

httpClient(requestWithCredentials).flatMap {
case HttpResponse(StatusCodes.OK, _, respEntity, _) => httpClient.discardBytes(respEntity) *> F.unit
case HttpResponse(StatusCodes.Unauthorized, _, respEntity, _) =>
httpClient.discardBytes(respEntity) *> F.raiseError(UnauthorizedAccess)
case response =>
httpClient.discardBytes(response.entity) *> F.raiseError(UnexpectedUnsuccessfulHttpResponse(response))
}
jsonClient(requestWithCredentials) *> F.unit
}

/**
Expand All @@ -108,27 +98,13 @@ class IamClient[F[_]] private[client] (config: IamClientConfig,
* @param permission the permission to check
* @param credentials an optionally available token
*/
def authorizeOn(path: Path, permission: Permission)(implicit credentials: Option[AuthToken]): F[Unit] =
def hasPermission(path: Path, permission: Permission)(implicit credentials: Option[AuthToken]): F[Boolean] =
acls(path, ancestors = true, self = true).flatMap { acls =>
val found = acls.value.exists { case (_, acl) => acl.value.permissions.contains(permission) }
if (found) F.unit
else F.raiseError(UnauthorizedAccess)
if (found) F.pure(true)
else F.pure(false)
}

private def recover[A](th: Throwable, iri: AbsoluteIri): F[A] = th match {
case UnexpectedUnsuccessfulHttpResponse(HttpResponse(StatusCodes.Unauthorized, _, _, _)) =>
F.raiseError(UnauthorizedAccess)
case ur: UnexpectedUnsuccessfulHttpResponse =>
log.warn(
s"Received an unexpected response status code '${ur.response.status}' from IAM when attempting to perform and operation on a resource '$iri'")
F.raiseError(ur)
case err =>
log.error(
s"Received an unexpected exception from IAM when attempting to perform and operation on a resource '$iri'",
err)
F.raiseError(err)
}

private def requestFrom(iri: AbsoluteIri, query: Query = Query.Empty)(implicit credentials: Option[AuthToken]) = {
val request = Get(iri.toAkkaUri.withQuery(query))
credentials.map(token => request.addCredentials(OAuth2BearerToken(token.value))).getOrElse(request)
Expand All @@ -139,7 +115,55 @@ class IamClient[F[_]] private[client] (config: IamClientConfig,
// $COVERAGE-OFF$
object IamClient {

final case object UserRefNotFound extends Err("Missing UserRef")
private def httpClient[F[_], A: ClassTag](
implicit L: LiftIO[F],
F: MonadError[F, Throwable],
ec: ExecutionContext,
mt: Materializer,
cl: UntypedHttpClient[F],
um: FromEntityUnmarshaller[A]
): HttpClient[F, A] = new HttpClient[F, A] {
private val logger = Logger(s"IamHttpClient[${implicitly[ClassTag[A]]}]")

override def apply(req: HttpRequest): F[A] =
cl.apply(req).flatMap { resp =>
resp.status match {
case StatusCodes.Unauthorized =>
cl.toString(resp.entity).flatMap { entityAsString =>
F.raiseError[A](Unauthorized(entityAsString))
}
case StatusCodes.Forbidden =>
logger.error(s"Received Forbidden when accessing '${req.method.name()} ${req.uri.toString()}'.")
cl.toString(resp.entity).flatMap { entityAsString =>
F.raiseError[A](Forbidden(entityAsString))
}
case other if other.isSuccess() =>
val value = L.liftIO(IO.fromFuture(IO(um(resp.entity))))
value.recoverWith {
case pf: ParsingFailure =>
logger.error(
s"Failed to parse a successful response of '${req.method.name()} ${req.getUri().toString}'.")
F.raiseError[A](UnmarshallingError(pf.getMessage()))
case df: DecodingFailure =>
logger.error(
s"Failed to decode a successful response of '${req.method.name()} ${req.getUri().toString}'.")
F.raiseError(UnmarshallingError(df.getMessage()))
}
case other =>
cl.toString(resp.entity).flatMap { entityAsString =>
logger.error(
s"Received '${other.value}' when accessing '${req.method.name()} ${req.uri.toString()}', response entity as string: '$entityAsString.'")
F.raiseError[A](UnknownError(other, entityAsString))
}
}
}

override def discardBytes(entity: HttpEntity): F[HttpMessage.DiscardedEntity] =
cl.discardBytes(entity)

override def toString(entity: HttpEntity): F[String] =
cl.toString(entity)
}

/**
* Constructs an ''IamClient[F]'' from implicitly available instances of [[IamClientConfig]], [[ActorSystem]],
Expand All @@ -155,10 +179,11 @@ object IamClient {
implicit val ec: ExecutionContextExecutor = as.dispatcher
implicit val ucl: UntypedHttpClient[F] = HttpClient.untyped[F]

val aclsClient: HttpClient[F, AccessControlLists] = HttpClient.withUnmarshaller[F, AccessControlLists]
val callerClient: HttpClient[F, Caller] = HttpClient.withUnmarshaller[F, Caller]
val permissionsClient: HttpClient[F, Permissions] = HttpClient.withUnmarshaller[F, Permissions]
new IamClient(config, aclsClient, callerClient, permissionsClient, ucl)
val aclsClient: HttpClient[F, AccessControlLists] = httpClient[F, AccessControlLists]
val callerClient: HttpClient[F, Caller] = httpClient[F, Caller]
val permissionsClient: HttpClient[F, Permissions] = httpClient[F, Permissions]
val jsonClient: HttpClient[F, Json] = httpClient[F, Json]
new IamClient(config, aclsClient, callerClient, permissionsClient, jsonClient)
}
}
// $COVERAGE-ON$
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package ch.epfl.bluebrain.nexus.iam.client
import akka.http.scaladsl.model.StatusCode

import scala.reflect.ClassTag

@SuppressWarnings(Array("IncorrectlyNamedExceptions"))
sealed abstract class IamClientError(val message: String) extends Exception {
override def fillInStackTrace(): IamClientError = this
override val getMessage: String = message
}

@SuppressWarnings(Array("IncorrectlyNamedExceptions"))
object IamClientError {

final case class Unauthorized(entityAsString: String)
extends IamClientError("The request did not complete successfully due to an invalid authentication method.")

final case class Forbidden(entityAsString: String)
extends IamClientError("The request did not complete successfully due to lack of access to the resource.")

final case class UnmarshallingError[A: ClassTag](reason: String)
extends IamClientError(
s"Unable to parse or decode the response from IAM to a '${implicitly[ClassTag[A]]}' due to '$reason'.")

final case class UnknownError(status: StatusCode, entityAsString: String)
extends IamClientError("The request did not complete successfully.")
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ object AccessControlLists {
.addContext(searchCtxUri)
}

implicit def aclsDecoder(implicit http: IamClientConfig): Decoder[AccessControlLists] = {
implicit def aclsDecoder: Decoder[AccessControlLists] = {
import cats.implicits._
import ch.epfl.bluebrain.nexus.rdf.instances._

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ object Identity {
* @param id the id
* @return Some(identity) when the id maps to a known identity pattern, None otherwise
*/
def apply(id: AbsoluteIri)(implicit config: IamClientConfig): Option[Identity] = {
val regexUser = (config.baseIri + ("realms" / allowedInput / "users" / allowedInput)).asString.r
val regexGroup = (config.baseIri + ("realms" / allowedInput / "groups" / allowedInput)).asString.r
val regexAuth = (config.baseIri + ("realms" / allowedInput / "authenticated")).asString.r
val regexAnonymous = (config.baseIri + "anonymous").asString.r
def apply(id: AbsoluteIri): Option[Identity] = {
val regexUser = s".+/v1/realms/$allowedInput/users/$allowedInput".r
val regexGroup = s".+/v1/realms/$allowedInput/groups/$allowedInput".r
val regexAuth = s".+/v1/realms/$allowedInput/authenticated".r
val regexAnonymous = ".+/v1/anonymous".r
id.asString match {
case regexUser(realm, subject) => Some(User(subject, realm))
case regexGroup(realm, group) => Some(Group(group, realm))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ package ch.epfl.bluebrain.nexus.iam.client.types
*
* @param permissions available permissions
*/
case class Permissions(permissions: Set[Permission])
final case class Permissions(permissions: Set[Permission])
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ object ResourceAccessControlList {
.addContext(iamCtxUri) deepMerge acl.asJson
}

implicit def resourceAccessControlListDecoder(implicit config: IamClientConfig): Decoder[ResourceAccessControlList] =
implicit def resourceAccessControlListDecoder: Decoder[ResourceAccessControlList] =
Decoder.instance { hc =>
def toSubject(id: AbsoluteIri): Decoder.Result[Subject] =
Identity(id)
Expand Down
Loading

0 comments on commit 9e2435c

Please sign in to comment.