From eb9fde25ce31dd90cf85906ad834857510d7fa72 Mon Sep 17 00:00:00 2001 From: Daniel Bell Date: Sat, 30 Sep 2023 19:17:36 +0100 Subject: [PATCH 1/4] Storage permission endpoint (#4316) * Working storage permission endpoint (refactoring / fixes needed) * use ADT rather than string hacking * remove unnecessary admin user * deal with errors better * rename method * move logic to a class * scalafmt --- .../delta/routes/UserPermissionsRoutes.scala | 35 ++++++--- .../nexus/delta/wiring/AclsModule.scala | 12 +++- .../plugins/storage/StoragePluginModule.scala | 8 ++- .../StoragePermissionProviderImpl.scala | 27 +++++++ .../storages/model/StorageRejection.scala | 3 +- .../delta/sdk/directives/AuthDirectives.scala | 5 ++ .../QueryParamsUnmarshalling.scala | 9 +++ .../StoragePermissionProvider.scala | 21 ++++++ .../kg/storages/disk-perms-parameterised.json | 8 +++ .../nexus/tests/iam/UserPermissionsSpec.scala | 72 ++++++++++++++++++- 10 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragePermissionProviderImpl.scala create mode 100644 delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala create mode 100644 tests/src/test/resources/kg/storages/disk-perms-parameterised.json diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/UserPermissionsRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/UserPermissionsRoutes.scala index 7854f8ad65..3eb2367b7d 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/UserPermissionsRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/UserPermissionsRoutes.scala @@ -3,12 +3,16 @@ package ch.epfl.bluebrain.nexus.delta.routes import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling 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.identities.Identities -import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri +import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegment, IdSegmentRef} +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider.AccessType import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission /** @@ -19,10 +23,11 @@ import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission * @param aclCheck * verify the acls for users */ -final class UserPermissionsRoutes(identities: Identities, aclCheck: AclCheck)(implicit - baseUri: BaseUri +final class UserPermissionsRoutes(identities: Identities, aclCheck: AclCheck, storages: StoragePermissionProvider)( + implicit baseUri: BaseUri ) extends AuthDirectives(identities, aclCheck) - with CirceUnmarshalling { + with CirceUnmarshalling + with MigrateEffectSyntax { def routes: Route = baseUriPrefix(baseUri.prefix) { @@ -31,11 +36,21 @@ final class UserPermissionsRoutes(identities: Identities, aclCheck: AclCheck)(im projectRef { project => extractCaller { implicit caller => head { - parameter("permission".as[Permission]) { permission => - authorizeFor(project, permission)(caller) { - complete(StatusCodes.NoContent) + concat( + parameter("permission".as[Permission]) { permission => + authorizeFor(project, permission)(caller) { + complete(StatusCodes.NoContent) + } + }, + parameters("storage".as[IdSegment], "type".as[AccessType]) { (storageId, `type`) => + authorizeForIO( + AclAddress.fromProject(project), + storages.permissionFor(IdSegmentRef(storageId), project, `type`) + )(caller) { + complete(StatusCodes.NoContent) + } } - } + ) } } } @@ -45,8 +60,8 @@ final class UserPermissionsRoutes(identities: Identities, aclCheck: AclCheck)(im } object UserPermissionsRoutes { - def apply(identities: Identities, aclCheck: AclCheck)(implicit + def apply(identities: Identities, aclCheck: AclCheck, storagePermissionProvider: StoragePermissionProvider)(implicit baseUri: BaseUri ): Route = - new UserPermissionsRoutes(identities, aclCheck: AclCheck).routes + new UserPermissionsRoutes(identities, aclCheck: AclCheck, storagePermissionProvider).routes } diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/AclsModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/AclsModule.scala index 7502e8eff5..cccfca2051 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/AclsModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/AclsModule.scala @@ -14,7 +14,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.acls.{AclCheck, Acls, AclsImpl} import ch.epfl.bluebrain.nexus.delta.sdk.deletion.ProjectDeletionTask import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, MetadataContextValue} -import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.{Permissions, StoragePermissionProvider} import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} @@ -72,8 +72,14 @@ object AclsModule extends ModuleDef { } yield RemoteContextResolution.fixed(contexts.acls -> aclsCtx, contexts.aclsMetadata -> aclsMetaCtx) ) - make[UserPermissionsRoutes].from { (identities: Identities, aclCheck: AclCheck, baseUri: BaseUri) => - new UserPermissionsRoutes(identities, aclCheck)(baseUri) + make[UserPermissionsRoutes].from { + ( + identities: Identities, + aclCheck: AclCheck, + baseUri: BaseUri, + storagePermissionProvider: StoragePermissionProvider + ) => + new UserPermissionsRoutes(identities, aclCheck, storagePermissionProvider)(baseUri) } many[PriorityRoute].add { (alcs: AclsRoutes, userPermissions: UserPermissionsRoutes) => 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 03f4609bfd..7e98d6daa5 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 @@ -18,7 +18,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.routes.StoragesRoutes import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.schemas.{storage => storagesSchemaId} -import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.{StorageDeletionTask, Storages, StoragesStatistics} +import ch.epfl.bluebrain.nexus.delta.plugins.storage.storages.{StorageDeletionTask, StoragePermissionProviderImpl, Storages, StoragesStatistics} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.JsonLdApi import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering @@ -33,7 +33,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount import ch.epfl.bluebrain.nexus.delta.sdk.model._ import ch.epfl.bluebrain.nexus.delta.sdk.model.metrics.ScopedEventMetricEncoder -import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.{Permissions, StoragePermissionProvider} 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 @@ -94,6 +94,10 @@ class StoragePluginModule(priority: Int) extends ModuleDef { ) } + make[StoragePermissionProvider].from { (storages: Storages) => + new StoragePermissionProviderImpl(storages) + } + make[StoragesStatistics].from { ( client: ElasticSearchClient, diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragePermissionProviderImpl.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragePermissionProviderImpl.scala new file mode 100644 index 0000000000..ce92c63939 --- /dev/null +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/StoragePermissionProviderImpl.scala @@ -0,0 +1,27 @@ +package ch.epfl.bluebrain.nexus.delta.plugins.storage.storages + +import ch.epfl.bluebrain.nexus.delta.sdk.model.IdSegmentRef +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider.AccessType.{Read, Write} +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission +import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef +import monix.bio.UIO + +class StoragePermissionProviderImpl(storages: Storages) extends StoragePermissionProvider { + override def permissionFor( + id: IdSegmentRef, + project: ProjectRef, + accessType: StoragePermissionProvider.AccessType + ): UIO[Permission] = { + storages + .fetch(id, project) + .map(storage => storage.value.storageValue) + .map(storage => + accessType match { + case Read => storage.readPermission + case Write => storage.writePermission + } + ) + .hideErrors + } +} diff --git a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/model/StorageRejection.scala b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/model/StorageRejection.scala index e7dbd44bfe..27e04eb625 100644 --- a/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/model/StorageRejection.scala +++ b/delta/plugins/storage/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/storage/storages/model/StorageRejection.scala @@ -27,7 +27,8 @@ import io.circe.{Encoder, JsonObject} * a descriptive message as to why the rejection occurred */ sealed abstract class StorageRejection(val reason: String, val loggedDetails: Option[String] = None) - extends Product + extends Exception(reason) + with Product with Serializable object StorageRejection { diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala index 1354cc85f1..a79cd85448 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/directives/AuthDirectives.scala @@ -68,6 +68,11 @@ abstract class AuthDirectives(identities: Identities, aclCheck: AclCheck) { def authorizeFor(path: AclAddress, permission: Permission)(implicit caller: Caller): Directive0 = authorizeAsync(toCatsIO(aclCheck.authorizeFor(path, permission)).unsafeToFuture()) or failWith(AuthorizationFailed) + def authorizeForIO(path: AclAddress, fetchPermission: IO[Permission])(implicit caller: Caller): Directive0 = { + val check = fetchPermission.flatMap(permission => toCatsIO(aclCheck.authorizeFor(path, permission))) + authorizeAsync(check.unsafeToFuture()) or failWith(AuthorizationFailed) + } + /** * Check whether [[Caller]] is the configured service account. */ diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala index 8fc343922f..df76cccfef 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/marshalling/QueryParamsUnmarshalling.scala @@ -6,6 +6,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, JsonLdCon import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.QueryParamsUnmarshalling.{IriBase, IriVocab} import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, IdSegment} +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider.AccessType import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, ProjectContext} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject @@ -99,6 +100,14 @@ trait QueryParamsUnmarshalling { } } + implicit def accessTypeFromStringUnmarshaller: FromStringUnmarshaller[AccessType] = + Unmarshaller.strict[String, AccessType] { + case "read" => AccessType.Read + case "write" => AccessType.Write + case string => + throw new IllegalArgumentException(s"Access type can be either 'read' or 'write', received [$string]") + } + /** * Unmarsaller to transform an Iri to a Subject */ diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala new file mode 100644 index 0000000000..171ffa7bad --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala @@ -0,0 +1,21 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.permissions + +import ch.epfl.bluebrain.nexus.delta.sdk.model.IdSegmentRef +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.StoragePermissionProvider.AccessType +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission +import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef +import monix.bio.UIO + +trait StoragePermissionProvider { + + def permissionFor(id: IdSegmentRef, project: ProjectRef, accessType: AccessType): UIO[Permission] + +} + +object StoragePermissionProvider { + sealed trait AccessType + object AccessType { + case object Read extends AccessType + case object Write extends AccessType + } +} diff --git a/tests/src/test/resources/kg/storages/disk-perms-parameterised.json b/tests/src/test/resources/kg/storages/disk-perms-parameterised.json new file mode 100644 index 0000000000..e54e31da89 --- /dev/null +++ b/tests/src/test/resources/kg/storages/disk-perms-parameterised.json @@ -0,0 +1,8 @@ +{ + "@id": "{{id}}", + "@type": "DiskStorage", + "volume": "/default-volume", + "default": false, + "readPermission": "{{read-permission}}", + "writePermission": "{{write-permission}}" +} \ No newline at end of file diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/UserPermissionsSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/UserPermissionsSpec.scala index 4adb4dca0b..2de95954ac 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/UserPermissionsSpec.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/UserPermissionsSpec.scala @@ -2,14 +2,32 @@ package ch.epfl.bluebrain.nexus.tests.iam import akka.http.scaladsl.model.StatusCodes import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils.encode -import ch.epfl.bluebrain.nexus.tests.BaseSpec import ch.epfl.bluebrain.nexus.tests.Identity.userPermissions.{UserWithNoPermissions, UserWithPermissions} +import ch.epfl.bluebrain.nexus.tests.iam.types.Permission import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.Resources +import ch.epfl.bluebrain.nexus.tests.{BaseSpec, Identity} +import io.circe.Json +import org.scalactic.source.Position class UserPermissionsSpec extends BaseSpec { - val org, project = genId() + val org, project = genId() + val StorageId = "https://bluebrain.github.io/nexus/vocabulary/storage1" + val StorageReadPermission = Permission("s3-storage", "read") + val StorageWritePermission = Permission("s3-storage", "write") + override def beforeAll(): Unit = { + super.beforeAll() + val result = for { + _ <- permissionDsl.addPermissions(StorageReadPermission, StorageWritePermission) + _ <- adminDsl.createOrganization(org, "UserPermissionsSpec organisation", Identity.ServiceAccount) + _ <- adminDsl.createProject(org, project, adminDsl.projectPayload(), Identity.ServiceAccount) + _ <- createStorage(StorageId, StorageReadPermission, StorageWritePermission) + } yield succeed + + result.accepted + () + } private def urlFor(permission: String, project: String) = s"/user/permissions/$project?permission=${encode(permission)}" @@ -27,4 +45,54 @@ class UserPermissionsSpec extends BaseSpec { } } yield succeed } + + private def storageUrlFor(project: String, storageId: String, typ: String): String = { + s"/user/permissions/$project?storage=${encode(storageId)}&type=$typ" + } + + "if a user does not have read permission for a storage, 403 should be returned" in { + deltaClient.head(storageUrlFor(s"$org/$project", StorageId, "read"), UserWithNoPermissions) { response => + response.status shouldBe StatusCodes.Forbidden + } + } + + "if a user has read permission for a storage, 204 should be returned" in { + for { + _ <- aclDsl.addPermission(s"/$org/$project", UserWithPermissions, StorageReadPermission) + _ <- deltaClient.head(storageUrlFor(s"$org/$project", StorageId, "read"), UserWithPermissions) { response => + response.status shouldBe StatusCodes.NoContent + } + } yield succeed + } + + "if a user does not have write permission for a storage, 403 should be returned" in { + deltaClient.head(storageUrlFor(s"$org/$project", StorageId, "write"), UserWithNoPermissions) { response => + response.status shouldBe StatusCodes.Forbidden + } + } + + "if a user has write permission for a storage, 204 should be returned" in { + for { + _ <- aclDsl.addPermission(s"/$org/$project", UserWithPermissions, StorageWritePermission) + _ <- deltaClient.head(storageUrlFor(s"$org/$project", StorageId, "write"), UserWithPermissions) { response => + response.status shouldBe StatusCodes.NoContent + } + } yield succeed + } + + private def createStorage(id: String, readPermission: Permission, writePermission: Permission)(implicit + pos: Position + ) = { + val payload = jsonContentOf( + "/kg/storages/disk-perms-parameterised.json", + "id" -> id, + "read-permission" -> readPermission.value, + "write-permission" -> writePermission.value + ) + deltaClient.post[Json](s"/storages/$org/$project", payload, Identity.ServiceAccount) { (_, response) => + withClue("creation of storage failed: ") { + response.status shouldEqual StatusCodes.Created + } + } + } } From 04f5c20c0893912c15792376e68dd3e70aaca625 Mon Sep 17 00:00:00 2001 From: Daniel Bell Date: Mon, 2 Oct 2023 09:27:44 +0100 Subject: [PATCH 2/4] Storage permissions endpoint docs (#4319) * Add docs for storage permisions requests * add scaladoc --- .../StoragePermissionProvider.scala | 3 +++ .../docs/delta/api/user-permissions-api.md | 27 +++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala index 171ffa7bad..a61853f23c 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/StoragePermissionProvider.scala @@ -6,6 +6,9 @@ import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef import monix.bio.UIO +/** + * Provides the permission a user needs to have in order to access files on this storage + */ trait StoragePermissionProvider { def permissionFor(id: IdSegmentRef, project: ProjectRef, accessType: AccessType): UIO[Permission] diff --git a/docs/src/main/paradox/docs/delta/api/user-permissions-api.md b/docs/src/main/paradox/docs/delta/api/user-permissions-api.md index 9b84b098d1..dc49acb52e 100644 --- a/docs/src/main/paradox/docs/delta/api/user-permissions-api.md +++ b/docs/src/main/paradox/docs/delta/api/user-permissions-api.md @@ -11,20 +11,31 @@ The described endpoints are experimental and the responses structure might chang @@@ -## Head +Requests +: All requests should have no body -This operation determines whether the current logged in user has a specific permission in a specific context +Responses +: A response will have a 204 (no content) status code if the user is authorised +: A response will have a 403 (forbidden) status code if the user is not authorised + + +## Standard permissions +This operation determines whether the current logged in user has a specific permission in a specific context ``` HEAD /v1/user/permissions/{org_label}/{project_label}?permission={permission} ``` - where - `{permission}`: String - the permission to check -Request -: The request should have no body -Response -: The response will have a 204 (no content) status code if the user is authorised -: The response will have a 403 (forbidden) status code if the user is not authorised +## Storage access permissions + +This operation determines whether the current logged in user would be able to access files on a specific storage +``` +HEAD /v1/user/permissions/{org_label}/{project_label}?storage={storage_id}&type={access_type} +``` +where +- `{storage_id}`: String - the id of the storage +- `{access_type}`: String - the access type of the storage. Can be `read` or `write` + From bf0e1f1d1023d87febbcc31c4d2c66c9576295ac Mon Sep 17 00:00:00 2001 From: Daniel Bell Date: Tue, 3 Oct 2023 08:30:08 +0100 Subject: [PATCH 3/4] Migrate auth token code to cats effect (#4318) --- .../nexus/delta/wiring/IdentitiesModule.scala | 5 ++- .../compositeviews/client/DeltaClient.scala | 14 ++++--- .../client/RemoteDiskStorageClient.scala | 13 +++--- .../delta/sdk/auth/AuthTokenProvider.scala | 42 ++++++++++--------- .../delta/sdk/auth/OpenIdAuthService.scala | 20 ++++----- 5 files changed, 50 insertions(+), 44 deletions(-) diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala index a7674443ec..489e0387e8 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/IdentitiesModule.scala @@ -1,5 +1,6 @@ package ch.epfl.bluebrain.nexus.delta.wiring +import cats.effect.{Clock, IO} import ch.epfl.bluebrain.nexus.delta.Main.pluginsMaxPriority import ch.epfl.bluebrain.nexus.delta.config.AppConfig import ch.epfl.bluebrain.nexus.delta.kernel.cache.CacheConfig @@ -34,8 +35,8 @@ object IdentitiesModule extends ModuleDef { new OpenIdAuthService(httpClient, realms) } - make[AuthTokenProvider].fromEffect { (authService: OpenIdAuthService) => - AuthTokenProvider(authService) + make[AuthTokenProvider].fromEffect { (authService: OpenIdAuthService, clock: Clock[IO]) => + AuthTokenProvider(authService)(clock) } many[RemoteContextResolution].addEffect(ContextValue.fromFile("contexts/identities.json").map { ctx => diff --git a/delta/plugins/composite-views/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/compositeviews/client/DeltaClient.scala b/delta/plugins/composite-views/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/compositeviews/client/DeltaClient.scala index abce4fdd32..27fbd59de6 100644 --- a/delta/plugins/composite-views/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/compositeviews/client/DeltaClient.scala +++ b/delta/plugins/composite-views/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/compositeviews/client/DeltaClient.scala @@ -7,6 +7,7 @@ import akka.http.scaladsl.model.Uri.Query import akka.http.scaladsl.model.headers.{`Last-Event-ID`, Accept} import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import akka.stream.alpakka.sse.scaladsl.EventSource +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.CompositeViewSource.RemoteProjectSource import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.stream.CompositeBranch import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri @@ -87,11 +88,12 @@ object DeltaClient { )(implicit as: ActorSystem[Nothing], scheduler: Scheduler - ) extends DeltaClient { + ) extends DeltaClient + with MigrateEffectSyntax { override def projectStatistics(source: RemoteProjectSource): HttpResult[ProjectStatistics] = { for { - authToken <- authTokenProvider(credentials) + authToken <- authTokenProvider(credentials).toBIO request = Get( source.endpoint / "projects" / source.project.organization.value / source.project.project.value / "statistics" @@ -104,7 +106,7 @@ object DeltaClient { override def remaining(source: RemoteProjectSource, offset: Offset): HttpResult[RemainingElems] = { for { - authToken <- authTokenProvider(credentials) + authToken <- authTokenProvider(credentials).toBIO request = Get(elemAddress(source) / "remaining") .addHeader(accept) .addHeader(`Last-Event-ID`(offset.value.toString)) @@ -115,7 +117,7 @@ object DeltaClient { override def checkElems(source: RemoteProjectSource): HttpResult[Unit] = { for { - authToken <- authTokenProvider(credentials) + authToken <- authTokenProvider(credentials).toBIO result <- client(Head(elemAddress(source)).withCredentials(authToken)) { case resp if resp.status.isSuccess() => UIO.delay(resp.discardEntityBytes()) >> IO.unit } @@ -130,7 +132,7 @@ object DeltaClient { def send(request: HttpRequest): Future[HttpResponse] = { (for { - authToken <- authTokenProvider(credentials) + authToken <- authTokenProvider(credentials).toBIO result <- client[HttpResponse](request.withCredentials(authToken))(IO.pure(_)) } yield result).runToFuture } @@ -164,7 +166,7 @@ object DeltaClient { val resourceUrl = source.endpoint / "resources" / source.project.organization.value / source.project.project.value / "_" / id.toString for { - authToken <- authTokenProvider(credentials) + authToken <- authTokenProvider(credentials).toBIO req = Get( source.resourceTag.fold(resourceUrl)(t => resourceUrl.withQuery(Query("tag" -> t.value))) ).addHeader(Accept(RdfMediaTypes.`application/n-quads`)).withCredentials(authToken) 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 02f3faa8a1..adf9f2bc37 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 @@ -7,6 +7,7 @@ import akka.http.scaladsl.model.Multipart.FormData import akka.http.scaladsl.model.Multipart.FormData.BodyPart import akka.http.scaladsl.model.StatusCodes._ import akka.http.scaladsl.model.Uri.Path +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration.MigrateEffectSyntax 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} @@ -34,7 +35,7 @@ import scala.concurrent.duration._ */ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenProvider, credentials: Credentials)( implicit as: ActorSystem -) { +) extends MigrateEffectSyntax { import as.dispatcher private val serviceName = Name.unsafe("remoteStorage") @@ -58,7 +59,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP * the storage bucket name */ def exists(bucket: Label)(implicit baseUri: BaseUri): IO[HttpClientError, Unit] = { - getAuthToken(credentials).flatMap { authToken => + getAuthToken(credentials).toBIO.flatMap { authToken => val endpoint = baseUri.endpoint / "buckets" / bucket.value val req = Head(endpoint).withCredentials(authToken) client(req) { @@ -82,7 +83,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP relativePath: Path, entity: BodyPartEntity )(implicit baseUri: BaseUri): IO[SaveFileRejection, RemoteDiskStorageFileAttributes] = { - getAuthToken(credentials).flatMap { authToken => + getAuthToken(credentials).toBIO.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 +107,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(credentials).flatMap { authToken => + getAuthToken(credentials).toBIO.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 +130,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP bucket: Label, relativePath: Path )(implicit baseUri: BaseUri): IO[FetchFileRejection, RemoteDiskStorageFileAttributes] = { - getAuthToken(credentials).flatMap { authToken => + getAuthToken(credentials).toBIO.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 +157,7 @@ final class RemoteDiskStorageClient(client: HttpClient, getAuthToken: AuthTokenP sourceRelativePath: Path, destRelativePath: Path )(implicit baseUri: BaseUri): IO[MoveFileRejection, RemoteDiskStorageFileAttributes] = { - getAuthToken(credentials).flatMap { authToken => + getAuthToken(credentials).toBIO.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/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 index 8d3057a074..31f9ed3691 100644 --- 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 @@ -1,14 +1,14 @@ package ch.epfl.bluebrain.nexus.delta.sdk.auth -import cats.effect.Clock +import cats.effect.{Clock, IO} import ch.epfl.bluebrain.nexus.delta.kernel.Logger -import ch.epfl.bluebrain.nexus.delta.kernel.cache.KeyValueStore +import ch.epfl.bluebrain.nexus.delta.kernel.cache.LocalCache 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.kernel.utils.IOInstant 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 monix.bio import java.time.{Duration, Instant} @@ -16,21 +16,23 @@ 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]] + def apply(credentials: Credentials): IO[Option[AuthToken]] } object AuthTokenProvider { - def apply(authService: OpenIdAuthService): UIO[AuthTokenProvider] = { - KeyValueStore[ClientCredentials, ParsedToken]().map(cache => new CachingOpenIdAuthTokenProvider(authService, cache)) + def apply(authService: OpenIdAuthService)(implicit clock: Clock[IO]): bio.UIO[AuthTokenProvider] = { + LocalCache[ClientCredentials, ParsedToken]() + .map(cache => new CachingOpenIdAuthTokenProvider(authService, cache)) + .toBIO } def anonymousForTest: AuthTokenProvider = new AnonymousAuthTokenProvider def fixedForTest(token: String): AuthTokenProvider = new AuthTokenProvider { - override def apply(credentials: Credentials): UIO[Option[AuthToken]] = UIO.pure(Some(AuthToken(token))) + override def apply(credentials: Credentials): IO[Option[AuthToken]] = IO.pure(Some(AuthToken(token))) } } private class AnonymousAuthTokenProvider extends AuthTokenProvider { - override def apply(credentials: Credentials): UIO[Option[AuthToken]] = UIO.pure(None) + override def apply(credentials: Credentials): IO[Option[AuthToken]] = IO.pure(None) } /** @@ -39,42 +41,42 @@ private class AnonymousAuthTokenProvider extends AuthTokenProvider { */ private class CachingOpenIdAuthTokenProvider( service: OpenIdAuthService, - cache: KeyValueStore[ClientCredentials, ParsedToken] + cache: LocalCache[ClientCredentials, ParsedToken] )(implicit - clock: Clock[UIO] + clock: Clock[IO] ) extends AuthTokenProvider with MigrateEffectSyntax { private val logger = Logger.cats[CachingOpenIdAuthTokenProvider] - override def apply(credentials: Credentials): UIO[Option[AuthToken]] = { + override def apply(credentials: Credentials): IO[Option[AuthToken]] = { credentials match { - case Credentials.Anonymous => UIO.pure(None) - case Credentials.JWTToken(token) => UIO.pure(Some(AuthToken(token))) + case Credentials.Anonymous => IO.pure(None) + case Credentials.JWTToken(token) => IO.pure(Some(AuthToken(token))) case credentials: ClientCredentials => clientCredentialsFlow(credentials) } } - private def clientCredentialsFlow(credentials: ClientCredentials) = { + private def clientCredentialsFlow(credentials: ClientCredentials): IO[Some[AuthToken]] = { for { existingValue <- cache.get(credentials) - now <- IOUtils.instant + now <- IOInstant.now finalValue <- existingValue match { case None => - logger.info("Fetching auth token, no initial value.").toUIO >> + logger.info("Fetching auth token, no initial value.") *> fetchValue(credentials) case Some(value) if isExpired(value, now) => - logger.info("Fetching new auth token, current value near expiry.").toUIO >> + logger.info("Fetching new auth token, current value near expiry.") *> fetchValue(credentials) - case Some(value) => UIO.pure(value) + case Some(value) => IO.pure(value) } } yield { Some(AuthToken(finalValue.rawToken)) } } - private def fetchValue(credentials: ClientCredentials) = { + private def fetchValue(credentials: ClientCredentials): IO[ParsedToken] = { cache.getOrElseUpdate(credentials, service.auth(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 index e1dc547bb4..38685f73e2 100644 --- 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 @@ -4,6 +4,7 @@ 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 cats.effect.IO 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 @@ -15,7 +16,6 @@ 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 @@ -25,7 +25,7 @@ class OpenIdAuthService(httpClient: HttpClient, realms: Realms) extends MigrateE /** * Exchanges client credentials for an auth token with a remote OpenId service, as defined in the specified realm */ - def auth(credentials: ClientCredentials): UIO[ParsedToken] = { + def auth(credentials: ClientCredentials): IO[ParsedToken] = { for { realm <- findRealm(credentials.realm) response <- requestToken(realm.tokenEndpoint, credentials.user, credentials.password) @@ -35,14 +35,14 @@ class OpenIdAuthService(httpClient: HttpClient, realms: Realms) extends MigrateE } } - private def findRealm(id: Label): UIO[Realm] = { + private def findRealm(id: Label): IO[Realm] = { for { - realm <- realms.fetch(id).toUIO - _ <- UIO.when(realm.deprecated)(UIO.terminate(RealmIsDeprecated(realm.value))) + realm <- realms.fetch(id) + _ <- IO.raiseWhen(realm.deprecated)(RealmIsDeprecated(realm.value)) } yield realm.value } - private def requestToken(tokenEndpoint: Uri, user: String, password: Secret[String]): UIO[Json] = { + private def requestToken(tokenEndpoint: Uri, user: String, password: Secret[String]): IO[Json] = { httpClient .toJson( HttpRequest( @@ -62,13 +62,13 @@ class OpenIdAuthService(httpClient: HttpClient, realms: Realms) extends MigrateE .hideErrorsWith(AuthTokenHttpError) } - private def parseResponse(json: Json): UIO[ParsedToken] = { + private def parseResponse(json: Json): IO[ParsedToken] = { for { rawToken <- json.hcursor.get[String]("access_token") match { - case Left(failure) => IO.terminate(AuthTokenNotFoundInResponse(failure)) - case Right(value) => UIO.pure(value) + case Left(failure) => IO.raiseError(AuthTokenNotFoundInResponse(failure)) + case Right(value) => IO.pure(value) } - parsedToken <- IO.fromEither(ParsedToken.fromToken(AuthToken(rawToken))).hideErrors + parsedToken <- IO.fromEither(ParsedToken.fromToken(AuthToken(rawToken))) } yield { parsedToken } From 523ff528b761eb4333333e497602d1940634b60d Mon Sep 17 00:00:00 2001 From: Oliver <20188437+olivergrabinski@users.noreply.github.com> Date: Tue, 3 Oct 2023 14:24:42 +0200 Subject: [PATCH 4/4] Handle deserialization of empty remote contexts in `ResourceEvent`s and `ResourceState` (#4317) --- .../sdk/resources/model/ResourceEvent.scala | 16 +++-- .../sdk/resources/model/ResourceState.scala | 8 ++- .../resource-created-no-remote-contexts.json | 54 ++++++++++++++++ ...resource-refreshed-no-remote-contexts.json | 42 +++++++++++++ .../resource-updated-no-remote-contexts.json | 54 ++++++++++++++++ .../resource-state-no-remote-contexts.json | 63 +++++++++++++++++++ .../model/ResourceSerializationSuite.scala | 41 ++++++++++-- .../init/V1_09_M05_002__remote_contexts.ddl | 14 ----- 8 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 delta/sdk/src/test/resources/resources/database/resource-created-no-remote-contexts.json create mode 100644 delta/sdk/src/test/resources/resources/database/resource-refreshed-no-remote-contexts.json create mode 100644 delta/sdk/src/test/resources/resources/database/resource-updated-no-remote-contexts.json create mode 100644 delta/sdk/src/test/resources/resources/resource-state-no-remote-contexts.json delete mode 100644 delta/sourcing-psql/src/main/resources/scripts/postgres/init/V1_09_M05_002__remote_contexts.ddl diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceEvent.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceEvent.scala index dfd2f8a826..97d596de23 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceEvent.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceEvent.scala @@ -21,7 +21,7 @@ import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Label, ProjectR import io.circe.generic.extras.Configuration import io.circe.generic.extras.semiauto.{deriveConfiguredCodec, deriveConfiguredEncoder} import io.circe.syntax._ -import io.circe.{Codec, Decoder, Encoder, Json, JsonObject} +import io.circe._ import java.time.Instant import scala.annotation.nowarn @@ -90,7 +90,8 @@ object ResourceEvent { source: Json, compacted: CompactedJsonLd, expanded: ExpandedJsonLd, - remoteContexts: Set[RemoteContextRef], + // TODO: Remove default after 1.10 migration + remoteContexts: Set[RemoteContextRef] = Set.empty, rev: Int, instant: Instant, subject: Subject @@ -133,7 +134,8 @@ object ResourceEvent { source: Json, compacted: CompactedJsonLd, expanded: ExpandedJsonLd, - remoteContexts: Set[RemoteContextRef], + // TODO: Remove default after 1.10 migration + remoteContexts: Set[RemoteContextRef] = Set.empty, rev: Int, instant: Instant, subject: Subject @@ -173,7 +175,8 @@ object ResourceEvent { types: Set[Iri], compacted: CompactedJsonLd, expanded: ExpandedJsonLd, - remoteContexts: Set[RemoteContextRef], + // TODO: Remove default after 1.10 migration + remoteContexts: Set[RemoteContextRef] = Set.empty, rev: Int, instant: Instant, subject: Subject @@ -269,8 +272,9 @@ object ResourceEvent { import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.ExpandedJsonLd.Database._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Database._ - //TODO remove after migration of events - implicit val configuration: Configuration = Serializer.circeConfiguration + // TODO: The `.withDefaults` method is used in order to inject the default empty remoteContexts + // when deserializing an event that has none. Remove it after 1.10 migration. + implicit val configuration: Configuration = Serializer.circeConfiguration.withDefaults implicit val coder: Codec.AsObject[ResourceEvent] = deriveConfiguredCodec[ResourceEvent] Serializer() diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceState.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceState.scala index f8e99955ed..5e40514e42 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceState.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceState.scala @@ -59,7 +59,8 @@ final case class ResourceState( source: Json, compacted: CompactedJsonLd, expanded: ExpandedJsonLd, - remoteContexts: Set[RemoteContextRef], + // TODO: Remove default after 1.10 migration + remoteContexts: Set[RemoteContextRef] = Set.empty, rev: Int, deprecated: Boolean, schema: ResourceRef, @@ -94,7 +95,10 @@ object ResourceState { import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.CompactedJsonLd.Database._ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.ExpandedJsonLd.Database._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Database._ - implicit val configuration: Configuration = Serializer.circeConfiguration + + // TODO: The `.withDefaults` method is used in order to inject the default empty remoteContexts + // when deserializing an event that has none. Remove it after 1.10 migration. + implicit val configuration: Configuration = Serializer.circeConfiguration.withDefaults implicit val codec: Codec.AsObject[ResourceState] = deriveConfiguredCodec[ResourceState] Serializer() } diff --git a/delta/sdk/src/test/resources/resources/database/resource-created-no-remote-contexts.json b/delta/sdk/src/test/resources/resources/database/resource-created-no-remote-contexts.json new file mode 100644 index 0000000000..19326052c2 --- /dev/null +++ b/delta/sdk/src/test/resources/resources/database/resource-created-no-remote-contexts.json @@ -0,0 +1,54 @@ +{ + "id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "project": "myorg/myproj", + "schema": "https://bluebrain.github.io/nexus/schemas/unconstrained.json?rev=1", + "schemaProject": "myorg/myproj", + "types": [ + "https://neuroshapes.org/Morphology" + ], + "source": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "compacted": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "expanded": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": [ + "https://neuroshapes.org/Morphology" + ], + "https://bluebrain.github.io/nexus/vocabulary/name": [ + { + "@value": "Morphology 001" + } + ] + } + ], + "rev": 1, + "instant": "1970-01-01T00:00:00Z", + "subject": { + "subject": "username", + "realm": "myrealm", + "@type": "User" + }, + "@type": "ResourceCreated" +} \ No newline at end of file diff --git a/delta/sdk/src/test/resources/resources/database/resource-refreshed-no-remote-contexts.json b/delta/sdk/src/test/resources/resources/database/resource-refreshed-no-remote-contexts.json new file mode 100644 index 0000000000..beb1843cf0 --- /dev/null +++ b/delta/sdk/src/test/resources/resources/database/resource-refreshed-no-remote-contexts.json @@ -0,0 +1,42 @@ +{ + "id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "project": "myorg/myproj", + "schema": "https://bluebrain.github.io/nexus/schemas/unconstrained.json?rev=1", + "schemaProject": "myorg/myproj", + "types" : [ + "https://neuroshapes.org/Morphology" + ], + "compacted": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "expanded": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": [ + "https://neuroshapes.org/Morphology" + ], + "https://bluebrain.github.io/nexus/vocabulary/name": [ + { + "@value": "Morphology 001" + } + ] + } + ], + "rev": 2, + "instant": "1970-01-01T00:00:00Z", + "subject": { + "subject": "username", + "realm": "myrealm", + "@type": "User" + }, + "@type": "ResourceRefreshed" +} \ No newline at end of file diff --git a/delta/sdk/src/test/resources/resources/database/resource-updated-no-remote-contexts.json b/delta/sdk/src/test/resources/resources/database/resource-updated-no-remote-contexts.json new file mode 100644 index 0000000000..82cda3addd --- /dev/null +++ b/delta/sdk/src/test/resources/resources/database/resource-updated-no-remote-contexts.json @@ -0,0 +1,54 @@ +{ + "id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "project": "myorg/myproj", + "schema": "https://bluebrain.github.io/nexus/schemas/unconstrained.json?rev=1", + "schemaProject": "myorg/myproj", + "types": [ + "https://neuroshapes.org/Morphology" + ], + "source": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "compacted": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "expanded": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": [ + "https://neuroshapes.org/Morphology" + ], + "https://bluebrain.github.io/nexus/vocabulary/name": [ + { + "@value": "Morphology 001" + } + ] + } + ], + "rev": 2, + "instant": "1970-01-01T00:00:00Z", + "subject": { + "subject": "username", + "realm": "myrealm", + "@type": "User" + }, + "@type": "ResourceUpdated" +} \ No newline at end of file diff --git a/delta/sdk/src/test/resources/resources/resource-state-no-remote-contexts.json b/delta/sdk/src/test/resources/resources/resource-state-no-remote-contexts.json new file mode 100644 index 0000000000..0eb22794b8 --- /dev/null +++ b/delta/sdk/src/test/resources/resources/resource-state-no-remote-contexts.json @@ -0,0 +1,63 @@ +{ + "id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "project": "myorg/myproj", + "schemaProject": "myorg/myproj", + "source": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "compacted": { + "@context": [ + "https://neuroshapes.org", + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/" + } + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": "Morphology", + "name": "Morphology 001" + }, + "expanded": [ + { + "@id": "https://bluebrain.github.io/nexus/vocabulary/myId", + "@type": [ + "https://neuroshapes.org/Morphology" + ], + "https://bluebrain.github.io/nexus/vocabulary/name": [ + { + "@value": "Morphology 001" + } + ] + } + ], + "rev": 2, + "deprecated": false, + "schema": "https://bluebrain.github.io/nexus/schemas/unconstrained.json?rev=1", + "types": [ + "https://neuroshapes.org/Morphology" + ], + "tags": { + "mytag": 3 + }, + "createdAt": "1970-01-01T00:00:00Z", + "createdBy": { + "subject": "username", + "realm": "myrealm", + "@type": "User" + }, + "updatedAt": "1970-01-01T00:00:00Z", + "updatedBy": { + "subject": "username", + "realm": "myrealm", + "@type": "User" + } +} \ No newline at end of file diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceSerializationSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceSerializationSuite.scala index d01a6dbced..395328cab2 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceSerializationSuite.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceSerializationSuite.scala @@ -108,21 +108,21 @@ class ResourceSerializationSuite extends SerializationSuite with ResourceInstanc ) resourcesMapping.foreach { case (event, (database, sse), action) => - test(s"Correctly serialize ${event.getClass.getName}") { + test(s"Correctly serialize ${event.getClass.getSimpleName}") { assertOutput(ResourceEvent.serializer, event, database) } - test(s"Correctly deserialize ${event.getClass.getName}") { + test(s"Correctly deserialize ${event.getClass.getSimpleName}") { assertEquals(ResourceEvent.serializer.codec.decodeJson(database), Right(event)) } - test(s"Correctly serialize ${event.getClass.getName} as an SSE") { + test(s"Correctly serialize ${event.getClass.getSimpleName} as an SSE") { sseEncoder.toSse .decodeJson(database) .assertRight(SseData(ClassUtils.simpleName(event), Some(ProjectRef(org, proj)), sse)) } - test(s"Correctly encode ${event.getClass.getName} to metric") { + test(s"Correctly encode ${event.getClass.getSimpleName} to metric") { ResourceEvent.resourceEventMetricEncoder.toMetric.decodeJson(database).assertRight { ProjectScopedMetric( instant, @@ -139,6 +139,19 @@ class ResourceSerializationSuite extends SerializationSuite with ResourceInstanc } } + private val resourcesMappingNoRemoteContexts = List( + (created.noRemoteContext, jsonContentOf("resources/database/resource-created-no-remote-contexts.json")), + (updated.noRemoteContext, jsonContentOf("resources/database/resource-updated-no-remote-contexts.json")), + (refreshed.noRemoteContext, jsonContentOf("resources/database/resource-refreshed-no-remote-contexts.json")) + ) + + // TODO: Remove test after 1.10 migration. + resourcesMappingNoRemoteContexts.foreach { case (event, database) => + test(s"Correctly deserialize a ${event.getClass.getSimpleName} with no RemoteContext") { + assertEquals(ResourceEvent.serializer.codec.decodeJson(database), Right(event)) + } + } + private val state = ResourceState( myId, projectRef, @@ -158,7 +171,8 @@ class ResourceSerializationSuite extends SerializationSuite with ResourceInstanc updatedBy = subject ) - private val jsonState = jsonContentOf("/resources/resource-state.json") + private val jsonState = jsonContentOf("/resources/resource-state.json") + private val jsonStateNoRemoteContext = jsonContentOf("/resources/resource-state-no-remote-contexts.json") test(s"Correctly serialize a ResourceState") { assertOutput(ResourceState.serializer, state, jsonState) @@ -168,4 +182,21 @@ class ResourceSerializationSuite extends SerializationSuite with ResourceInstanc assertEquals(ResourceState.serializer.codec.decodeJson(jsonState), Right(state)) } + // TODO: Remove test after 1.10 migration. + test("Correctly deserialize a ResourceState with no remote contexts") { + assertEquals( + ResourceState.serializer.codec.decodeJson(jsonStateNoRemoteContext), + Right(state.copy(remoteContexts = Set.empty)) + ) + } + + implicit class ResourceEventTestOps(event: ResourceEvent) { + def noRemoteContext: ResourceEvent = event match { + case r: ResourceCreated => r.copy(remoteContexts = Set.empty) + case r: ResourceUpdated => r.copy(remoteContexts = Set.empty) + case r: ResourceRefreshed => r.copy(remoteContexts = Set.empty) + case r => r + } + } + } diff --git a/delta/sourcing-psql/src/main/resources/scripts/postgres/init/V1_09_M05_002__remote_contexts.ddl b/delta/sourcing-psql/src/main/resources/scripts/postgres/init/V1_09_M05_002__remote_contexts.ddl deleted file mode 100644 index 858030423e..0000000000 --- a/delta/sourcing-psql/src/main/resources/scripts/postgres/init/V1_09_M05_002__remote_contexts.ddl +++ /dev/null @@ -1,14 +0,0 @@ -------------------------------------------------- --- Add empty remoteContexts field to resources -- -------------------------------------------------- -UPDATE public.scoped_events -SET value = value || '{"remoteContexts": []}' -WHERE type = 'resource' -AND value ->> 'remoteContexts' is null -AND value ->> '@type' in ('ResourceCreated', 'ResourceUpdated', 'ResourceRefreshed'); - -UPDATE public.scoped_states -SET value = value || '{"remoteContexts": []}' -WHERE type = 'resource' -AND value ->> 'remoteContexts' is null -