Skip to content

Commit

Permalink
Add proxy pass route for id resolution (#4356)
Browse files Browse the repository at this point in the history
  • Loading branch information
olivergrabinski authored Oct 13, 2023
1 parent 725afec commit cb4b6b1
Show file tree
Hide file tree
Showing 14 changed files with 162 additions and 16 deletions.
2 changes: 2 additions & 0 deletions delta/app/src/main/resources/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ app {
# Allows to return a redirection when fetching a resource with the `Accept` header
# set to `text/html`
enable-redirects = false
# base to use to reconstruct resource identifiers in the proxy pass
resolve-base = "http://localhost:8081"
}

# Json-LD Api configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,10 @@ class ElasticSearchPluginModule(priority: Int) extends ModuleDef {
s: Scheduler,
ordering: JsonKeyOrdering,
rcr: RemoteContextResolution @Id("aggregate"),
fusionConfig: FusionConfig
fusionConfig: FusionConfig,
baseUri: BaseUri
) =>
new IdResolutionRoutes(identities, aclCheck, idResolution)(
new IdResolutionRoutes(identities, aclCheck, idResolution, baseUri)(
s,
ordering,
rcr,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes

import akka.http.scaladsl.model.Uri
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolutionResponse.{MultipleResults, SingleResult}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.ElasticSearchQueryError
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.{IdResolution, IdResolutionResponse}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution
import ch.epfl.bluebrain.nexus.delta.rdf.syntax.uriSyntax
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._
import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig
import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities
import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri
import monix.bio.{IO, UIO}
import monix.execution.Scheduler

class IdResolutionRoutes(
identities: Identities,
aclCheck: AclCheck,
idResolution: IdResolution
idResolution: IdResolution,
baseUri: BaseUri
)(implicit s: Scheduler, jko: JsonKeyOrdering, rcr: RemoteContextResolution, fusionConfig: FusionConfig)
extends AuthDirectives(identities, aclCheck) {

def routes: Route =
def routes: Route = concat(resolutionRoute, proxyRoute)

private def resolutionRoute: Route =
pathPrefix("resolve") {
extractCaller { implicit caller =>
(get & iriSegment & pathEndOrSingleSlash) { iri =>
Expand All @@ -37,6 +42,19 @@ class IdResolutionRoutes(
}
}

private def proxyRoute: Route =
pathPrefix("resolve-proxy-pass") {
extractUnmatchedPath { path =>
get {
val resourceId = fusionConfig.resolveBase / path
emitOrFusionRedirect(
fusionResolveUri(resourceId),
redirect(deltaResolveEndpoint(resourceId), StatusCodes.SeeOther)
)
}
}
}

private def fusionUri(
resolved: IO[ElasticSearchQueryError, IdResolutionResponse.Result]
): UIO[Uri] =
Expand All @@ -47,4 +65,7 @@ class IdResolutionRoutes(
}
.onErrorHandleWith { _ => fusionLoginUri }

private def deltaResolveEndpoint(id: Uri): Uri =
baseUri.endpoint / "resolve" / id.toString

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes

import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.model.headers.Location
import akka.http.scaladsl.model.{HttpResponse, StatusCodes}
import akka.http.scaladsl.server.Route
import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolution
Expand Down Expand Up @@ -43,7 +44,8 @@ class IdResolutionRoutesSuite extends ElasticSearchViewsRoutesFixtures {
}

private val idResolution = new IdResolution(dummyDefaultViewsQuery, fetchResource)
private val route = Route.seal(new IdResolutionRoutes(identities, aclCheck, idResolution).routes)
private val route =
Route.seal(new IdResolutionRoutes(identities, aclCheck, idResolution, baseUri).routes)

"The IdResolution route" should {

Expand All @@ -54,6 +56,21 @@ class IdResolutionRoutesSuite extends ElasticSearchViewsRoutesFixtures {
}
}

"redirect the proxy call to the resolve endpoint" in {
val segment = s"neurosciencegraph/data/$uuid"
val fullId = s"https://bbp.epfl.ch/$segment"
val expectedRedirection = s"$baseUri/resolve/${UrlUtils.encode(fullId)}".replace("%3A", ":")

Get(s"/resolve-proxy-pass/$segment") ~> route ~> check {
response.status shouldEqual StatusCodes.SeeOther
response.locationHeader shouldEqual expectedRedirection
}
}

}

implicit class HeaderOps(response: HttpResponse) {
def locationHeader: String = response.header[Location].value.uri.toString()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ trait DeltaDirectives extends UriDirectives {
def fusionLoginUri(implicit config: FusionConfig): UIO[Uri] =
UIO.pure { config.base / "login" }

/** The URI of fusion's id resolution endpoint */
def fusionResolveUri(id: Uri)(implicit config: FusionConfig): UIO[Uri] =
UIO.pure { config.base / "resolve" / id.toString }

/** Injects a `Vary: Accept,Accept-Encoding` into the response */
def varyAcceptHeaders: Directive0 =
vary(Set(Accept.name, `Accept-Encoding`.name))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import pureconfig.generic.semiauto.deriveReader
* the base url of fusion
* @param enableRedirects
* enables redirections to Fusion if the `Accept` header is set to `text/html`
* @param resolveBase
* base to use to reconstruct resource identifiers in the resolve proxy pass
*/
final case class FusionConfig(base: Uri, enableRedirects: Boolean)
final case class FusionConfig(base: Uri, enableRedirects: Boolean, resolveBase: Uri)

object FusionConfig {
implicit final val fusionConfigReader: ConfigReader[FusionConfig] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ trait ConfigFixtures {
def httpClientConfig: HttpClientConfig =
HttpClientConfig(RetryStrategyConfig.AlwaysGiveUp, HttpClientWorthRetry.never, false)

def fusionConfig: FusionConfig = FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true)
def fusionConfig: FusionConfig =
FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true, Uri("https://bbp.epfl.ch"))

def deletionConfig: ProjectsConfig.DeletionConfig = ProjectsConfig.DeletionConfig(
enabled = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ class DeltaDirectivesSpec
List("@context", "@id", "@type", "reason", "details", "sourceId", "projectionId", "_total", "_results")
)

implicit private val f: FusionConfig = FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true)
implicit private val f: FusionConfig =
FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true, Uri("https://bbp.epfl.ch"))

implicit val baseUri: BaseUri = BaseUri("http://localhost", Label.unsafe("v1"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ trait RouteFixtures {

implicit val baseUri: BaseUri = BaseUri("http://localhost", Label.unsafe("v1"))
implicit val paginationConfig: PaginationConfig = PaginationConfig(5, 10, 5)
implicit val f: FusionConfig = FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true)
implicit val f: FusionConfig =
FusionConfig(Uri("https://bbp.epfl.ch/nexus/web/"), enableRedirects = true, Uri("https://bbp.epfl.ch"))
implicit val s: Scheduler = Scheduler.global
implicit val rejectionHandler: RejectionHandler = RdfRejectionHandler.apply
implicit val exceptionHandler: ExceptionHandler = RdfExceptionHandler.apply
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
curl "http://localhost:8080/v1/resolve-proxy-pass/nexus/data/identifier"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
303 See Other
The response to the request can be found under <a href="https://localhost:8080/v1/resolve/https:%2F%2Fexample.com%2Fnexus%2Fdata%2Fidentifier">this URI</a> using a
GET method.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
303 See Other
The response to the request can be found under <a href="http://localhost:8080/fusion/resolve/https:%2F%2Fexample.com%2Fnexus%2Fdata%2Fidentifier">this URI</a> using a
GET method.
63 changes: 63 additions & 0 deletions docs/src/main/paradox/docs/delta/api/id-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,66 @@ Response (single resource)
Response (multiple choices)
: @@snip [response.json](assets/id-resolution/multiple-resolution-response.json)

### Fusion Redirection

When querying the resolve endpoint, it is possible to add the `Accept: text/html` header in order for Nexus Delta to
redirect to the appropriate Nexus Fusion page.

## Resolve (Proxy Pass)

@@@ note { .warning }

This endpoint is designed for the Nexus deployment at the Blue Brain Project and as such might not suit the needs of
other deployments.

@@@

The @ref:[resolve](#resolve) endpoint offers resource resolution on the resources the caller has access to. As such, if
the client is a browser and does not have the ability to include an authorization header in the request, it is possible
to use the proxy pass version of the resolve endpoint which will lead the client to a Nexus Fusion authentication page.
This will allow to "inject" the user's token in a subsequent @ref:[resolve](#resolve) request made by Nexus Fusion.

### Configuration

* `app.fusion.base`: String - base URL for Nexus Fusion
* `app.fusion.enable-redirects`: Boolean - needs to be `true` in order for redirects to work (defaults to `false`)
* `app.fusion.resolve-base`: String - base URL to use when reconstructing the resource identifier in the
proxy pass endpoint

### Redirection

1. The client calls `/v1/resolve-proxy-pass/{segment}`
2. Nexus Delta reconstructs the resource identifier
* `{resourceId}` = `{resolveBase}/{segment}`
3. Nexus Delta redirects the client to...
* the `{fusionBaseUrl}/resolve/{resourceId}` Fusion endpoint if the `Accept: text/html` header is present
* the `/v1/resolve/{resourceId}` Delta endpoint otherwise

The Nexus Fusion resolve page allows the user to authenticate (if they are not already authenticated) and will perform a
call to the Nexus Delta `/v1/resolve/{resourceId}` with the user's authentication token.

All calls to the `/v1/resolve-proxy-pass` endpoint lead to
@link:[303 See Other responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303).

### Example

The example below assumes that:

* `{fusionBaseUrl}` = `http://localhost:8080/fusion`
* `{segment}` = `nexus/data/identifier`
* `{resolveBase}` = `https://example.com`

Request
: @@snip [request.sh](assets/id-resolution/proxy-request.sh)

Redirect (when `Acccept:text/html` is provided)
: @@snip [response.json](assets/id-resolution/proxy-response-fusion.html)

Redirect (no `Accept:text/html` provided)
: @@snip [response.json](assets/id-resolution/proxy-response-delta.html)

#### Remark

In your networking setup, if a proxy pass is enabled to map `https://example.com/nexus/data/*`
to `https://localhost:8080/v1/resolve-proxy-pass/nexus/data/*`, the proxy pass allows to de facto resolve resource with
identifier of the type `https://example.com/nexus/data/*` by simply querying their `@id`.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ch.epfl.bluebrain.nexus.tests.BaseSpec
import ch.epfl.bluebrain.nexus.tests.Identity.listings.{Alice, Bob}
import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.Organizations
import io.circe.Json
import org.scalatest.Assertion

class IdResolutionSpec extends BaseSpec {

Expand All @@ -36,6 +37,11 @@ class IdResolutionSpec extends BaseSpec {
private val uniqueResourcePayload = resource(uniqueId)
private val reusedResourcePayload = resource(reusedId)

private val neurosciencegraphSegment = "neurosciencegraph/data/segment"
private val proxyIdBase = "http://localhost:8081"
private val neurosciencegraphId = s"$proxyIdBase/$neurosciencegraphSegment"
private val encodedNeurosciencegraphId = UrlUtils.encode(neurosciencegraphId)

private val unauthorizedAccessErrorPayload =
jsonContentOf("iam/errors/unauthorized-access.json")

Expand Down Expand Up @@ -93,30 +99,50 @@ class IdResolutionSpec extends BaseSpec {
}

"redirect to fusion login when if text/html accept header is present (no results)" in {
val expectedRedirectUrl = "https://bbp.epfl.ch/nexus/web/login"
val fusionLoginPage = "https://bbp.epfl.ch/nexus/web/login"

deltaClient.get[String]("/resolve/unknownId", Bob, acceptTextHtml) { (_, response) =>
response.status shouldEqual StatusCodes.SeeOther
locationHeaderOf(response) shouldEqual expectedRedirectUrl
response isRedirectTo fusionLoginPage
}(PredefinedFromEntityUnmarshallers.stringUnmarshaller)
}

"redirect to fusion resource page if text/html accept header is present (single result)" in {
deltaClient.get[String](s"/resolve/$encodedUniqueId", Bob, acceptTextHtml) { (_, response) =>
response.status shouldEqual StatusCodes.SeeOther
locationHeaderOf(response) shouldEqual fusionResourcePageFor(encodedUniqueId)
response isRedirectTo fusionResourcePageFor(encodedUniqueId)
}(PredefinedFromEntityUnmarshallers.stringUnmarshaller)
}

"redirect to fusion resource selection page if text/html accept header is present (multiple result)" in { pending }

"redirect to delta resolve if the request comes to the proxy endpoint" in {
deltaClient.get[String](s"/resolve-proxy-pass/$neurosciencegraphSegment", Bob) { (_, response) =>
response isRedirectTo deltaResolveEndpoint(encodedNeurosciencegraphId)
}(PredefinedFromEntityUnmarshallers.stringUnmarshaller)
}

"redirect to fusion resolve if the request comes to the proxy endpoint with text/html accept header is present" in {
deltaClient.get[String](s"/resolve-proxy-pass/$neurosciencegraphSegment", Bob, acceptTextHtml) { (_, response) =>
response isRedirectTo fusionResolveEndpoint(encodedNeurosciencegraphId)
}(PredefinedFromEntityUnmarshallers.stringUnmarshaller)
}

}

implicit class HttpResponseOps(response: HttpResponse) {
def isRedirectTo(uri: String): Assertion = {
response.status shouldEqual StatusCodes.SeeOther
locationHeaderOf(response) shouldEqual uri
}
}
private def locationHeaderOf(response: HttpResponse) =
response.header[Location].value.uri.toString()
private def acceptTextHtml =
List(Accept(MediaRange.One(`text/html`, 1f)))
private def fusionResourcePageFor(encodedId: String) =
s"https://bbp.epfl.ch/nexus/web/$ref11/resources/$encodedId".replace("%3A", ":")
private def fusionResolveEndpoint(encodedId: String) =
s"https://bbp.epfl.ch/nexus/web/resolve/$encodedId".replace("%3A", ":")
private def deltaResolveEndpoint(encodedId: String) =
s"http://delta:8080/v1/resolve/$encodedId".replace("%3A", ":")

}

0 comments on commit cb4b6b1

Please sign in to comment.