Skip to content

Commit

Permalink
Merge pull request #37 from Ferlab-Ste-Justine/fix/cqdg-764_list_auth…
Browse files Browse the repository at this point in the history
…orized_files

fix: CQDG-765 authorized files list
  • Loading branch information
adipaul1981 authored Jun 11, 2024
2 parents 1349766 + e331f6f commit f780a35
Show file tree
Hide file tree
Showing 4 changed files with 248 additions and 7 deletions.
3 changes: 1 addition & 2 deletions src/main/scala/bio/ferlab/ferload/endpoints/Endpoints.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package bio.ferlab.ferload.endpoints

import bio.ferlab.ferload.Config
import bio.ferlab.ferload.endpoints.ConfigEndpoint.configServerEndpoint
import bio.ferlab.ferload.endpoints.LegacyObjectEndpoints.{objectByPathServer, listObjectsByPathServer}
import bio.ferlab.ferload.endpoints.ObjectsEndpoints.ById.singleObjectServer
import bio.ferlab.ferload.services.{AuthorizationService, ResourceService, S3Service}
import cats.effect.IO
import io.circe.generic.auto.*
Expand All @@ -25,6 +23,7 @@ object Endpoints:
statusServerEndpoint,
configServerEndpoint(config),
) ++ ObjectsEndpoints.all(config, authorizationService, resourceService, s3Service)
++ PermissionsEndpoints.ById.all(authorizationService)
++ DrsEndpoints.all(config, authorizationService, resourceService, s3Service)
++ LegacyObjectEndpoints.all(config, authorizationService, s3Service)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package bio.ferlab.ferload.endpoints

import bio.ferlab.ferload.endpoints.SecuredEndpoint.baseEndpoint
import bio.ferlab.ferload.model.{ErrorResponse, User}
import bio.ferlab.ferload.services.AuthorizationService
import cats.effect.IO
import sttp.model.StatusCode
import sttp.tapir.*
import sttp.tapir.json.circe.*
import sttp.tapir.server.*
import sttp.tapir.generic.auto._
import io.circe._, io.circe.parser._

case class RawPermissions(rsid: String)

object PermissionsEndpoints:

case class InputPermissions(file_ids: Seq[String])

stringJsonBody.schema(implicitly[Schema[InputPermissions]].as[String])


object ById:

private val byIdEndpoint = baseEndpoint.prependSecurityIn("permissions")

private def allUserPermissions(authorizationService: AuthorizationService): PartialServerEndpoint[String, Seq[String], Unit, (StatusCode, ErrorResponse), List[String], Any, IO] = byIdEndpoint
.post
.securityIn("for-user")
.serverSecurityLogic(token => authorizationService.authLogicAuthorizationForUser(token))
.description("Retrieve all permissions for target user")
.out(jsonBody[List[String]]
.description("List of object id authorized for user")
.example(List("FI1", "FI2")))

private def listPermissions(authorizationService: AuthorizationService): PartialServerEndpoint[(String, io.circe.Json), User, Unit, (StatusCode, ErrorResponse), List[String], Any, IO] = byIdEndpoint
.post
.securityIn("by-list")
.securityIn(jsonBody.description("List of ids of objects to retrieve").example(parse("""{"file_ids":["FI1","FI2"]}""".stripMargin).getOrElse(io.circe.Json.Null)))
.serverSecurityLogic((token, objects) => authorizationService.authLogicAuthorizationForUser(token, objects))
.description("Return list of object Id the user can download from a provided input list")
.out(jsonBody[List[String]]
.description("List of object id authorized for user")
.example(List("FI1", "FI2")))

private def allUserPermissionsServer(authorizationService: AuthorizationService): ServerEndpoint[Any, IO] =
allUserPermissions(authorizationService).serverLogicSuccess { user =>
_ =>
IO(user.toList)

}


private def listPermissionsServer(authorizationService: AuthorizationService): ServerEndpoint[Any, IO] =
listPermissions(authorizationService).serverLogicSuccess { user =>
_ =>
IO(user.permissions.map(_.resource_id).toList)
}

def all(authorizationService: AuthorizationService): Seq[ServerEndpoint[Any, IO]] = List(
listPermissionsServer(authorizationService),
allUserPermissionsServer(authorizationService),
)

Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package bio.ferlab.ferload.services

