From b90801e8ac5c5c73fb9f46c786440099e073097b Mon Sep 17 00:00:00 2001 From: dantb Date: Thu, 21 Sep 2023 16:38:09 +0200 Subject: [PATCH] Add organization deletion route - delete scoped partitions and global --- .../delta/routes/OrganizationsRoutes.scala | 30 +++-- .../delta/wiring/OrganizationsModule.scala | 8 +- .../routes/OrganizationsRoutesSpec.scala | 51 +++++++- .../bluebrain/nexus/delta/sdk/acls/Acls.scala | 20 +-- .../nexus/delta/sdk/acls/AclsImpl.scala | 2 +- .../organizations/OrganizationDeleter.scala | 65 ++++++++++ .../sdk/organizations/Organizations.scala | 4 +- .../sdk/organizations/OrganizationsImpl.scala | 14 ++- .../model/OrganizationRejection.scala | 12 +- .../delta/sdk/permissions/Permissions.scala | 2 + .../nexus/delta/sdk/ConfigFixtures.scala | 2 + .../OrganizationDeleterSpec.scala | 115 ++++++++++++++++++ .../organizations/OrganizationsImplSpec.scala | 23 ++-- 13 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleter.scala create mode 100644 delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleterSpec.scala diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutes.scala index ec13f0c459..cc6bdee68b 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutes.scala @@ -1,8 +1,9 @@ package ch.epfl.bluebrain.nexus.delta.routes import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directive1 import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.{Directive1, Route} +import akka.http.scaladsl.server.Route import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder @@ -11,18 +12,22 @@ import ch.epfl.bluebrain.nexus.delta.routes.OrganizationsRoutes.OrganizationInpu import ch.epfl.bluebrain.nexus.delta.sdk.OrganizationResource import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck 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.directives.{AuthDirectives, DeltaSchemeDirectives} +import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaSchemeDirectives import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ 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.model.search.SearchParams.OrganizationSearchParams +import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults._ -import ch.epfl.bluebrain.nexus.delta.sdk.model.search.{PaginationConfig, SearchResults} +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationDeleter import ch.epfl.bluebrain.nexus.delta.sdk.organizations.Organizations +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.Organization +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection._ -import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.{Organization, OrganizationRejection} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions._ import io.circe.Decoder import io.circe.generic.extras.Configuration @@ -47,6 +52,7 @@ import scala.annotation.nowarn final class OrganizationsRoutes( identities: Identities, organizations: Organizations, + orgDeleter: OrganizationDeleter, aclCheck: AclCheck, schemeDirectives: DeltaSchemeDirectives )(implicit @@ -115,8 +121,17 @@ final class OrganizationsRoutes( }, // Deprecate organization delete { - authorizeFor(id, orgs.write).apply { - parameter("rev".as[Int]) { rev => emit(organizations.deprecate(id, rev).mapValue(_.metadata)) } + parameter("rev".as[Int].?, "prune".?(false)) { + case (_, true) => + authorizeFor(id, orgs.delete).apply { + emit(orgDeleter.delete(id).leftWiden[OrganizationRejection]) + } + case (Some(rev), false) => + authorizeFor(id, orgs.write).apply { + emit(organizations.deprecate(id, rev).mapValue(_.metadata)) + } + case (None, false) => + complete(StatusCodes.BadRequest) } } ) @@ -154,6 +169,7 @@ object OrganizationsRoutes { def apply( identities: Identities, organizations: Organizations, + orgDeleter: OrganizationDeleter, aclCheck: AclCheck, schemeDirectives: DeltaSchemeDirectives )(implicit @@ -163,6 +179,6 @@ object OrganizationsRoutes { cr: RemoteContextResolution, ordering: JsonKeyOrdering ): Route = - new OrganizationsRoutes(identities, organizations, aclCheck, schemeDirectives).routes + new OrganizationsRoutes(identities, organizations, orgDeleter, aclCheck, schemeDirectives).routes } diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/OrganizationsModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/OrganizationsModule.scala index 41f86ccf7f..611f5eb5d5 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/OrganizationsModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/OrganizationsModule.scala @@ -20,6 +20,7 @@ import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} import monix.bio.UIO import monix.execution.Scheduler +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationDeleter /** * Organizations module wiring config. @@ -43,10 +44,15 @@ object OrganizationsModule extends ModuleDef { )(clock, uuidF) } + make[OrganizationDeleter].from { (xas: Transactors) => + OrganizationDeleter(xas) + } + make[OrganizationsRoutes].from { ( identities: Identities, organizations: Organizations, + orgDeleter: OrganizationDeleter, cfg: AppConfig, aclCheck: AclCheck, schemeDirectives: DeltaSchemeDirectives, @@ -54,7 +60,7 @@ object OrganizationsModule extends ModuleDef { cr: RemoteContextResolution @Id("aggregate"), ordering: JsonKeyOrdering ) => - new OrganizationsRoutes(identities, organizations, aclCheck, schemeDirectives)( + new OrganizationsRoutes(identities, organizations, orgDeleter, aclCheck, schemeDirectives)( cfg.http.baseUri, cfg.organizations.pagination, s, diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutesSpec.scala index fc59eb1aaf..6c738f285e 100644 --- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutesSpec.scala +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/OrganizationsRoutesSpec.scala @@ -3,7 +3,8 @@ package ch.epfl.bluebrain.nexus.delta.routes import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.headers.OAuth2BearerToken import akka.http.scaladsl.server.Route -import ch.epfl.bluebrain.nexus.delta.kernel.utils.{UUIDF, UrlUtils} +import ch.epfl.bluebrain.nexus.delta.kernel.utils.UUIDF +import ch.epfl.bluebrain.nexus.delta.kernel.utils.UrlUtils import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress @@ -12,14 +13,22 @@ import ch.epfl.bluebrain.nexus.delta.sdk.generators.OrganizationGen import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesDummy import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ -import ch.epfl.bluebrain.nexus.delta.sdk.organizations.{OrganizationsConfig, OrganizationsImpl} -import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{events, orgs => orgsPermissions} +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationsConfig +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationDeleter +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationsImpl +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.OrganizationNonEmpty +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.events +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{orgs => orgsPermissions} import ch.epfl.bluebrain.nexus.delta.sdk.projects.OwnerPermissionsScopeInitialization import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec -import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Anonymous +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Authenticated +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Group +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap import io.circe.Json +import monix.bio.IO import java.util.UUID @@ -38,7 +47,11 @@ class OrganizationsRoutesSpec extends BaseRouteSpec with IOFromMap { aclChecker.append, Set(orgsPermissions.write, orgsPermissions.read) ) - private lazy val orgs = OrganizationsImpl(Set(aopd), config, xas) + + private lazy val orgs = OrganizationsImpl(Set(aopd), config, xas) + private lazy val orgDeleter: OrganizationDeleter = id => + if (id == org1.label) IO.raiseError(OrganizationNonEmpty(id)) + else IO.unit private val caller = Caller(alice, Set(alice, Anonymous, Authenticated(realm), Group("group", realm))) @@ -48,6 +61,7 @@ class OrganizationsRoutesSpec extends BaseRouteSpec with IOFromMap { OrganizationsRoutes( identities, orgs, + orgDeleter, aclChecker, DeltaSchemeDirectives.onlyResolveOrgUuid(ioFromMap(fixedUuid -> org1.label)) ) @@ -212,6 +226,33 @@ class OrganizationsRoutesSpec extends BaseRouteSpec with IOFromMap { } } + "fail to deprecate an organization if the revision is omitted" in { + Delete("/v1/orgs/org2") ~> addCredentials(OAuth2BearerToken("alice")) ~> routes ~> check { + status shouldEqual StatusCodes.BadRequest + } + } + + "delete an organization" in { + aclChecker.append(AclAddress.fromOrg(org2.label), caller.subject -> Set(orgsPermissions.delete)).accepted + Delete("/v1/orgs/org2?prune=true") ~> addCredentials(OAuth2BearerToken("alice")) ~> routes ~> check { + status shouldEqual StatusCodes.OK + } + } + + "fail when trying to delete a non-empty organization" in { + aclChecker.append(AclAddress.fromOrg(org1.label), caller.subject -> Set(orgsPermissions.delete)).accepted + Delete("/v1/orgs/org1?prune=true") ~> addCredentials(OAuth2BearerToken("alice")) ~> routes ~> check { + status shouldEqual StatusCodes.Conflict + } + } + + "fail to delete an organization without organizations/delete permission" in { + aclChecker.subtract(AclAddress.fromOrg(org2.label), caller.subject -> Set(orgsPermissions.delete)).accepted + Delete("/v1/orgs/org2?prune=true") ~> addCredentials(OAuth2BearerToken("alice")) ~> routes ~> check { + status shouldEqual StatusCodes.Forbidden + } + } + "fail fetch an organization without organizations/read permission" in { aclChecker.delete(Label.unsafe("org1")).accepted forAll( diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/Acls.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/Acls.scala index c7ae80dea0..bf11533efd 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/Acls.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/Acls.scala @@ -25,7 +25,7 @@ import java.time.Instant /** * Operations pertaining to managing Access Control Lists. */ -trait Acls { +trait Acls extends PurgeAcl { /** * Fetches the ACL resource for an ''address'' on the current revision. @@ -190,6 +190,7 @@ trait Acls { */ def subtract(acl: Acl, rev: Int)(implicit caller: Subject): IO[AclRejection, AclResource] + // DTB TODO - is this more of a deprecate than a delete? /** * Delete all ''acl'' on the passed ''address''. * @@ -200,17 +201,20 @@ trait Acls { */ def delete(address: AclAddress, rev: Int)(implicit caller: Subject): IO[AclRejection, AclResource] - /** - * Hard deletes events and states for the given acl address This is meant to be used internally for the project - * deletion feature - */ - def internalDelete(project: AclAddress): UIO[Unit] - private def filterSelf(resource: AclResource)(implicit caller: Caller): AclResource = resource.map(_.filter(caller.identities)) } +trait PurgeAcl { + + /** + * Hard deletes events and states for the given acl address. This is meant to be used internally for project and + * organization deletion. + */ + def purge(project: AclAddress): UIO[Unit] +} + object Acls { /** @@ -363,7 +367,7 @@ object Acls { def projectDeletionTask(acls: Acls): ProjectDeletionTask = new ProjectDeletionTask { override def apply(project: ProjectRef)(implicit subject: Subject): Task[ProjectDeletionReport.Stage] = acls - .internalDelete(project) + .purge(project) .as( ProjectDeletionReport.Stage("acls", "The acl has been deleted.") ) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImpl.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImpl.scala index 234dbedcae..a002f89ece 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImpl.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImpl.scala @@ -89,7 +89,7 @@ final class AclsImpl private ( private def eval(cmd: AclCommand): IO[AclRejection, AclResource] = log.evaluate(cmd.address, cmd).map(_._2.toResource) - override def internalDelete(project: AclAddress): UIO[Unit] = log.delete(project) + override def purge(project: AclAddress): UIO[Unit] = log.delete(project) } object AclsImpl { diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleter.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleter.scala new file mode 100644 index 0000000000..ad77a942f8 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleter.scala @@ -0,0 +1,65 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.organizations + +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode +import ch.epfl.bluebrain.nexus.delta.sdk.acls.Acls +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.OrganizationNonEmpty +import ch.epfl.bluebrain.nexus.delta.sourcing.{MD5, Transactors} +import ch.epfl.bluebrain.nexus.delta.sourcing.implicits._ +import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityType +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import com.typesafe.scalalogging.Logger +import doobie.{ConnectionIO, Update} +import doobie.implicits._ +import doobie.util.update.Update0 +import monix.bio.IO +import monix.bio.UIO + +trait OrganizationDeleter { + def delete(id: Label): IO[OrganizationNonEmpty, Unit] +} + +object OrganizationDeleter { + + def apply(xas: Transactors): OrganizationDeleter = new OrganizationDeleter { + + private val logger: Logger = Logger[OrganizationDeleter] + + def delete(id: Label): IO[OrganizationNonEmpty, Unit] = + for { + orgIsEmpty <- orgIsEmpty(id) + _ <- if (orgIsEmpty) log(s"Deleting empty organization $id") *> deleteAll(id) + else IO.raiseError(OrganizationNonEmpty(id)) + } yield () + + private def log(msg: String): UIO[Unit] = UIO.delay(logger.info(msg)) + + private def deleteAll(id: Label): UIO[Unit] = + (for { + _ <- List("scoped_events", "scoped_states").traverse(dropPartition(id, _)).void + _ <- List("global_events", "global_states").traverse(deleteGlobal(id, _)) + } yield ()).transact(xas.write).void.hideErrors + + private def dropPartition(id: Label, table: String): ConnectionIO[Unit] = + Update0(s"DROP TABLE IF EXISTS ${table}_${MD5.hash(id.value)}", None).run.void + + private def deleteGlobal(id: Label, table: String): ConnectionIO[Unit] = + for { + _ <- deleteGlobalQuery(Organizations.encodeId(id), Organizations.entityType, table) + _ <- deleteGlobalQuery(Acls.encodeId(AclAddress.fromOrg(id)), Acls.entityType, table) + } yield () + + private def deleteGlobalQuery(id: IriOrBNode.Iri, tpe: EntityType, table: String): ConnectionIO[Unit] = + Update[(EntityType, IriOrBNode.Iri)](s"DELETE FROM $table WHERE" ++ " type = ? AND id = ?").run((tpe, id)).void + + private def orgIsEmpty(id: Label): UIO[Boolean] = + sql"SELECT type from scoped_events WHERE org = $id LIMIT 1" + .query[Label] + .option + .map(_.isEmpty) + .transact(xas.read) + .hideErrors + } + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/Organizations.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/Organizations.scala index 7bb8118835..f659111b0a 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/Organizations.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/Organizations.scala @@ -195,8 +195,8 @@ object Organizations { command match { case c: CreateOrganization => create(c) - case c: UpdateOrganization => update(c) - case c: DeprecateOrganization => deprecate(c) + case u: UpdateOrganization => update(u) + case d: DeprecateOrganization => deprecate(d) } } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImpl.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImpl.scala index 9548d79a45..34924899a5 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImpl.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImpl.scala @@ -4,18 +4,24 @@ import cats.effect.Clock import ch.epfl.bluebrain.nexus.delta.kernel.kamon.KamonMetricComponent import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination import ch.epfl.bluebrain.nexus.delta.kernel.utils.UUIDF -import ch.epfl.bluebrain.nexus.delta.sdk.model.search.{SearchParams, SearchResults} +import ch.epfl.bluebrain.nexus.delta.sdk.OrganizationResource +import ch.epfl.bluebrain.nexus.delta.sdk.ScopeInitialization +import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchParams +import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults import ch.epfl.bluebrain.nexus.delta.sdk.organizations.Organizations.entityType import ch.epfl.bluebrain.nexus.delta.sdk.organizations.OrganizationsImpl.OrganizationsLog +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationCommand import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationCommand._ +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationEvent +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection._ -import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.{OrganizationCommand, OrganizationEvent, OrganizationRejection, OrganizationState} +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationState import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ -import ch.epfl.bluebrain.nexus.delta.sdk.{OrganizationResource, ScopeInitialization} import ch.epfl.bluebrain.nexus.delta.sourcing._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label -import monix.bio.{IO, UIO} +import monix.bio.IO +import monix.bio.UIO final class OrganizationsImpl private ( log: OrganizationsLog, diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/model/OrganizationRejection.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/model/OrganizationRejection.scala index 43c61a61a8..76ba67d9d3 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/model/OrganizationRejection.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/model/OrganizationRejection.scala @@ -68,7 +68,7 @@ object OrganizationRejection { ) /** - * Signals and attempt to update/deprecate an organization that is already deprecated. + * Signals an attempt to update/deprecate an organization that is already deprecated. * * @param label * the label of the organization @@ -76,6 +76,15 @@ object OrganizationRejection { final case class OrganizationIsDeprecated(label: Label) extends OrganizationRejection(s"Organization '$label' is deprecated.") + /** + * Signals an attempt to delete an organization that contains at least one project. + * + * @param label + * the label of the organization + */ + final case class OrganizationNonEmpty(label: Label) + extends OrganizationRejection(s"Organization '$label' cannot be deleted since it contains at least one project.") + /** * Rejection returned when the organization initialization could not be performed. * @@ -108,6 +117,7 @@ object OrganizationRejection { case OrganizationRejection.OrganizationNotFound(_) => StatusCodes.NotFound case OrganizationRejection.OrganizationAlreadyExists(_) => StatusCodes.Conflict case OrganizationRejection.IncorrectRev(_, _) => StatusCodes.Conflict + case OrganizationRejection.OrganizationNonEmpty(_) => StatusCodes.Conflict case OrganizationRejection.RevisionNotFound(_, _) => StatusCodes.NotFound case _ => StatusCodes.BadRequest } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala index 7307f7c3b6..ad94177c27 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala @@ -157,6 +157,8 @@ object Permissions { final val read: Permission = Permission.unsafe("organizations/read") final val write: Permission = Permission.unsafe("organizations/write") final val create: Permission = Permission.unsafe("organizations/create") + // TODO setup this permission + final val delete: Permission = Permission.unsafe("organizations/delete") } /** diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala index 37aad0f789..540ccdb6fa 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/ConfigFixtures.scala @@ -37,4 +37,6 @@ trait ConfigFixtures { 1.second, RetryStrategyConfig.AlwaysGiveUp ) + + def logConfig: EventLogConfig = EventLogConfig(queryConfig, 10.seconds) } diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleterSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleterSpec.scala new file mode 100644 index 0000000000..d46043eb16 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleterSpec.scala @@ -0,0 +1,115 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.organizations + +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.utils.UUIDF +import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.generators.ProjectGen.defaultApiMappings +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.{Organization, OrganizationCommand} +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.{OrganizationNonEmpty, OrganizationNotFound} +import ch.epfl.bluebrain.nexus.delta.sdk.projects.Projects.FetchOrganization +import ch.epfl.bluebrain.nexus.delta.sdk.projects.{ProjectsConfig, ProjectsFixture} +import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectRejection.WrappedOrganizationRejection +import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, ProjectFields} +import ch.epfl.bluebrain.nexus.delta.sourcing.{GlobalEventLog, MD5} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Identity, Label, ProjectRef} +import ch.epfl.bluebrain.nexus.delta.sourcing.offset.Offset +import ch.epfl.bluebrain.nexus.testkit.IOFixedClock +import ch.epfl.bluebrain.nexus.testkit.bio.BioSuite +import doobie.implicits._ +import doobie.util.update.Update0 +import monix.bio.{IO, Task, UIO} +import munit.AnyFixture + +import java.util.UUID + +class OrganizationDeleterSpec extends BioSuite with IOFixedClock with ConfigFixtures { + + private val org = Label.unsafe("org") + private val orgUuid = UUID.randomUUID() + + private def fetchOrg: FetchOrganization = { + case `org` => UIO.pure(Organization(org, orgUuid, None)) + case other => IO.raiseError(WrappedOrganizationRejection(OrganizationNotFound(other))) + } + + private val config = ProjectsConfig(eventLogConfig, pagination, cacheConfig, deletionConfig) + private val projectFixture = ProjectsFixture.init(fetchOrg, defaultApiMappings, config) + + override def munitFixtures: Seq[AnyFixture[_]] = List(projectFixture) + + private lazy val (xas, projects) = projectFixture() + private lazy val orgDeleter = OrganizationDeleter(xas) + private val projRef = ProjectRef.unsafe("org", "myproj") + private val fields = ProjectFields(None, ApiMappings.empty, None, None) + private lazy val orgs = GlobalEventLog(Organizations.definition, logConfig, xas) + + implicit val subject: Subject = Identity.User("Bob", Label.unsafe("realm")) + implicit val uuidF: UUIDF = UUIDF.fixed(orgUuid) + + test("Fail when trying to delete a non-empty organization") { + val arrange: IO[Any, Unit] = for { + _ <- truncateTables + _ <- orgs.evaluate(org, OrganizationCommand.CreateOrganization(org, None, Identity.Anonymous)) + _ <- projects.create(projRef, fields) + } yield () + + val act: UIO[Either[OrganizationNonEmpty, Unit]] = + orgDeleter.delete(org).attempt + + def assert(result: Either[OrganizationNonEmpty, Unit]): IO[Any, Unit] = for { + eventPartitions <- queryPartitions("scoped_events") + statePartitions <- queryPartitions("scoped_states") + fetchedProject <- projects.fetch(projRef) + globalOrgEvent <- orgs.currentEvents(Offset.Start).compile.to(List).map(_.map(_.value.uuid)) + globalOrgState <- orgs.currentStates(Offset.Start).compile.to(List).map(_.map(_.value.uuid)) + } yield { + assertEquals(result, Left(OrganizationNonEmpty(org))) + val orgPartition = MD5.hash(org.value) + assertEquals(eventPartitions.headOption, Some(s"scoped_events_$orgPartition")) + assertEquals(statePartitions.headOption, Some(s"scoped_states_$orgPartition")) + assertEquals(fetchedProject.value.ref, projRef) + assertEquals(globalOrgState, List(orgUuid)) + assertEquals(globalOrgEvent, List(orgUuid)) + } + + arrange >> act >>= assert + } + + test("Successfully delete an empty organization") { + val arrange: IO[Any, Unit] = for { + _ <- truncateTables + _ <- orgs.evaluate(org, OrganizationCommand.CreateOrganization(org, None, Identity.Anonymous)) + } yield () + + val act: UIO[Either[OrganizationNonEmpty, Unit]] = + orgDeleter.delete(org).attempt + + def assert(result: Either[OrganizationNonEmpty, Unit]): IO[Any, Unit] = for { + globalEventsDeleted <- orgs.currentEvents(Offset.Start).compile.to(List).map(_.isEmpty) + globalStateDeleted <- orgs.currentStates(Offset.Start).compile.to(List).map(_.isEmpty) + eventPartitionDeleted <- queryPartitions("scoped_events").map(_.isEmpty) + statePartitionDeleted <- queryPartitions("scoped_states").map(_.isEmpty) + } yield { + assertEquals(result, Right(())) + assertEquals(eventPartitionDeleted, true) + assertEquals(statePartitionDeleted, true) + assertEquals(globalStateDeleted, true) + assertEquals(globalEventsDeleted, true) + } + + arrange >> act >>= assert + } + + def truncateTables: UIO[Unit] = + List("global_events", "global_states", "scoped_events", "scoped_states") + .traverse(t => Update0(s"DELETE FROM $t", None).run.transact(xas.write)) + .void + .hideErrors + + def queryPartitions(table: String): Task[List[String]] = + sql"""SELECT inhrelid::regclass AS child + FROM pg_catalog.pg_inherits + WHERE inhparent = $table::regclass + """.query[String].to[List].transact(xas.read) +} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImplSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImplSpec.scala index 5a744757a2..9c7ffeba64 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImplSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationsImplSpec.scala @@ -1,23 +1,32 @@ package ch.epfl.bluebrain.nexus.delta.sdk.organizations +import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination.FromPagination import ch.epfl.bluebrain.nexus.delta.kernel.utils.UUIDF -import ch.epfl.bluebrain.nexus.delta.sdk.generators.OrganizationGen.{organization, resourceFor} +import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.ScopeInitializationLog +import ch.epfl.bluebrain.nexus.delta.sdk.generators.OrganizationGen.organization +import ch.epfl.bluebrain.nexus.delta.sdk.generators.OrganizationGen.resourceFor import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceF -import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination.FromPagination import ch.epfl.bluebrain.nexus.delta.sdk.model.search.ResultEntry.UnscoredResultEntry import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchParams.OrganizationSearchParams import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults.UnscoredSearchResults import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.Organization -import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.{IncorrectRev, OrganizationAlreadyExists, OrganizationIsDeprecated, OrganizationNotFound, RevisionNotFound} -import ch.epfl.bluebrain.nexus.delta.sdk.{ConfigFixtures, ScopeInitializationLog} +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.IncorrectRev +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.OrganizationAlreadyExists +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.OrganizationIsDeprecated +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.OrganizationNotFound +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.RevisionNotFound +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject -import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Identity, Label} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.DoobieScalaTestFixture -import ch.epfl.bluebrain.nexus.testkit.{IOFixedClock, IOValues} +import ch.epfl.bluebrain.nexus.testkit.IOFixedClock +import ch.epfl.bluebrain.nexus.testkit.IOValues import monix.bio.UIO import monix.execution.Scheduler +import org.scalatest.CancelAfterFailure +import org.scalatest.OptionValues import org.scalatest.matchers.should.Matchers -import org.scalatest.{CancelAfterFailure, OptionValues} import java.time.Instant import java.util.UUID