Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add proxy pass route for id resolution #4356

Merged
merged 9 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -271,9 +271,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 @@ -36,6 +36,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 @@ -110,6 +115,20 @@ class IdResolutionSpec extends BaseSpec {

"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.status shouldEqual StatusCodes.SeeOther
locationHeaderOf(response) shouldEqual 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.status shouldEqual StatusCodes.SeeOther
locationHeaderOf(response) shouldEqual fusionResolveEndpoint(encodedNeurosciencegraphId)
}(PredefinedFromEntityUnmarshallers.stringUnmarshaller)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dream assertion would be

response shouldBe redirectTo(fusionResolveEndpoint(encodedNeurosciencegraphId))

It would be really nice to get rid of this boilerplate too but I don't know how: (PredefinedFromEntityUnmarshallers.stringUnmarshaller)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but with this option we'd be matching on the response body which is not really relevant


}

private def locationHeaderOf(response: HttpResponse) =
Expand All @@ -118,5 +137,9 @@ class IdResolutionSpec extends BaseSpec {
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", ":")
Comment on lines +143 to +146
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be URL encoded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow why it should be encoded? I don't think the location in a redirect needs to be encoded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the last segment is encoded because it needs to be a single segment representing the resource ID, and that can contain /. Similar to how the id needs to be URL encoded when fetching resources, etc.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if other unpredictable things could happen if url characters are in the ID


}