import bio.ferlab.ferload.AuthConfig
import bio.ferlab.ferload.endpoints.PermissionsEndpoints.InputPermissions
import bio.ferlab.ferload.endpoints.RawPermissions
import bio.ferlab.ferload.model.*
import cats.effect.IO
import com.github.benmanes.caffeine.cache.{AsyncCacheLoader, AsyncLoadingCache, Caffeine, Expiry}
import io.circe.Error
import io.circe.{Error, Json}
import io.circe.generic.auto.*
import sttp.capabilities.fs2.Fs2Streams
import sttp.client3.*
Expand All @@ -13,9 +14,6 @@ import sttp.model
import sttp.model.MediaType.ApplicationXWwwFormUrlencoded
import sttp.model.StatusCode

import java.util.concurrent.Executor
import scala.concurrent.ExecutionContext

/**
* Service used to authorize a user to access resources
*
Expand Down Expand Up @@ -46,6 +44,29 @@ class AuthorizationService(authConfig: AuthConfig, backend: SttpBackend[IO, Fs2S
auth.flatMap(r => IO.fromEither(r.body).map(_.access_token))
}

/**
* Exchange a token for a Request Party Token (RPT) to return the list of user permissions.
*
* @param token the token to exchange
* @return list of user permissions
*/
protected[services] def requestUserPermissions(token: String): IO[Seq[RawPermissions]] = {
val body: Seq[(String, String)] = Seq(
"grant_type" -> "urn:ietf:params:oauth:grant-type:uma-ticket",
"audience" -> authConfig.clientId,
"response_mode" -> "permissions",
)

val auth: IO[Response[Either[ResponseException[String, Error], Seq[RawPermissions]]]] = basicRequest.post(uri"${authConfig.baseUri}/protocol/openid-connect/token")
.auth.bearer(token)
.contentType(ApplicationXWwwFormUrlencoded)
.body(body, "utf-8")
.response(asJson[Seq[RawPermissions]])
.send(backend)

auth.flatMap(r => IO.fromEither(r.body))
}

/**
* Introspect a party token to get the permissions associated with it.
*
Expand Down Expand Up @@ -93,6 +114,63 @@ class AuthorizationService(authConfig: AuthConfig, backend: SttpBackend[IO, Fs2S

}

/**
* Fetch only resources user has access from a provided input list of resources ids. Return a list resources id the user is authorized. Otherwise, return an error.
*
* @param token the token to validate
* @param resources the resources to access
* @return ta list resources id the user is authorized. Otherwise, return errors (Unauthorized, Forbidden, NotFound).
*/
def authLogicAuthorizationForUser(token: String, resources: Json): IO[Either[(StatusCode, ErrorResponse), User]] = {
val parsedResources = resources.as[InputPermissions]

val fileIds = parsedResources match {
case Left(parsingError) =>
throw new IllegalArgumentException(s"Invalid JSON object: ${parsingError.message}")
case Right(json) => json.file_ids
}

val r: IO[User] = for {
partyToken <- requestPartyToken(token, fileIds)
permissionToken <- introspectPartyToken(partyToken)
} yield {
val value: Set[Permissions] = permissionToken.permissions.map(_.toSet).getOrElse(Set.empty)
User(partyToken, value)
}

r.map {
case User(_, permissions) => Right(User(token, permissions))
}
.recover {
case HttpError(_, statusCode) if Seq(StatusCode.Unauthorized, StatusCode.Forbidden).contains(statusCode) => Left((statusCode, ErrorResponse("Unauthorized", statusCode.code))).withRight[User]
case e: HttpError[String] if e.statusCode == StatusCode.BadRequest && e.body.contains("invalid_resource") => Left((StatusCode.NotFound, ErrorResponse("Not Found", 404))).withRight[User]
}

}

/**
* Fetch all resources user has access. Return a list resources id the user is authorized. Otherwise, return an error.
*
* @param token the token to validate
* @return ta list resources id the user is authorized. Otherwise, return errors (Unauthorized, Forbidden, NotFound).
*/
def authLogicAuthorizationForUser(token: String): IO[Either[(StatusCode, ErrorResponse), Seq[String]]] = {
val r: IO[Seq[String]] = for {
userPermissions <- requestUserPermissions(token)
} yield {
userPermissions.map(_.rsid)
}

r.map {
case resource: Seq[String] => Right(resource)
}
.recover {
case HttpError(_, statusCode) if Seq(StatusCode.Unauthorized, StatusCode.Forbidden).contains(statusCode) => Left((statusCode, ErrorResponse("Unauthorized", statusCode.code))).withRight[Seq[String]]
case e: HttpError[String] if e.statusCode == StatusCode.BadRequest && e.body.contains("invalid_resource") => Left((StatusCode.NotFound, ErrorResponse("Not Found", 404))).withRight[Seq[String]]
}

}

