From eb9fde25ce31dd90cf85906ad834857510d7fa72 Mon Sep 17 00:00:00 2001 From: Daniel Bell Date: Sat, 30 Sep 2023 19:17:36 +0100 Subject: [PATCH] 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 + } + } + } }