From 5fcc3d79104e28d397b2c2017804ba46aaa2a2d8 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 10 Jun 2024 15:30:43 -0400 Subject: [PATCH 1/3] fix: CQDG-765 authorized files list --- .../ferlab/ferload/endpoints/Endpoints.scala | 3 +- .../endpoints/PermissionsEndpoints.scala | 59 ++++++++++++++ .../services/AuthorizationService.scala | 77 ++++++++++++++++++- 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala diff --git a/src/main/scala/bio/ferlab/ferload/endpoints/Endpoints.scala b/src/main/scala/bio/ferlab/ferload/endpoints/Endpoints.scala index e5ff0da..d7199ee 100644 --- a/src/main/scala/bio/ferlab/ferload/endpoints/Endpoints.scala +++ b/src/main/scala/bio/ferlab/ferload/endpoints/Endpoints.scala @@ -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.* @@ -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) diff --git a/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala b/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala new file mode 100644 index 0000000..43c2c8f --- /dev/null +++ b/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala @@ -0,0 +1,59 @@ +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.CodecFormat.Json +import sttp.tapir.json.circe.* +import sttp.tapir.server.* + +case class RawPermissions(rsid: String) + +object PermissionsEndpoints: + + + 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, String), User, Unit, (StatusCode, ErrorResponse), List[String], Any, IO] = byIdEndpoint + .post + .securityIn("by-list") + .securityIn(stringBody.description("List of ids of objects to retrieve").example("FI1,FI2")) + .serverSecurityLogic((token, objects) => authorizationService.authLogicAuthorizationForUser(token, objects.split(","))) + .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), + ) + diff --git a/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala b/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala index 5c0ce81..cde8d26 100644 --- a/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala +++ b/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala @@ -1,9 +1,9 @@ package bio.ferlab.ferload.services import bio.ferlab.ferload.AuthConfig +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.generic.auto.* import sttp.capabilities.fs2.Fs2Streams @@ -13,9 +13,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 * @@ -46,6 +43,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. * @@ -93,6 +113,55 @@ 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: Seq[String]): IO[Either[(StatusCode, ErrorResponse), User]] = { + val r: IO[User] = for { + partyToken <- requestPartyToken(token, resources) + 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. * From cce717a87a8b236815857a02236bd47d473133b6 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 11 Jun 2024 08:51:25 -0400 Subject: [PATCH 2/3] fix: CQDG-764 add tests --- .../services/AuthorizationService.scala | 2 +- .../services/AuthorizationServiceSpec.scala | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala b/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala index cde8d26..cec3a95 100644 --- a/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala +++ b/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala @@ -47,7 +47,7 @@ class AuthorizationService(authConfig: AuthConfig, backend: SttpBackend[IO, Fs2S * 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 + * @return list of user permissions */ protected[services] def requestUserPermissions(token: String): IO[Seq[RawPermissions]] = { val body: Seq[(String, String)] = Seq( diff --git a/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala b/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala index 0c426e1..f5294d2 100644 --- a/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala +++ b/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala @@ -236,5 +236,97 @@ 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) + + authorizationService.authLogicAuthorizationForUser("token", Seq("F1")).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) + + authorizationService.authLogicAuthorizationForUser("token", Seq("F1", "F2")).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) + + authorizationService.authLogicAuthorizationForUser("token", Seq("F1")).unwrap.left.value shouldBe(StatusCode.Unauthorized, ErrorResponse("Unauthorized", 401)) + } } From e331f6f388bad293ce6434b4dfe6e8b47af179a7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 11 Jun 2024 13:30:21 -0400 Subject: [PATCH 3/3] fix: CQDG-764 comments from Aymeric --- .../ferload/endpoints/PermissionsEndpoints.scala | 13 +++++++++---- .../ferload/services/AuthorizationService.scala | 15 ++++++++++++--- .../services/AuthorizationServiceSpec.scala | 14 +++++++++++--- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala b/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala index 43c2c8f..b558c64 100644 --- a/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala +++ b/src/main/scala/bio/ferlab/ferload/endpoints/PermissionsEndpoints.scala @@ -6,14 +6,19 @@ import bio.ferlab.ferload.services.AuthorizationService import cats.effect.IO import sttp.model.StatusCode import sttp.tapir.* -import sttp.tapir.CodecFormat.Json 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: @@ -28,11 +33,11 @@ object PermissionsEndpoints: .description("List of object id authorized for user") .example(List("FI1", "FI2"))) - private def listPermissions(authorizationService: AuthorizationService): PartialServerEndpoint[(String, String), User, Unit, (StatusCode, ErrorResponse), List[String], Any, IO] = byIdEndpoint + private def listPermissions(authorizationService: AuthorizationService): PartialServerEndpoint[(String, io.circe.Json), User, Unit, (StatusCode, ErrorResponse), List[String], Any, IO] = byIdEndpoint .post .securityIn("by-list") - .securityIn(stringBody.description("List of ids of objects to retrieve").example("FI1,FI2")) - .serverSecurityLogic((token, objects) => authorizationService.authLogicAuthorizationForUser(token, objects.split(","))) + .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") diff --git a/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala b/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala index cec3a95..9e9038e 100644 --- a/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala +++ b/src/main/scala/bio/ferlab/ferload/services/AuthorizationService.scala @@ -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 io.circe.Error +import io.circe.{Error, Json} import io.circe.generic.auto.* import sttp.capabilities.fs2.Fs2Streams import sttp.client3.* @@ -120,9 +121,17 @@ class AuthorizationService(authConfig: AuthConfig, backend: SttpBackend[IO, Fs2S * @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: Seq[String]): IO[Either[(StatusCode, ErrorResponse), User]] = { + 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, resources) + partyToken <- requestPartyToken(token, fileIds) permissionToken <- introspectPartyToken(partyToken) } yield { val value: Set[Permissions] = permissionToken.permissions.map(_.toSet).getOrElse(Set.empty) diff --git a/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala b/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala index f5294d2..6d5925c 100644 --- a/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala +++ b/src/test/scala/bio/ferlab/ferload/services/AuthorizationServiceSpec.scala @@ -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 @@ -262,7 +264,9 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu val authorizationService = new AuthorizationService(authConfig, testingBackend) - authorizationService.authLogicAuthorizationForUser("token", Seq("F1")).unwrap.value shouldBe User("token", Set(Permissions("F1", Some("F1"), Seq("Scope1", "Scope2")))) + 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 { @@ -293,7 +297,9 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu val authorizationService = new AuthorizationService(authConfig, testingBackend) - authorizationService.authLogicAuthorizationForUser("token", Seq("F1", "F2")).unwrap.value shouldBe User("token", Set(Permissions("F1", Some("F1"), List("Scope1", "Scope2")))) + 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 { @@ -327,6 +333,8 @@ class AuthorizationServiceSpec extends AnyFlatSpec with Matchers with EitherValu val authorizationService = new AuthorizationService(authConfig, testingBackend) - authorizationService.authLogicAuthorizationForUser("token", Seq("F1")).unwrap.left.value shouldBe(StatusCode.Unauthorized, ErrorResponse("Unauthorized", 401)) + val inputJson = parse("""{"file_ids":["FI1"]}""".stripMargin).getOrElse(Json.Null) + + authorizationService.authLogicAuthorizationForUser("token", inputJson).unwrap.left.value shouldBe(StatusCode.Unauthorized, ErrorResponse("Unauthorized", 401)) } }