/**
* Verifies if the permissions contains all the resources.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import bio.ferlab.ferload.AuthConfig
import bio.ferlab.ferload.unwrap
import bio.ferlab.ferload.model.{ErrorResponse, IntrospectResponse, Permissions, User}
import cats.effect.IO
import io.circe.Json
import io.circe.parser.parse
import org.scalatest.EitherValues
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
Expand Down Expand Up @@ -236,5 +238,103 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu
authorizationService.authLogic("token", Seq("F1")).unwrap.left.value shouldBe(StatusCode.Unauthorized, ErrorResponse("Unauthorized", 401))
}

"authLogicAuthorizationForUser" should "return a User" in {
val testingBackend = new RecordingSttpBackend(Http4sBackend.stub[IO]
.whenRequestMatches(r => r.uri.path == Seq("realms", "realm", "protocol", "openid-connect", "token"))
.thenRespond(""" {"access_token": "E123456", "expires_in": 65, "refresh_expires_in": 0, "token_type" : "bearer"} """)
.whenRequestMatches(r => r.uri.path.contains("introspect"))
.thenRespond(
""" {
| "active": true,
| "exp": 65,
| "iat": 20,
| "aud" : "cqdg",
| "nbf": 4,
| "permissions" : [
| {
| "resource_id": "F1",
| "rsname": "F1",
| "resource_scopes": ["Scope1", "Scope2"]
| }
| ]
|} """.stripMargin)
)

val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None)

val authorizationService = new AuthorizationService(authConfig, testingBackend)

val inputJson = parse("""{"file_ids":["FI1"]}""".stripMargin).getOrElse(Json.Null)

authorizationService.authLogicAuthorizationForUser("token", inputJson).unwrap.value shouldBe User("token", Set(Permissions("F1", Some("F1"), Seq("Scope1", "Scope2"))))
}

it should "return only resources ids the user has access" in {
val testingBackend = new RecordingSttpBackend(Http4sBackend.stub[IO]
.whenRequestMatches(r => {
r.uri.path == Seq("realms", "realm", "protocol", "openid-connect", "token")
})
.thenRespond(""" {"access_token": "E123456", "expires_in": 65, "refresh_expires_in": 0, "token_type" : "bearer"} """)
.whenRequestMatches(r => r.uri.path.contains("introspect"))
.thenRespond(
""" {
| "active": true,
| "exp": 65,
| "iat": 20,
| "aud" : "cqdg",
| "nbf": 4,
| "permissions" : [
| {
| "resource_id": "F1",
| "rsname": "F1",
| "resource_scopes": ["Scope1", "Scope2"]
| }
| ]
|} """.stripMargin)
)

val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None)

val authorizationService = new AuthorizationService(authConfig, testingBackend)

val inputJson = parse("""{"file_ids":["FI1","F2"]}""".stripMargin).getOrElse(Json.Null)

authorizationService.authLogicAuthorizationForUser("token", inputJson).unwrap.value shouldBe User("token", Set(Permissions("F1", Some("F1"), List("Scope1", "Scope2"))))
}

it should "return unauthorized if bearer token is not valid" in {
val testingBackend = new RecordingSttpBackend(Http4sBackend.stub[IO]
.whenRequestMatches(r => {
r.uri.path == Seq("realms", "realm", "protocol", "openid-connect", "token")
})
.thenRespond(
""" {
| "error": "invalid_grant",
| "error_description": "Invalid bearer token"
|} """.stripMargin, StatusCode.Unauthorized)
.whenRequestMatches(r => r.uri.path.contains("introspect"))
.thenRespond(
""" {
| "active": true,
| "exp": 65,
| "iat": 20,
| "aud" : "cqdg",
| "nbf": 4,
| "permissions" : [
| {
| "resource_id": "F1",
| "rsname": "F1",
| "resource_scopes": ["Scope1", "Scope2"]
| }
| ]
|} """.stripMargin)
)
val authConfig = AuthConfig("http://stub.local", "realm", "clientId", "clientSecret", None, None)

val authorizationService = new AuthorizationService(authConfig, testingBackend)

val inputJson = parse("""{"file_ids":["FI1"]}""".stripMargin).getOrElse(Json.Null)

authorizationService.authLogicAuthorizationForUser("token", inputJson).unwrap.left.value shouldBe(StatusCode.Unauthorized, ErrorResponse("Unauthorized", 401))
}
}

0 comments on commit f780a35

Please sign in to comment.