diff --git a/delta/plugins/storage/src/main/resources/storage.conf b/delta/plugins/storage/src/main/resources/storage.conf index ed404f3431..6f159cfd84 100644 --- a/delta/plugins/storage/src/main/resources/storage.conf +++ b/delta/plugins/storage/src/main/resources/storage.conf @@ -52,7 +52,9 @@ plugins.storage { # the default endpoint default-endpoint = "http://localhost:8084/v1" # the default credentials for the endpoint - default-credentials = null + credentials { + type: "anonymous" + } # the default digest algorithm digest-algorithm = "SHA-256" # the default permission required in order to download a file from a remote disk storage @@ -61,7 +63,7 @@ plugins.storage { default-write-permission = "files/write" # flag to decide whether or not to show the absolute location of the files in the metadata response show-location = true - # the default maximum allowed file size (in bytes) for uploaded files. 10 GB + # the default maximum allowed file size (in bytes) for uploaded files. 10 GB default-max-file-size = 10737418240 # Retry strategy for digest computation digest-computation = ${app.defaults.retry-strategy} diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala index b4a4b4f53a..768939edc3 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/StoragePluginModule.scala @@ -15,7 +15,6 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.Sto import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.contexts.{storages => storageCtxId, storagesMetadata => storageMetaCtxId} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model._ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.StorageAccess -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.AuthTokenProvider import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.routes.StoragesRoutes import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.schemas.{storage => storagesSchemaId} @@ -25,6 +24,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteCon import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.sdk._ import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials, OpenIdAuthService} import ch.epfl.bluebrain.nexus.delta.sdk.deletion.ProjectDeletionTask import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaSchemeDirectives import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig @@ -37,6 +37,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContext import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContext.ContextRejection import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ApiMappings +import ch.epfl.bluebrain.nexus.delta.sdk.realms.Realms import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors @@ -146,8 +147,12 @@ class StoragePluginModule(priority: Int) extends ModuleDef { many[ResourceShift[_, _, _]].ref[Storage.Shift] - make[AuthTokenProvider].from { (cfg: StorageTypeConfig) => - AuthTokenProvider(cfg) + make[OpenIdAuthService].from { (httpClient: HttpClient @Id("realm"), realms: Realms) => + new OpenIdAuthService(httpClient, realms) + } + + make[AuthTokenProvider].fromEffect { (authService: OpenIdAuthService) => + AuthTokenProvider(authService) } make[Files] @@ -226,8 +231,14 @@ class StoragePluginModule(priority: Int) extends ModuleDef { ( client: HttpClient @Id("storage"), as: ActorSystem[Nothing], - authTokenProvider: AuthTokenProvider - ) => new RemoteDiskStorageClient(client, authTokenProvider)(as.classicSystem) + authTokenProvider: AuthTokenProvider, + cfg: StorageTypeConfig + ) => + new RemoteDiskStorageClient( + client, + authTokenProvider, + cfg.remoteDisk.map(_.credentials).getOrElse(Credentials.Anonymous) + )(as.classicSystem) } many[ServiceDependency].addSet { diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala index 25b333f454..13dcd2e3d6 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragesConfig.scala @@ -5,6 +5,7 @@ import cats.implicits.toBifunctorOps import ch.epfl.bluebrain.nexus.delta.kernel.{RetryStrategyConfig, Secret} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.StorageTypeConfig import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{AbsolutePath, DigestAlgorithm, StorageType} +import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.model.search.PaginationConfig import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission @@ -198,7 +199,7 @@ object StoragesConfig { final case class RemoteDiskStorageConfig( digestAlgorithm: DigestAlgorithm, defaultEndpoint: BaseUri, - defaultCredentials: Option[Secret[String]], + credentials: Credentials, defaultReadPermission: Permission, defaultWritePermission: Permission, showLocation: Boolean, diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/AuthTokenProvider.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/AuthTokenProvider.scala deleted file mode 100644 index 248dab973d..0000000000 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/AuthTokenProvider.scala +++ /dev/null @@ -1,26 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote - -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.StorageTypeConfig -import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.AuthToken -import monix.bio.UIO - -/** - * Provides an auth token for the service account, for use when comunicating with remote storage - */ -trait AuthTokenProvider { - def apply(): UIO[Option[AuthToken]] -} - -object AuthTokenProvider { - def apply(config: StorageTypeConfig): AuthTokenProvider = new AuthTokenProvider { - override def apply(): UIO[Option[AuthToken]] = - UIO.pure(config.remoteDisk.flatMap(_.defaultCredentials).map(secret => AuthToken(secret.value))) - } - def test(fixed: Option[AuthToken]): AuthTokenProvider = new AuthTokenProvider { - override def apply(): UIO[Option[AuthToken]] = UIO.pure(fixed) - } - - def test(implicit config: StorageTypeConfig): AuthTokenProvider = { - test(config.remoteDisk.flatMap(_.defaultCredentials).map(secret => AuthToken(secret.value))) - } -} diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteDiskStorageClient.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteDiskStorageClient.scala index 1416422de8..02f3faa8a1 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteDiskStorageClient.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteDiskStorageClient.scala @@ -10,10 +10,10 @@ import akka.http.scaladsl.model.Uri.Path import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.StorageFileRejection.FetchFileRejection.UnexpectedFetchError import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.StorageFileRejection.MoveFileRejection.UnexpectedMoveError import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.StorageFileRejection.{FetchFileRejection, MoveFileRejection, SaveFileRejection} -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.AuthTokenProvider import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.model.RemoteDiskStorageFileAttributes import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords import ch.epfl.bluebrain.nexus.delta.sdk.AkkaSource +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceMarshalling._ import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError._ import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClient, HttpClientError} @@ -32,8 +32,8 @@ import scala.concurrent.duration._ /** * The client to communicate with the remote storage service */ -final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenProvider)(implicit - as: ActorSystem +final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenProvider, credentials: Credentials)( + implicit as: ActorSystem ) { import as.dispatcher @@ -58,7 +58,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP * the storage bucket name */ def exists(bucket: Label)(implicit baseUri: BaseUri): IO[HttpClientError, Unit] = { - getAuthToken().flatMap { authToken => + getAuthToken(credentials).flatMap { authToken => val endpoint = baseUri.endpoint / "buckets" / bucket.value val req = Head(endpoint).withCredentials(authToken) client(req) { @@ -82,7 +82,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP relativePath: Path, entity: BodyPartEntity )(implicit baseUri: BaseUri): IO[SaveFileRejection, RemoteDiskStorageFileAttributes] = { - getAuthToken().flatMap { authToken => + getAuthToken(credentials).flatMap { authToken => val endpoint = baseUri.endpoint / "buckets" / bucket.value / "files" / relativePath val filename = relativePath.lastSegment.getOrElse("filename") val multipartForm = FormData(BodyPart("file", entity, Map("filename" -> filename))).toEntity() @@ -106,7 +106,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP * the relative path to the file location */ def getFile(bucket: Label, relativePath: Path)(implicit baseUri: BaseUri): IO[FetchFileRejection, AkkaSource] = { - getAuthToken().flatMap { authToken => + getAuthToken(credentials).flatMap { authToken => val endpoint = baseUri.endpoint / "buckets" / bucket.value / "files" / relativePath client.toDataBytes(Get(endpoint).withCredentials(authToken)).mapError { case error @ HttpClientStatusError(_, `NotFound`, _) if !bucketNotFoundType(error) => @@ -129,7 +129,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP bucket: Label, relativePath: Path )(implicit baseUri: BaseUri): IO[FetchFileRejection, RemoteDiskStorageFileAttributes] = { - getAuthToken().flatMap { authToken => + getAuthToken(credentials).flatMap { authToken => val endpoint = baseUri.endpoint / "buckets" / bucket.value / "attributes" / relativePath client.fromJsonTo[RemoteDiskStorageFileAttributes](Get(endpoint).withCredentials(authToken)).mapError { case error @ HttpClientStatusError(_, `NotFound`, _) if !bucketNotFoundType(error) => @@ -156,7 +156,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP sourceRelativePath: Path, destRelativePath: Path )(implicit baseUri: BaseUri): IO[MoveFileRejection, RemoteDiskStorageFileAttributes] = { - getAuthToken().flatMap { authToken => + getAuthToken(credentials).flatMap { authToken => val endpoint = baseUri.endpoint / "buckets" / bucket.value / "files" / destRelativePath val payload = Json.obj("source" -> sourceRelativePath.toString.asJson) client.fromJsonTo[RemoteDiskStorageFileAttributes](Put(endpoint, payload).withCredentials(authToken)).mapError { diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala index 60da88be89..9b01a64594 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/FilesSpec.scala @@ -14,13 +14,13 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.StorageRejec import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.StorageType.{RemoteDiskStorage => RemoteStorageType} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{StorageRejection, StorageStatEntry} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.AkkaSourceHelpers -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.AuthTokenProvider import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.{StorageFixtures, Storages, StoragesConfig, StoragesStatistics} import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.directives.FileResponse import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.{Caller, ServiceAccount} @@ -69,8 +69,8 @@ class FilesSpec(docker: RemoteStorageDocker) implicit val typedSystem: typed.ActorSystem[Nothing] = system.toTyped implicit val httpClient: HttpClient = HttpClient()(httpClientConfig, system, sc) implicit val caller: Caller = Caller(bob, Set(bob, Group("mygroup", realm), Authenticated(realm))) - implicit val authTokenProvider: AuthTokenProvider = AuthTokenProvider.test - val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider) + implicit val authTokenProvider: AuthTokenProvider = AuthTokenProvider.anonymousForTest + val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider, Credentials.Anonymous) val tag = UserTag.unsafe("tag") val otherRead = Permission.unsafe("other/read") diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala index bb76ca313a..b22a4106d8 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/files/routes/FilesRoutesSpec.scala @@ -12,7 +12,6 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.{FileAttributes import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.routes.FilesRoutesSpec.fileMetadata import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.{contexts => fileContexts, permissions, FileFixtures, Files, FilesConfig} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{StorageRejection, StorageStatEntry, StorageType} -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.AuthTokenProvider import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.{contexts => storageContexts, permissions => storagesPermissions, StorageFixtures, Storages, StoragesConfig, StoragesStatistics} import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri @@ -23,6 +22,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteCon import ch.epfl.bluebrain.nexus.delta.sdk.IndexingAction import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaSchemeDirectives import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesDummy @@ -53,8 +53,8 @@ class FilesRoutesSpec import akka.actor.typed.scaladsl.adapter._ implicit val typedSystem: typed.ActorSystem[Nothing] = system.toTyped val httpClient: HttpClient = HttpClient()(httpClientConfig, system, s) - val authTokenProvider: AuthTokenProvider = AuthTokenProvider.test - val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider) + val authTokenProvider: AuthTokenProvider = AuthTokenProvider.anonymousForTest + val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider, Credentials.Anonymous) // TODO: sort out how we handle this in tests implicit override def rcr: RemoteContextResolution = diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala index e95661694d..22b27b659d 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StorageFixtures.scala @@ -5,6 +5,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.StoragesConfig.{Di import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.StorageFields.{DiskStorageFields, RemoteDiskStorageFields, S3StorageFields} import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.{AbsolutePath, DigestAlgorithm} import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv +import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials.Anonymous import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ @@ -29,7 +30,7 @@ trait StorageFixtures extends TestHelpers with CirceLiteral { implicit val config: StorageTypeConfig = StorageTypeConfig( disk = DiskStorageConfig(diskVolume, Set(diskVolume,tmpVolume), DigestAlgorithm.default, permissions.read, permissions.write, showLocation = false, Some(5000), 50), amazon = Some(S3StorageConfig(DigestAlgorithm.default, Some("localhost"), Some(Secret(MinioDocker.RootUser)), Some(Secret(MinioDocker.RootPassword)), permissions.read, permissions.write, showLocation = false, 60)), - remoteDisk = Some(RemoteDiskStorageConfig(DigestAlgorithm.default, BaseUri("http://localhost", Label.unsafe("v1")), None, permissions.read, permissions.write, showLocation = false, 70, RetryStrategyConfig.AlwaysGiveUp)), + remoteDisk = Some(RemoteDiskStorageConfig(DigestAlgorithm.default, BaseUri("http://localhost", Label.unsafe("v1")), Anonymous, permissions.read, permissions.write, showLocation = false, 70, RetryStrategyConfig.AlwaysGiveUp)), ) val diskFields = DiskStorageFields(Some("diskName"), Some("diskDescription"), default = true, Some(tmpVolume), Some(Permission.unsafe("disk/read")), Some(Permission.unsafe("disk/write")), Some(1000), Some(50)) val diskVal = diskFields.toValue(config).get diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteDiskStorageAccessSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteDiskStorageAccessSpec.scala index cf220d0f8f..40ff841563 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteDiskStorageAccessSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteDiskStorageAccessSpec.scala @@ -9,6 +9,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.StorageValue import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.permissions._ import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClient, HttpClientConfig} import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ @@ -37,8 +38,9 @@ class RemoteDiskStorageAccessSpec(docker: RemoteStorageDocker) implicit private val sc: Scheduler = Scheduler.global implicit private val httpConfig: HttpClientConfig = httpClientConfig private val httpClient: HttpClient = HttpClient() - private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.test - private val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider) + private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.anonymousForTest + private val remoteDiskStorageClient = + new RemoteDiskStorageClient(httpClient, authTokenProvider, Credentials.Anonymous) private val access = new RemoteDiskStorageAccess(remoteDiskStorageClient) diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageLinkFileSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageLinkFileSpec.scala index a8b5b3bab1..0819bed9ae 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageLinkFileSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageLinkFileSpec.scala @@ -16,6 +16,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.Storage import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.permissions.{read, write} import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClient, HttpClientConfig} import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, Tags} import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ @@ -47,8 +48,9 @@ class RemoteStorageLinkFileSpec(docker: RemoteStorageDocker) implicit val ec: ExecutionContext = system.dispatcher implicit private val httpConfig: HttpClientConfig = httpClientConfig private val httpClient: HttpClient = HttpClient() - private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.test - private val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider) + private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.anonymousForTest + private val remoteDiskStorageClient = + new RemoteDiskStorageClient(httpClient, authTokenProvider, Credentials.Anonymous) private val iri = iri"http://localhost/remote" private val uuid = UUID.fromString("8049ba90-7cc6-4de5-93a1-802c04200dcc") diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageSaveAndFetchFileSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageSaveAndFetchFileSpec.scala index 977b97f3dc..4dd5a0705b 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageSaveAndFetchFileSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/RemoteStorageSaveAndFetchFileSpec.scala @@ -17,6 +17,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.Storage import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.RemoteDiskStorageClient import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.permissions.{read, write} import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClient, HttpClientConfig} import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, Tags} import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ @@ -51,8 +52,9 @@ class RemoteStorageSaveAndFetchFileSpec(docker: RemoteStorageDocker) implicit val ec: ExecutionContext = system.dispatcher implicit private val httpConfig: HttpClientConfig = httpClientConfig private val httpClient: HttpClient = HttpClient() - private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.test - private val remoteDiskStorageClient = new RemoteDiskStorageClient(httpClient, authTokenProvider) + private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.anonymousForTest + private val remoteDiskStorageClient = + new RemoteDiskStorageClient(httpClient, authTokenProvider, Credentials.Anonymous) private val iri = iri"http://localhost/remote" private val uuid = UUID.fromString("8049ba90-7cc6-4de5-93a1-802c04200dcc") diff --git a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala index e7b9057a5c..6d859a2088 100644 --- a/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala +++ b/delta/plugins/storage/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/operations/remote/client/RemoteStorageClientSpec.scala @@ -8,9 +8,9 @@ import ch.epfl.bluebrain.nexus.delta.plugins.storage.files.model.Digest.{Compute import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.model.DigestAlgorithm import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.AkkaSourceHelpers import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.StorageFileRejection.{FetchFileRejection, MoveFileRejection} -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.AuthTokenProvider import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.operations.remote.client.model.RemoteDiskStorageFileAttributes import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.auth.{AuthTokenProvider, Credentials} import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError.HttpClientStatusError import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClient, HttpClientConfig} import ch.epfl.bluebrain.nexus.delta.sdk.model.ComponentDescription.ServiceDescription @@ -43,7 +43,7 @@ class RemoteStorageClientSpec(docker: RemoteStorageDocker) private var client: RemoteDiskStorageClient = _ private var baseUri: BaseUri = _ - private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.test(None) + private val authTokenProvider: AuthTokenProvider = AuthTokenProvider.anonymousForTest private val bucket: Label = Label.unsafe(BucketName) override protected def beforeAll(): Unit = { @@ -51,7 +51,7 @@ class RemoteStorageClientSpec(docker: RemoteStorageDocker) val httpConfig: HttpClientConfig = httpClientConfig implicit val httpClient: HttpClient = HttpClient()(httpConfig, system, Scheduler.global) - client = new RemoteDiskStorageClient(httpClient, authTokenProvider) + client = new RemoteDiskStorageClient(httpClient, authTokenProvider, Credentials.Anonymous) baseUri = BaseUri(docker.hostConfig.endpoint).rightValue } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala new file mode 100644 index 0000000000..5d2dbd5e67 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/AuthTokenProvider.scala @@ -0,0 +1,84 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.auth + +import cats.effect.Clock +import ch.epfl.bluebrain.nexus.delta.kernel.Logger +import ch.epfl.bluebrain.nexus.delta.kernel.cache.KeyValueStore +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax +import ch.epfl.bluebrain.nexus.delta.kernel.utils.IOUtils +import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials.ClientCredentials +import ch.epfl.bluebrain.nexus.delta.sdk.identities.ParsedToken +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.AuthToken +import monix.bio.UIO + +import java.time.{Duration, Instant} + +/** + * Provides an auth token for the service account, for use when comunicating with remote storage + */ +trait AuthTokenProvider { + def apply(credentials: Credentials): UIO[Option[AuthToken]] +} + +object AuthTokenProvider { + def apply(authService: OpenIdAuthService): UIO[AuthTokenProvider] = { + KeyValueStore[ClientCredentials, ParsedToken]().map(cache => new CachingOpenIdAuthTokenProvider(authService, cache)) + } + def anonymousForTest: AuthTokenProvider = new AnonymousAuthTokenProvider +} + +private class AnonymousAuthTokenProvider extends AuthTokenProvider { + override def apply(credentials: Credentials): UIO[Option[AuthToken]] = UIO.pure(None) +} + +/** + * Uses the supplied credentials to get an auth token from an open id service. This token is cached until near-expiry + * to speed up operations + */ +private class CachingOpenIdAuthTokenProvider( + service: OpenIdAuthService, + cache: KeyValueStore[ClientCredentials, ParsedToken] +)(implicit + clock: Clock[UIO] +) extends AuthTokenProvider + with MigrateEffectSyntax { + + private val logger = Logger.cats[CachingOpenIdAuthTokenProvider] + + override def apply(credentials: Credentials): UIO[Option[AuthToken]] = { + + credentials match { + case Credentials.Anonymous => UIO.pure(None) + case Credentials.JWTToken(token) => UIO.pure(Some(AuthToken(token))) + case credentials: ClientCredentials => clientCredentialsFlow(credentials) + } + } + + private def clientCredentialsFlow(credentials: ClientCredentials) = { + for { + existingValue <- cache.get(credentials) + now <- IOUtils.instant + finalValue <- existingValue match { + case None => + logger.info("Fetching auth token, no initial value.").toUIO >> + fetchValue(credentials) + case Some(value) if isExpired(value, now) => + logger.info("Fetching new auth token, current value near expiry.").toUIO >> + fetchValue(credentials) + case Some(value) => UIO.pure(value) + } + } yield { + Some(AuthToken(finalValue.rawToken)) + } + } + + private def fetchValue(credentials: ClientCredentials) = { + cache.getOrElseUpdate(credentials, service.auth(credentials)) + } + + private def isExpired(value: ParsedToken, now: Instant): Boolean = { + // minus 10 seconds to account for tranport / processing time + val cutoffTime = value.expirationTime.minus(Duration.ofSeconds(10)) + + now.isAfter(cutoffTime) + } +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/Credentials.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/Credentials.scala new file mode 100644 index 0000000000..bf4a486b39 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/Credentials.scala @@ -0,0 +1,48 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.auth + +import ch.epfl.bluebrain.nexus.delta.kernel.Secret +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert +import pureconfig.generic.semiauto.deriveReader + +import scala.annotation.nowarn + +/** + * Enumerates the different ways to obtain an auth toke for making requests to a remote service + */ +sealed trait Credentials + +object Credentials { + + /** + * When no auth token should be used + */ + case object Anonymous extends Credentials { + implicit val configReader: ConfigReader[Anonymous.type] = deriveReader[Anonymous.type] + } + + /** + * When a long-lived auth token should be used (legacy, not recommended) + */ + case class JWTToken(token: String) extends Credentials + case object JWTToken { + implicit val configReader: ConfigReader[JWTToken] = deriveReader[JWTToken] + } + + /** + * When client credentials should be exchanged with an OpenId service to obtain an auth token + * @param realm + * the realm which defines the OpenId service + */ + case class ClientCredentials(user: String, password: Secret[String], realm: Label) extends Credentials + object ClientCredentials { + @nowarn("cat=unused") + implicit private val labelConfigReader: ConfigReader[Label] = ConfigReader.fromString(str => + Label(str).left.map(e => CannotConvert(str, classOf[Label].getSimpleName, e.getMessage)) + ) + implicit val configReader: ConfigReader[ClientCredentials] = deriveReader[ClientCredentials] + } + + implicit val configReader: ConfigReader[Credentials] = deriveReader[Credentials] +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala new file mode 100644 index 0000000000..e1dc547bb4 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/auth/OpenIdAuthService.scala @@ -0,0 +1,76 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.auth + +import akka.http.javadsl.model.headers.HttpCredentials +import akka.http.scaladsl.model.HttpMethods.POST +import akka.http.scaladsl.model.headers.Authorization +import akka.http.scaladsl.model.{HttpRequest, Uri} +import ch.epfl.bluebrain.nexus.delta.kernel.Secret +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax +import ch.epfl.bluebrain.nexus.delta.sdk.auth.Credentials.ClientCredentials +import ch.epfl.bluebrain.nexus.delta.sdk.error.AuthTokenError.{AuthTokenHttpError, AuthTokenNotFoundInResponse, RealmIsDeprecated} +import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClient +import ch.epfl.bluebrain.nexus.delta.sdk.identities.ParsedToken +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.AuthToken +import ch.epfl.bluebrain.nexus.delta.sdk.realms.Realms +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.Realm +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import io.circe.Json +import monix.bio.{IO, UIO} + +/** + * Exchanges client credentials for an auth token with a remote OpenId service, as defined in the specified realm + */ +class OpenIdAuthService(httpClient: HttpClient, realms: Realms) extends MigrateEffectSyntax { + + /** + * Exchanges client credentials for an auth token with a remote OpenId service, as defined in the specified realm + */ + def auth(credentials: ClientCredentials): UIO[ParsedToken] = { + for { + realm <- findRealm(credentials.realm) + response <- requestToken(realm.tokenEndpoint, credentials.user, credentials.password) + parsedToken <- parseResponse(response) + } yield { + parsedToken + } + } + + private def findRealm(id: Label): UIO[Realm] = { + for { + realm <- realms.fetch(id).toUIO + _ <- UIO.when(realm.deprecated)(UIO.terminate(RealmIsDeprecated(realm.value))) + } yield realm.value + } + + private def requestToken(tokenEndpoint: Uri, user: String, password: Secret[String]): UIO[Json] = { + httpClient + .toJson( + HttpRequest( + method = POST, + uri = tokenEndpoint, + headers = Authorization(HttpCredentials.createBasicHttpCredentials(user, password.value)) :: Nil, + entity = akka.http.scaladsl.model + .FormData( + Map( + "scope" -> "openid", + "grant_type" -> "client_credentials" + ) + ) + .toEntity + ) + ) + .hideErrorsWith(AuthTokenHttpError) + } + + private def parseResponse(json: Json): UIO[ParsedToken] = { + for { + rawToken <- json.hcursor.get[String]("access_token") match { + case Left(failure) => IO.terminate(AuthTokenNotFoundInResponse(failure)) + case Right(value) => UIO.pure(value) + } + parsedToken <- IO.fromEither(ParsedToken.fromToken(AuthToken(rawToken))).hideErrors + } yield { + parsedToken + } + } +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/AuthTokenError.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/AuthTokenError.scala new file mode 100644 index 0000000000..2f1bd3e3f8 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/AuthTokenError.scala @@ -0,0 +1,57 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.error + +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder +import ch.epfl.bluebrain.nexus.delta.sdk.http.HttpClientError +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.Realm +import io.circe.syntax.{EncoderOps, KeyOps} +import io.circe.{DecodingFailure, Encoder, JsonObject} + +sealed abstract class AuthTokenError(reason: String) extends SDKError { + override def getMessage: String = reason +} + +object AuthTokenError { + + /** + * Signals that an HTTP error occurred when fetching the token + */ + final case class AuthTokenHttpError(cause: HttpClientError) + extends AuthTokenError(s"HTTP error when requesting auth token: ${cause.reason}") + + /** + * Signals that the token was missing from the authentication response + */ + final case class AuthTokenNotFoundInResponse(failure: DecodingFailure) + extends AuthTokenError(s"Auth token not found in auth response: ${failure.reason}") + + /** + * Signals that the expiry was missing from the authentication response + */ + final case class ExpiryNotFoundInResponse(failure: DecodingFailure) + extends AuthTokenError(s"Expiry not found in auth response: ${failure.reason}") + + /** + * Signals that the realm specified for authentication is deprecated + */ + final case class RealmIsDeprecated(realm: Realm) + extends AuthTokenError(s"Realm for authentication is deprecated: ${realm.label}") + + implicit val identityErrorEncoder: Encoder.AsObject[AuthTokenError] = { + Encoder.AsObject.instance[AuthTokenError] { + case AuthTokenHttpError(r) => + JsonObject(keywords.tpe := "AuthTokenHttpError", "reason" := r.reason) + case AuthTokenNotFoundInResponse(r) => + JsonObject(keywords.tpe -> "AuthTokenNotFoundInResponse".asJson, "reason" := r.message) + case ExpiryNotFoundInResponse(r) => + JsonObject(keywords.tpe -> "ExpiryNotFoundInResponse".asJson, "reason" := r.message) + case r: RealmIsDeprecated => + JsonObject(keywords.tpe := "RealmIsDeprecated", "reason" := r.getMessage) + } + } + + implicit val identityErrorJsonLdEncoder: JsonLdEncoder[AuthTokenError] = + JsonLdEncoder.computeFromCirce(ContextValue(contexts.error)) +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala index a753683306..598139952b 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/error/IdentityError.scala @@ -32,7 +32,7 @@ object IdentityError { * @param rejection * the specific reason why the token is invalid */ - final case class InvalidToken(rejection: TokenRejection) extends IdentityError(rejection.reason) + final case class InvalidToken(rejection: TokenRejection) extends IdentityError(rejection.getMessage) implicit val identityErrorEncoder: Encoder.AsObject[IdentityError] = Encoder.AsObject.instance[IdentityError] { diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala index 7d96bdb876..13e13e00b5 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/ParsedToken.scala @@ -28,7 +28,7 @@ object ParsedToken { * @param token * the raw token */ - private[identities] def fromToken(token: AuthToken): Either[TokenRejection, ParsedToken] = { + def fromToken(token: AuthToken): Either[TokenRejection, ParsedToken] = { def parseJwt: Either[TokenRejection, SignedJWT] = Either diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala index 499e4e6472..e4855bafcb 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejection.scala @@ -7,6 +7,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder +import ch.epfl.bluebrain.nexus.delta.sdk.error.SDKError import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields import io.circe.syntax._ import io.circe.{Encoder, JsonObject} @@ -17,7 +18,9 @@ import io.circe.{Encoder, JsonObject} * @param reason * a descriptive message for reasons why a token is rejected by the system */ -sealed abstract class TokenRejection(val reason: String) extends Product with Serializable +sealed abstract class TokenRejection(reason: String) extends SDKError with Product with Serializable { + override def getMessage: String = reason +} object TokenRejection { @@ -62,7 +65,7 @@ object TokenRejection { implicit val tokenRejectionEncoder: Encoder.AsObject[TokenRejection] = Encoder.AsObject.instance { r => val tpe = ClassUtils.simpleName(r) - val json = JsonObject.empty.add(keywords.tpe, tpe.asJson).add("reason", r.reason.asJson) + val json = JsonObject.empty.add(keywords.tpe, tpe.asJson).add("reason", r.getMessage.asJson) r match { case InvalidAccessToken(_, _, error) => json.add("details", error.asJson) case _ => json diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala index 873815cb12..f6696132ea 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/RdfExceptionHandler.scala @@ -11,7 +11,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.AuthorizationFailed -import ch.epfl.bluebrain.nexus.delta.sdk.error.{IdentityError, ServiceError} +import ch.epfl.bluebrain.nexus.delta.sdk.error.{AuthTokenError, IdentityError, ServiceError} import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import com.typesafe.scalalogging.Logger import io.circe.syntax._ @@ -33,6 +33,7 @@ object RdfExceptionHandler { ): ExceptionHandler = ExceptionHandler { case err: IdentityError => discardEntityAndForceEmit(err) + case err: AuthTokenError => discardEntityAndForceEmit(err) case AuthorizationFailed => discardEntityAndForceEmit(AuthorizationFailed: ServiceError) case err: RdfError => discardEntityAndForceEmit(err) case err: Throwable => diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala index 4a1fd4a19e..b6dc1cf571 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/identities/model/TokenRejectionSpec.scala @@ -25,8 +25,8 @@ class TokenRejectionSpec "be converted to compacted JSON-LD" in { val list = List( - noIssuer -> json"""{"@type": "AccessTokenDoesNotContainSubject", "reason": "${noIssuer.reason}"}""", - invalidFormat -> json"""{"@type": "InvalidAccessTokenFormat", "reason": "${invalidFormat.reason}"}""" + noIssuer -> json"""{"@type": "AccessTokenDoesNotContainSubject", "reason": "${noIssuer.getMessage}"}""", + invalidFormat -> json"""{"@type": "InvalidAccessTokenFormat", "reason": "${invalidFormat.getMessage}"}""" ) forAll(list) { case (rejection, json) => rejection.toCompactedJsonLd.accepted.json shouldEqual json.addContext(contexts.error) @@ -35,8 +35,8 @@ class TokenRejectionSpec "be converted to expanded JSON-LD" in { val list = List( - noIssuer -> json"""[{"@type": ["${nxv + "AccessTokenDoesNotContainSubject"}"], "${nxv + "reason"}": [{"@value": "${noIssuer.reason}"} ] } ]""", - invalidFormat -> json"""[{"@type": ["${nxv + "InvalidAccessTokenFormat"}"], "${nxv + "reason"}": [{"@value": "${invalidFormat.reason}"} ] } ]""" + noIssuer -> json"""[{"@type": ["${nxv + "AccessTokenDoesNotContainSubject"}"], "${nxv + "reason"}": [{"@value": "${noIssuer.getMessage}"} ] } ]""", + invalidFormat -> json"""[{"@type": ["${nxv + "InvalidAccessTokenFormat"}"], "${nxv + "reason"}": [{"@value": "${invalidFormat.getMessage}"} ] } ]""" ) forAll(list) { case (rejection, json) => rejection.toExpandedJsonLd.accepted.json shouldEqual json diff --git a/docs/src/main/paradox/docs/delta/api/storages-api.md b/docs/src/main/paradox/docs/delta/api/storages-api.md index 97623cc149..3fbbbb05b8 100644 --- a/docs/src/main/paradox/docs/delta/api/storages-api.md +++ b/docs/src/main/paradox/docs/delta/api/storages-api.md @@ -64,6 +64,8 @@ While there's no formal specification for this service, you can check out or dep @link:[Nexus remote storage service](https://github.com/BlueBrain/nexus/tree/$git.branch$/storage){ open=new }. In order to be able to use this storage, the configuration flag `plugins.storage.storages.remote-disk.enabled` should be set to `true`. +@ref:[More information about configuration](../../getting-started/running-nexus/configuration/index.md#remote-storage-configuration) + ```json { diff --git a/docs/src/main/paradox/docs/getting-started/running-nexus/configuration/index.md b/docs/src/main/paradox/docs/getting-started/running-nexus/configuration/index.md index cfff3d84ae..4021e5ce7c 100644 --- a/docs/src/main/paradox/docs/getting-started/running-nexus/configuration/index.md +++ b/docs/src/main/paradox/docs/getting-started/running-nexus/configuration/index.md @@ -129,7 +129,36 @@ Nexus Delta supports 3 types of storages: 'disk', 'amazon' (s3 compatible) and ' - For disk storages the most relevant configuration flag is `plugins.storage.storages.disk.default-volume`, which defines the default location in the Nexus Delta filesystem where the files using that storage are going to be saved. - For S3 compatible storages the most relevant configuration flags are the ones related to the S3 settings: `plugins.storage.storages.amazon.default-endpoint`, `plugins.storage.storages.amazon.default-access-key` and `plugins.storage.storages.amazon.default-secret-key`. -- For remote disk storages the most relevant configuration flags are `plugins.storage.storages.remote-disk.default-endpoint` (the endpoint where the remote storage service is running) and `plugins.storage.storages.remote-disk.default-credentials` (the Bearer token to authenticate to the remote storage service). +- For remote disk storages the most relevant configuration flags are `plugins.storage.storages.remote-disk.default-endpoint` (the endpoint where the remote storage service is running) and `plugins.storage.storages.remote-disk.credentials` (the method to authenticate to the remote storage service). + +#### Remote storage configuration + +Authentication for remote storage can be specified in three different ways. The value of `plugins.storage.storages.remote-disk.credentials` can be: + +##### Recommended: client credentials (OpenId authentication) +```hocon +{ + type: "client-credentials" + user: "username" + password: "password" + realm: "internal" +} +``` +This configuration tells Delta to log into the `internal` realm (which should have already been defined) with the `user` and `password` credentials, which will give Delta an access token to use when making requests to remote storage + +##### Anonymous +```hocon +{ + type: "anonymous" +} +``` +##### Long-living auth token (legacy) +```hocon +{ + type: "jwt-token" + token: "long-living-auth-token" +} +``` ### Archive plugin configuration diff --git a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md index 2d73831af8..164b35fc4a 100644 --- a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md +++ b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md @@ -114,6 +114,8 @@ Storages can no longer be created with credentials that would get stored: These should instead be defined in the Delta configuration. +@ref:[More information](../getting-started/running-nexus/configuration/index.md#remote-storage-configuration) + ### Graph Analytics The Elasticsearch views behind Graph Analytics can now be queried using the `_search` endpoint. diff --git a/tests/docker/config/delta-postgres.conf b/tests/docker/config/delta-postgres.conf index 08cc2a33be..17f686a802 100644 --- a/tests/docker/config/delta-postgres.conf +++ b/tests/docker/config/delta-postgres.conf @@ -107,8 +107,13 @@ plugins { remote-disk { enabled = true + credentials { + type: "client-credentials" + user: "delta" + password: "shhh" + realm: "internal" + } default-endpoint = "http://storage-service:8080/v1" - default-credentials = "" } amazon { diff --git a/tests/src/test/resources/kg/storages/remote-disk-response.json b/tests/src/test/resources/kg/storages/remote-disk-response.json index b1a151c95d..21d6be9456 100644 --- a/tests/src/test/resources/kg/storages/remote-disk-response.json +++ b/tests/src/test/resources/kg/storages/remote-disk-response.json @@ -15,9 +15,9 @@ "readPermission": "{{read}}", "writePermission": "{{write}}", "_algorithm": "SHA-256", - "_incoming": "{{deltaUri}}/storages/{{project}}/nxv:{{id}}/incoming", - "_outgoing": "{{deltaUri}}/storages/{{project}}/nxv:{{id}}/outgoing", - "_self": "{{deltaUri}}/storages/{{project}}/nxv:{{id}}", + "_incoming": "{{self}}/incoming", + "_outgoing": "{{self}}/outgoing", + "_self": "{{self}}", "_constrainedBy": "https://bluebrain.github.io/nexus/schemas/storages.json", "_project": "{{deltaUri}}/projects/{{project}}", "_rev": 1, diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/RemoteStorageSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/RemoteStorageSpec.scala index 02f0e44143..d9f7d4019e 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/RemoteStorageSpec.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/RemoteStorageSpec.scala @@ -11,13 +11,11 @@ import io.circe.generic.semiauto.deriveDecoder import io.circe.{Decoder, Json} import monix.bio.Task import org.scalactic.source.Position -import org.scalatest.{Assertion, Ignore} +import org.scalatest.Assertion import scala.annotation.nowarn import scala.sys.process._ -// Ignore while https://github.com/BlueBrain/nexus/issues/4063 is ongoing -@Ignore class RemoteStorageSpec extends StorageSpec { override def storageName: String = "external"