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..000e2860ed 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 @@ -10,6 +10,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.routes.OrganizationsRoutes.OrganizationInput import ch.epfl.bluebrain.nexus.delta.sdk.OrganizationResource import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.ce.DeltaDirectives.{emit => emitCE} import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.directives.{AuthDirectives, DeltaSchemeDirectives} @@ -20,9 +21,9 @@ import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri 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.{PaginationConfig, SearchResults} -import ch.epfl.bluebrain.nexus.delta.sdk.organizations.Organizations 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.organizations.{OrganizationDeleter, Organizations} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions._ import io.circe.Decoder import io.circe.generic.extras.Configuration @@ -47,6 +48,7 @@ import scala.annotation.nowarn final class OrganizationsRoutes( identities: Identities, organizations: Organizations, + orgDeleter: OrganizationDeleter, aclCheck: AclCheck, schemeDirectives: DeltaSchemeDirectives )(implicit @@ -113,11 +115,20 @@ final class OrganizationsRoutes( } } }, - // Deprecate organization + // Deprecate or delete organization delete { - authorizeFor(id, orgs.write).apply { - parameter("rev".as[Int]) { rev => emit(organizations.deprecate(id, rev).mapValue(_.metadata)) } - } + concat( + parameter("rev".as[Int]) { rev => + authorizeFor(id, orgs.write).apply { + emit(organizations.deprecate(id, rev).mapValue(_.metadata)) + } + }, + parameter("prune".requiredValue(true)) { _ => + authorizeFor(id, orgs.delete).apply { + emitCE(orgDeleter.delete(id).attemptNarrow[OrganizationRejection]) + } + } + ) } ) } @@ -154,6 +165,7 @@ object OrganizationsRoutes { def apply( identities: Identities, organizations: Organizations, + orgDeleter: OrganizationDeleter, aclCheck: AclCheck, schemeDirectives: DeltaSchemeDirectives )(implicit @@ -163,6 +175,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/DeltaModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala index 40f4a1e011..ce4b3414cb 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala @@ -62,6 +62,8 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class make[StrictEntity].from { appCfg.http.strictEntityTimeout } make[ServiceAccount].from { appCfg.serviceAccount.value } + implicit val scheduler: Scheduler = Scheduler.global + make[Transactors].fromResource { Transactors.init(appCfg.database) } @@ -104,7 +106,7 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class make[Clock[IO]].from(Clock.create[IO]) make[Timer[IO]].from(IO.timer(ExecutionContext.global)) make[UUIDF].from(UUIDF.random) - make[Scheduler].from(Scheduler.global) + make[Scheduler].from(scheduler) make[JsonKeyOrdering].from( JsonKeyOrdering.default(topKeys = List("@context", "@id", "@type", "reason", "details", "sourceId", "projectionId", "_total", "_results") 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 02560abb53..339dfca586 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 @@ -13,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 cats.effect.IO import java.util.UUID @@ -39,7 +47,9 @@ class OrganizationsRoutesSpec extends BaseRouteSpec with IOFromMap { acl => aclChecker.append(acl), 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 => IO.raiseWhen(id == org1.label)(OrganizationNonEmpty(id)) private val caller = Caller(alice, Set(alice, Anonymous, Authenticated(realm), Group("group", realm))) @@ -49,6 +59,7 @@ class OrganizationsRoutesSpec extends BaseRouteSpec with IOFromMap { OrganizationsRoutes( identities, orgs, + orgDeleter, aclChecker, DeltaSchemeDirectives.onlyResolveOrgUuid(ioFromMap(fixedUuid -> org1.label)) ) @@ -213,6 +224,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..4874e47fb1 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 @@ -201,10 +201,10 @@ 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 + * Hard deletes events and states for the given acl address. This is meant to be used internally for project and + * organization deletion. */ - def internalDelete(project: AclAddress): UIO[Unit] + def purge(project: AclAddress): UIO[Unit] private def filterSelf(resource: AclResource)(implicit caller: Caller): AclResource = resource.map(_.filter(caller.identities)) @@ -363,7 +363,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..7ab8650711 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleter.scala @@ -0,0 +1,61 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.organizations + +import cats.effect.IO +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.Logger +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.model.{EntityType, Label} +import ch.epfl.bluebrain.nexus.delta.sourcing.{PartitionInit, Transactors} +import doobie.implicits._ +import ch.epfl.bluebrain.nexus.delta.sourcing.implicits._ +import doobie.util.update.Update0 +import doobie.{ConnectionIO, Update} +import org.typelevel.log4cats.{Logger => Log4CatsLogger} + +trait OrganizationDeleter { + def delete(id: Label): IO[Unit] +} + +object OrganizationDeleter { + + def apply(xas: Transactors): OrganizationDeleter = new OrganizationDeleter { + + private val log: Log4CatsLogger[IO] = Logger.cats[OrganizationDeleter] + + def delete(id: Label): IO[Unit] = + for { + canDelete <- orgIsEmpty(id) + _ <- if (canDelete) log.info(s"Deleting empty organization $id") *> deleteAll(id) + else log.error(s"Failed to delete empty organization $id") *> IO.raiseError(OrganizationNonEmpty(id)) + } yield () + + private def deleteAll(id: Label): IO[Unit] = + (for { + _ <- List("scoped_events", "scoped_states").traverse(dropPartition(id, _)) + _ <- List("global_events", "global_states").traverse(deleteGlobal(id, _)) + } yield ()).transact(xas.writeCE).void + + private def dropPartition(id: Label, table: String): ConnectionIO[Unit] = + Update0(s"DROP TABLE IF EXISTS ${PartitionInit.orgPartition(table, id)}", 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): IO[Boolean] = + sql"SELECT type from scoped_events WHERE org = $id LIMIT 1" + .query[Label] + .option + .map(_.isEmpty) + .transact(xas.readCE) + } + +} 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 a816e2bc26..02b97091f4 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 @@ -5,18 +5,24 @@ import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ 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..68710c7ff4 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 @@ -18,7 +18,7 @@ import io.circe.{Encoder, JsonObject} * @param reason * a descriptive message as to why the rejection occurred */ -sealed abstract class OrganizationRejection(val reason: String) extends Product with Serializable +sealed abstract class OrganizationRejection(val reason: String) extends Exception(reason) with Product with Serializable object OrganizationRejection { @@ -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..0f919a4fb8 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,7 @@ 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") + 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..bad3229faa 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 @@ -3,6 +3,7 @@ package ch.epfl.bluebrain.nexus.delta.sdk import akka.http.scaladsl.model.Uri import ch.epfl.bluebrain.nexus.delta.kernel.RetryStrategyConfig import ch.epfl.bluebrain.nexus.delta.kernel.cache.CacheConfig +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclsConfig import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig import ch.epfl.bluebrain.nexus.delta.sdk.http.{HttpClientConfig, HttpClientWorthRetry} import ch.epfl.bluebrain.nexus.delta.sdk.model.search.PaginationConfig @@ -20,6 +21,8 @@ trait ConfigFixtures { def eventLogConfig: EventLogConfig = EventLogConfig(queryConfig, 5.seconds) + def aclsConfig: AclsConfig = AclsConfig(eventLogConfig) + def pagination: PaginationConfig = PaginationConfig( defaultSize = 30, @@ -37,4 +40,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/OrganizationDeleterSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleterSuite.scala new file mode 100644 index 0000000000..fe129c61c8 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/organizations/OrganizationDeleterSuite.scala @@ -0,0 +1,118 @@ +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.acls.AclsImpl +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.{Acl, AclAddress} +import ch.epfl.bluebrain.nexus.delta.sdk.generators.ProjectGen.defaultApiMappings +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.Organization +import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection.{OrganizationNonEmpty, OrganizationNotFound} +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions +import ch.epfl.bluebrain.nexus.delta.sdk.projects.Projects.FetchOrganization +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.sdk.projects.{ProjectsConfig, ProjectsFixture} +import ch.epfl.bluebrain.nexus.delta.sourcing.PartitionInit +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.testkit.IOFixedClock +import ch.epfl.bluebrain.nexus.testkit.bio.BioSuite +import doobie.implicits._ +import monix.bio.{IO, Task, UIO} +import munit.AnyFixture + +import java.util.UUID + +class OrganizationDeleterSuite extends BioSuite with IOFixedClock with ConfigFixtures { + + private val org1 = Label.unsafe("org1") + private val org2 = Label.unsafe("org2") + + private def fetchOrg: FetchOrganization = { + case `org1` => UIO.pure(Organization(org1, UUID.randomUUID(), None)) + case `org2` => UIO.pure(Organization(org2, UUID.randomUUID(), None)) + case other => IO.raiseError(WrappedOrganizationRejection(OrganizationNotFound(other))) + } + + private val config = ProjectsConfig(eventLogConfig, pagination, cacheConfig, deletionConfig) + private val orgConfig = OrganizationsConfig(eventLogConfig, pagination, cacheConfig) + private lazy 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(org1.value, "myproj") + private val fields = ProjectFields(None, ApiMappings.empty, None, None) + private lazy val orgs = OrganizationsImpl(Set(), orgConfig, xas) + private val permission = Permissions.resources.read + private lazy val acls = AclsImpl(UIO.pure(Set(permission)), _ => IO.unit, Set(), aclsConfig, xas) + + implicit val subject: Subject = Identity.User("Bob", Label.unsafe("realm")) + implicit val uuidF: UUIDF = UUIDF.fixed(UUID.randomUUID()) + + test("Fail when trying to delete a non-empty organization") { + for { + _ <- createOrgAndAcl(org1) + _ <- createProj() + result <- deleteOrg(org1) + _ <- assertDeletionFailed(result) + } yield () + } + + test("Successfully delete an empty organization") { + for { + _ <- createOrgAndAcl(org2) + result <- deleteOrg(org2) + _ <- assertPartitionsAndDataIsDeleted(result) + } yield () + } + + def createOrgAndAcl(org: Label) = for { + _ <- acls.replace(Acl(AclAddress.fromOrg(org), subject -> Set(permission)), 0) + _ <- orgs.create(org, None) + } yield () + + def createProj() = projects.create(projRef, fields) + + def deleteOrg(org: Label): UIO[Either[OrganizationNonEmpty, Unit]] = + IO.from(orgDeleter.delete(org).attemptNarrow[OrganizationNonEmpty]).hideErrors + + def assertDeletionFailed(result: Either[OrganizationNonEmpty, Unit]) = for { + eventPartitionDeleted <- orgPartitionIsDeleted("scoped_events", org1) + statePartitionDeleted <- orgPartitionIsDeleted("scoped_states", org1) + fetchedProject <- projects.fetch(projRef) + orgResult <- orgs.fetch(org1).map(_.value.label) + aclExists <- acls.fetch(AclAddress.fromOrg(org1)).attempt.map(_.isRight) + } yield { + assertEquals(result, Left(OrganizationNonEmpty(org1))) + assertEquals(eventPartitionDeleted, false) + assertEquals(statePartitionDeleted, false) + assertEquals(fetchedProject.value.ref, projRef) + assertEquals(orgResult, org1) + assertEquals(aclExists, true) + } + + def assertPartitionsAndDataIsDeleted(result: Either[OrganizationNonEmpty, Unit]) = for { + orgResult <- orgs.fetch(org2).attempt + eventPartitionDeleted <- orgPartitionIsDeleted("scoped_events", org2) + statePartitionDeleted <- orgPartitionIsDeleted("scoped_states", org2) + aclDeleted <- acls.fetch(AclAddress.fromOrg(org2)).attempt.map(_.isLeft) + } yield { + assertEquals(result, Right(())) + assertEquals(eventPartitionDeleted, true) + assertEquals(statePartitionDeleted, true) + assertEquals(orgResult, Left(OrganizationNotFound(org2))) + assertEquals(aclDeleted, true) + } + + def orgPartitionIsDeleted(table: String, org: Label): Task[Boolean] = + queryPartitions(table).map(!_.contains(PartitionInit.orgPartition(table, org))) + + 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 4fa17d8b97..9c2a103482 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 @@ -2,23 +2,31 @@ 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.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 import ch.epfl.bluebrain.nexus.testkit.ce.CatsIOValues 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 diff --git a/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/PartitionInit.scala b/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/PartitionInit.scala index 29d1a8b90c..4ce66adfe3 100644 --- a/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/PartitionInit.scala +++ b/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/PartitionInit.scala @@ -3,7 +3,7 @@ package ch.epfl.bluebrain.nexus.delta.sourcing import cats.implicits._ import Transactors.PartitionsCache import ch.epfl.bluebrain.nexus.delta.sourcing.PartitionInit.{createOrgPartition, createProjectPartition, projectRefHash} -import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef} import doobie.Fragment import doobie.free.connection import monix.bio.Task @@ -73,7 +73,7 @@ object PartitionInit { */ def createOrgPartition(mainTable: String, projectRef: ProjectRef): Fragment = Fragment.const(s""" - | CREATE TABLE IF NOT EXISTS ${orgPartition(mainTable, projectRef)} + | CREATE TABLE IF NOT EXISTS ${orgPartitionFromProj(mainTable, projectRef)} | PARTITION OF $mainTable FOR VALUES IN ('${projectRef.organization}') | PARTITION BY LIST (project); |""".stripMargin) @@ -89,7 +89,7 @@ object PartitionInit { def createProjectPartition(mainTable: String, projectRef: ProjectRef): Fragment = Fragment.const(s""" | CREATE TABLE IF NOT EXISTS ${projectRefPartition(mainTable, projectRef)} - | PARTITION OF ${orgPartition(mainTable, projectRef)} FOR VALUES IN ('${projectRef.project}') + | PARTITION OF ${orgPartitionFromProj(mainTable, projectRef)} FOR VALUES IN ('${projectRef.project}') |""".stripMargin) def projectRefHash(projectRef: ProjectRef): String = @@ -98,10 +98,13 @@ object PartitionInit { def projectRefPartition(mainTable: String, projectRef: ProjectRef) = s"${mainTable}_${projectRefHash(projectRef)}" - private def orgHash(projectRef: ProjectRef) = - MD5.hash(projectRef.organization.value) + def orgHash(orgId: Label): String = + MD5.hash(orgId.value) - private def orgPartition(mainTable: String, projectRef: ProjectRef) = - s"${mainTable}_${orgHash(projectRef)}" + def orgPartition(mainTable: String, orgId: Label) = + s"${mainTable}_${orgHash(orgId)}" + + private def orgPartitionFromProj(mainTable: String, projectRef: ProjectRef) = + orgPartition(mainTable, projectRef.organization) } diff --git a/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/Transactors.scala b/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/Transactors.scala index af9d26f9c7..0bda9251a3 100644 --- a/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/Transactors.scala +++ b/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/Transactors.scala @@ -1,6 +1,6 @@ package ch.epfl.bluebrain.nexus.delta.sourcing -import cats.effect.{Blocker, Resource} +import cats.effect.{Blocker, IO, Resource} import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.kernel.Secret import ch.epfl.bluebrain.nexus.delta.kernel.cache.{CacheConfig, KeyValueStore} @@ -15,7 +15,8 @@ import doobie.implicits._ import doobie.util.ExecutionContexts import doobie.util.transactor.Transactor import io.github.classgraph.ClassGraph -import monix.bio.Task +import monix.bio.{IO => BIO, Task} +import monix.execution.Scheduler import scala.concurrent.duration._ import scala.jdk.CollectionConverters._ @@ -28,7 +29,10 @@ final case class Transactors( write: Transactor[Task], streaming: Transactor[Task], cache: PartitionsCache -) { +)(implicit s: Scheduler) { + + def readCE: Transactor[IO] = read.mapK(BIO.liftTo) + def writeCE: Transactor[IO] = write.mapK(BIO.liftTo) def execDDL(ddl: String)(implicit cl: ClassLoader): Task[Unit] = ClasspathResourceUtils.ioContentOf(ddl).flatMap(Fragment.const0(_).update.run.transact(write)).void @@ -76,7 +80,9 @@ object Transactors { * @param password * the password */ - def test(host: String, port: Int, username: String, password: String): Resource[Task, Transactors] = { + def test(host: String, port: Int, username: String, password: String)(implicit + s: Scheduler + ): Resource[Task, Transactors] = { val access = DatabaseAccess(host, port, 10) val databaseConfig = DatabaseConfig( access, @@ -88,10 +94,10 @@ object Transactors { false, CacheConfig(500, 10.minutes) ) - init(databaseConfig)(getClass.getClassLoader) + init(databaseConfig)(getClass.getClassLoader, s) } - def init(config: DatabaseConfig)(implicit classLoader: ClassLoader): Resource[Task, Transactors] = { + def init(config: DatabaseConfig)(implicit classLoader: ClassLoader, s: Scheduler): Resource[Task, Transactors] = { def transactor(access: DatabaseAccess, readOnly: Boolean, poolName: String) = { for { ce <- ExecutionContexts.fixedThreadPool[Task](access.poolSize) diff --git a/delta/sourcing-psql/src/test/scala/ch/epfl/bluebrain/nexus/delta/sourcing/postgres/Doobie.scala b/delta/sourcing-psql/src/test/scala/ch/epfl/bluebrain/nexus/delta/sourcing/postgres/Doobie.scala index cc36b221bc..0a9cb108bc 100644 --- a/delta/sourcing-psql/src/test/scala/ch/epfl/bluebrain/nexus/delta/sourcing/postgres/Doobie.scala +++ b/delta/sourcing-psql/src/test/scala/ch/epfl/bluebrain/nexus/delta/sourcing/postgres/Doobie.scala @@ -13,6 +13,7 @@ import doobie.postgres.sqlstate import monix.bio.{IO, Task, UIO} import munit.Location import org.postgresql.util.PSQLException +import monix.execution.Scheduler.Implicits.global object Doobie { diff --git a/docs/src/main/paradox/docs/delta/api/assets/organizations/delete.sh b/docs/src/main/paradox/docs/delta/api/assets/organizations/delete.sh new file mode 100644 index 0000000000..6d65f15bb9 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/organizations/delete.sh @@ -0,0 +1,2 @@ +curl -X DELETE \ + "http://localhost:8080/v1/orgs/myorg?prune=true" \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/orgs-api.md b/docs/src/main/paradox/docs/delta/api/orgs-api.md index 96bda1f17b..d246431cd2 100644 --- a/docs/src/main/paradox/docs/delta/api/orgs-api.md +++ b/docs/src/main/paradox/docs/delta/api/orgs-api.md @@ -89,6 +89,22 @@ Request Response : @@snip [deprecated.json](assets/organizations/deprecated.json) +## Delete + +Delete a organization containing no projects. If there is a project, returns 409 Conflict. + +``` +DELETE /v1/orgs/{label}?prune=true +``` + +... where + +- `{label}`: String - is the user friendly name that identifies this organization. + +**Example** + +Request +: @@snip [delete.sh](assets/organizations/deprecate.sh) ## Fetch (current version) diff --git a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md index 500a18c6ce..5b2c7d0efe 100644 --- a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md +++ b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md @@ -135,6 +135,13 @@ Creating an archive now requires only the `resources/read` permission instead of Tarball archives are no longer supported due to unnecessary restrictions. ZIP is now the only allowed format and clients should send `application/zip` in the `Accept` header when creating archives. +### Organizations + +#### Support deletion of empty organizations +Previously it was only possible to deprecate organizations at a specific revision. Now organizations containing no projects can be deleted by specifying a prune parameter: `DELETE /v1/org/{label}?prune=true` + +@ref:[More information](../delta/api/orgs-api.md#delete) + ### Storages ### Remote Storages