Skip to content

Commit

Permalink
Add reference check on project / add endpoint back (BlueBrain#3859)
Browse files Browse the repository at this point in the history
* Add reference check on project / add endpoint back

---------

Co-authored-by: Simon Dumas <[email protected]>
  • Loading branch information
imsdu and Simon Dumas authored May 2, 2023
1 parent 28143d1 commit 8e4d4c2
Show file tree
Hide file tree
Showing 27 changed files with 298 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,15 @@ final class ProjectsRoutes(
},
// Deprecate/delete project
(delete & pathEndOrSingleSlash) {
parameters("rev".as[Int]) { rev =>
authorizeFor(ref, projectsPermissions.write).apply {
emit(projects.deprecate(ref, rev).mapValue(_.metadata))
}
parameters("rev".as[Int], "prune".?(false)) {
case (rev, true) if config.deletion.enabled =>
authorizeFor(ref, projectsPermissions.delete).apply {
emit(projects.delete(ref, rev).mapValue(_.metadata))
}
case (rev, _) =>
authorizeFor(ref, projectsPermissions.write).apply {
emit(projects.deprecate(ref, rev).mapValue(_.metadata))
}
}
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ object MigrationModule extends ModuleDef {

// Projects
many[MigrationLog].add { (cfg: AppConfig, xas: Transactors, clock: Clock[UIO], uuidF: UUIDF) =>
val denyCommandEvaluation = IO.terminate(new IllegalStateException("Project command evaluation should not happen"))
MigrationLog.scoped[ProjectRef, ProjectState, ProjectCommand, ProjectEvent, ProjectRejection](
Projects.definition(_ => IO.terminate(new IllegalStateException("Project command evaluation should not happen")))(
Projects.definition(_ => denyCommandEvaluation, _ => denyCommandEvaluation)(
clock,
uuidF
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ object ProjectsModule extends ModuleDef {
Task.pure(
ProjectsImpl(
organizations.fetchActiveOrganization(_).mapError(WrappedOrganizationRejection),
ProjectReferenceFinder(xas),
scopeInitializations,
mappings.merge,
config.projects,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class ProjectsRoutesSpec extends BaseRouteSpec {
case _ => UIO.none
}

private lazy val projects = ProjectsImpl(fetchOrg, Set.empty, defaultApiMappings, projectsConfig, xas)
private lazy val projects = ProjectsImpl(fetchOrg, _ => UIO.unit, Set.empty, defaultApiMappings, projectsConfig, xas)
private lazy val provisioning = ProjectProvisioning(aclCheck.append, projects, provisioningConfig)
private lazy val routes = Route.seal(
ProjectsRoutes(
Expand Down Expand Up @@ -515,6 +515,33 @@ class ProjectsRoutesSpec extends BaseRouteSpec {
response.header[Location].value.uri shouldEqual Uri("https://bbp.epfl.ch/nexus/web/admin/users-org/user1")
}
}

"fail to delete a project without projects/delete permission" in {
Delete("/v1/projects/org1/proj?rev=3&prune=true") ~> routes ~> check {
response.status shouldEqual StatusCodes.Forbidden
response.asJson shouldEqual jsonContentOf("errors/authorization-failed.json")
}
}

"delete a project" in {
aclCheck.append(AclAddress.Root, Anonymous -> Set(projectsPermissions.delete, resources.read)).accepted
Delete("/v1/projects/org1/proj?rev=3&prune=true") ~> routes ~> check {
status shouldEqual StatusCodes.OK
val ref = ProjectRef(Label.unsafe("org1"), Label.unsafe("proj"))
response.asJson should equalIgnoreArrayOrder(
projectMetadata(
ref,
"proj",
projectUuid,
"org1",
orgUuid,
rev = 4,
deprecated = true,
markedForDeletion = true
)
)
}
}
}

def projectMetadata(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution
import ch.epfl.bluebrain.nexus.delta.sourcing.ScopedEntityDefinition.Tagger
import ch.epfl.bluebrain.nexus.delta.sourcing._
import ch.epfl.bluebrain.nexus.delta.sourcing.config.EventLogConfig
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.DependsOn
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model._
Expand Down Expand Up @@ -515,8 +516,8 @@ object BlazegraphViews {
{ s =>
s.value match {
case a: AggregateBlazegraphViewValue =>
Some(a.views.map { v => EntityDependency(v.project, v.viewId) }.toSortedSet)
case _ => None
Some(a.views.map { v => DependsOn(v.project, v.viewId) }.toSortedSet)
case _: IndexingBlazegraphViewValue => None
}
},
onUniqueViolation = (id: Iri, c: BlazegraphViewCommand) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.projects.{FetchContext, Projects}
import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution
import ch.epfl.bluebrain.nexus.delta.sdk.syntax.nonEmptySetSyntax
import ch.epfl.bluebrain.nexus.delta.sourcing.ScopedEntityDefinition.Tagger
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.DependsOn
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model._
Expand Down Expand Up @@ -599,9 +600,10 @@ object CompositeViews {
),
state =>
Some(
state.value.sources.value.foldLeft(Set.empty[EntityDependency]) {
case (acc, s: CrossProjectSource) => acc + EntityDependency(s.project, Projects.encodeId(s.project))
case (acc, _) => acc
state.value.sources.value.foldLeft(Set.empty[DependsOn]) {
case (acc, _: ProjectSource) => acc
case (acc, s: CrossProjectSource) => acc + DependsOn(s.project, Projects.encodeId(s.project))
case (acc, _: RemoteProjectSource) => acc
}
),
onUniqueViolation = (id: Iri, c: CompositeViewCommand) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchVi
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewEvent._
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewRejection._
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewType.{AggregateElasticSearch, ElasticSearch}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewValue.AggregateElasticSearchViewValue
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewValue.{AggregateElasticSearchViewValue, IndexingElasticSearchViewValue}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewValue.IndexingElasticSearchViewValue.nextIndexingRev
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model._
import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
Expand All @@ -29,6 +29,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution
import ch.epfl.bluebrain.nexus.delta.sourcing.ScopedEntityDefinition.Tagger
import ch.epfl.bluebrain.nexus.delta.sourcing._
import ch.epfl.bluebrain.nexus.delta.sourcing.config.EventLogConfig
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.DependsOn
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model._
Expand Down Expand Up @@ -552,8 +553,8 @@ object ElasticSearchViews {
{ s =>
s.value match {
case a: AggregateElasticSearchViewValue =>
Some(a.views.map { v => EntityDependency(v.project, v.viewId) }.toSortedSet)
case _ => None
Some(a.views.map { v => DependsOn(v.project, v.viewId) }.toSortedSet)
case _: IndexingElasticSearchViewValue => None
}
},
onUniqueViolation = (id: Iri, c: ElasticSearchViewCommand) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, Project}
import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution
import ch.epfl.bluebrain.nexus.delta.sdk.views.{PipeStep, ViewRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.EntityDependencyStore
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.DependsOn
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Group, Subject, User}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityDependency, Label, ProjectRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.stream.PipeChain
import ch.epfl.bluebrain.nexus.delta.sourcing.stream.pipes.{FilterBySchema, FilterByType, FilterDeprecated}
import ch.epfl.bluebrain.nexus.testkit.{DoobieScalaTestFixture, EitherValuable, IOFixedClock}
Expand Down Expand Up @@ -217,8 +218,8 @@ class ElasticSearchViewsSpec
views.create(aggregateViewId, projectRef, value).accepted

// Dependency to the referenced project should have been saved
EntityDependencyStore.list(projectRef, aggregateViewId, xas).accepted shouldEqual Set(
EntityDependency(projectRef, viewId)
EntityDependencyStore.directDependencies(projectRef, aggregateViewId, xas).accepted shouldEqual Set(
DependsOn(projectRef, viewId)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package ch.epfl.bluebrain.nexus.delta.sdk.projects

import ch.epfl.bluebrain.nexus.delta.kernel.database.Transactors
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectRejection.ProjectIsReferenced
import ch.epfl.bluebrain.nexus.delta.sourcing.EntityDependencyStore
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.ReferencedBy
import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef
import monix.bio.{IO, UIO}

/**
* Allows to find reference of a project in other projects
*/
trait ProjectReferenceFinder {

/**
* Check if a given project is not referenced by another one
* @param project
* the project to check
*/
def raiseIfAny(project: ProjectRef): IO[ProjectIsReferenced, Unit]
}

object ProjectReferenceFinder {

def apply(xas: Transactors): ProjectReferenceFinder =
apply(EntityDependencyStore.directExternalReferences(_, xas))

def apply(fetchReferences: ProjectRef => UIO[Set[ReferencedBy]]): ProjectReferenceFinder =
(project: ProjectRef) =>
fetchReferences(project).flatMap { references =>
IO.raiseWhen(references.nonEmpty)(ProjectIsReferenced(project, references))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,19 @@ object Projects {
}

private[delta] def evaluate(
orgs: Organizations
orgs: Organizations,
referenceFinder: ProjectReferenceFinder
)(state: Option[ProjectState], command: ProjectCommand)(implicit
clock: Clock[UIO],
uuidF: UUIDF
): IO[ProjectRejection, ProjectEvent] = {
val f: FetchOrganization = label => orgs.fetchActiveOrganization(label).mapError(WrappedOrganizationRejection(_))
evaluate(f)(state, command)
evaluate(f, referenceFinder)(state, command)
}

private[sdk] def evaluate(
fetchAndValidateOrg: FetchOrganization
fetchAndValidateOrg: FetchOrganization,
referenceFinder: ProjectReferenceFinder
)(state: Option[ProjectState], command: ProjectCommand)(implicit
clock: Clock[UIO],
uuidF: UUIDF
Expand Down Expand Up @@ -261,6 +263,7 @@ object Projects {
IO.raiseError(ProjectIsMarkedForDeletion(c.ref))
case Some(s) =>
// format: off
referenceFinder.raiseIfAny(c.ref) >>
instant.map(ProjectMarkedForDeletion(s.label, s.uuid,s.organizationLabel, s.organizationUuid,s.rev + 1, _, c.subject))
// format: on
}
Expand All @@ -276,13 +279,13 @@ object Projects {
/**
* Entity definition for [[Projects]]
*/
def definition(fetchAndValidateOrg: FetchOrganization)(implicit
def definition(fetchAndValidateOrg: FetchOrganization, referenceFinder: ProjectReferenceFinder)(implicit
clock: Clock[UIO],
uuidF: UUIDF
): ScopedEntityDefinition[ProjectRef, ProjectState, ProjectCommand, ProjectEvent, ProjectRejection] =
ScopedEntityDefinition.untagged(
entityType,
StateMachine(None, evaluate(fetchAndValidateOrg), next),
StateMachine(None, evaluate(fetchAndValidateOrg, referenceFinder), next),
ProjectEvent.serializer,
ProjectState.serializer,
onUniqueViolation = (id: ProjectRef, c: ProjectCommand) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ object ProjectsImpl {
*/
final def apply(
fetchAndValidateOrg: FetchOrganization,
referenceFinder: ProjectReferenceFinder,
scopeInitializations: Set[ScopeInitialization],
defaultApiMappings: ApiMappings,
config: ProjectsConfig,
Expand All @@ -130,7 +131,7 @@ object ProjectsImpl {
uuidF: UUIDF
): Projects =
new ProjectsImpl(
ScopedEventLog(Projects.definition(fetchAndValidateOrg), config.eventLog, xas),
ScopedEventLog(Projects.definition(fetchAndValidateOrg, referenceFinder), config.eventLog, xas),
scopeInitializations,
defaultApiMappings
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package ch.epfl.bluebrain.nexus.delta.sdk.projects.model
import akka.http.scaladsl.model.StatusCodes
import ch.epfl.bluebrain.nexus.delta.kernel.Mapper
import ch.epfl.bluebrain.nexus.delta.kernel.utils.ClassUtils
import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.JsonLdContext.keywords
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.ScopeInitializationFailed
import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields
import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejection
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectRejection.ProjectIsReferenced.ReferencesByProject
import ch.epfl.bluebrain.nexus.delta.sdk.syntax.httpResponseFieldsSyntax
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.ReferencedBy
import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef
import io.circe.syntax._
import io.circe.{Encoder, JsonObject}
Expand Down Expand Up @@ -67,17 +70,38 @@ object ProjectRejection {
extends ProjectRejection(rejection.reason)

/**
* Signals and attempt to update/deprecate a project that is already deprecated.
* Signals an attempt to update/deprecate a project that is already deprecated.
*/
final case class ProjectIsDeprecated(projectRef: ProjectRef)
extends ProjectRejection(s"Project '$projectRef' is deprecated.")

/**
* Signals and attempt to update/deprecate/delete a project that is already marked for deletion.
* Signals an attempt to update/deprecate/delete a project that is already marked for deletion.
*/
final case class ProjectIsMarkedForDeletion(projectRef: ProjectRef)
extends ProjectRejection(s"Project '$projectRef' is marked for deletion.")

final case class ProjectIsReferenced private (project: ProjectRef, references: ReferencesByProject)
extends ProjectRejection(
s"Project '$project' can't be deleted as it is referenced by projects '${references.value.keys.mkString(", ")}'."
)

object ProjectIsReferenced {

def apply(project: ProjectRef, references: Set[ReferencedBy]): ProjectIsReferenced = {
val idsByProject = references.groupMap(_.project)(_.id)
new ProjectIsReferenced(project, ReferencesByProject(idsByProject))
}

def apply(project: ProjectRef, references: Map[ProjectRef, Set[Iri]]): ProjectIsReferenced =
new ProjectIsReferenced(project, ReferencesByProject(references))

final private[model] case class ReferencesByProject(value: Map[ProjectRef, Set[Iri]])

implicit private[model] val referencesEncoder: Encoder.AsObject[ReferencesByProject] =
Encoder.encodeMap[ProjectRef, Set[Iri]].contramapObject(_.value)
}

/**
* Signals that a project update cannot be performed due to an incorrect revision provided.
*
Expand Down Expand Up @@ -110,6 +134,7 @@ object ProjectRejection {
r match {
case WrappedOrganizationRejection(rejection) => rejection.asJsonObject
case ProjectInitializationFailed(rejection) => default.add("details", rejection.reason.asJson)
case ProjectIsReferenced(_, references) => default.add("referencedBy", references.asJson)
case IncorrectRev(provided, expected) =>
default.add("provided", provided.asJson).add("expected", expected.asJson)
case ProjectAlreadyExists(projectRef) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResolverRejection.{Diff
import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResolverValue.{CrossProjectValue, InProjectValue}
import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model._
import ch.epfl.bluebrain.nexus.delta.sourcing.ScopedEntityDefinition.Tagger
import ch.epfl.bluebrain.nexus.delta.sourcing.model.EntityDependency.DependsOn
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityDependency, EntityType, Label, ProjectRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Label, ProjectRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.{ScopedEntityDefinition, StateMachine}
import io.circe.Json
import monix.bio.{IO, UIO}
Expand Down Expand Up @@ -415,7 +416,7 @@ object Resolvers {
case _: InProjectValue => None
case c: CrossProjectValue =>
Some(
c.projects.map { ref => EntityDependency(ref, Projects.encodeId(ref)) }.toList.toSet
c.projects.map { ref => DependsOn(ref, Projects.encodeId(ref)) }.toList.toSet
)
},
onUniqueViolation = (id: Iri, c: ResolverCommand) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ object ValidateAggregate {
xas
) >> references.value.toList
.foldLeftM(references.length) { (acc, ref) =>
EntityDependencyStore.recursiveList(ref.project, ref.viewId, xas).map { r =>
EntityDependencyStore.recursiveDependencies(ref.project, ref.viewId, xas).map { r =>
acc + r.size
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ object ViewsStore {
for {
res <- fetchValue(id, project).flatMap(asView)
singleOrMultiple <- IO.fromEither(res).widen[View].onErrorHandleWith { iri =>
EntityDependencyStore.decodeRecursiveList[Iri, Value](project, iri, xas).flatMap {
EntityDependencyStore.decodeRecursiveDependencies[Iri, Value](project, iri, xas).flatMap {
_.traverseFilter(embeddedView).map(AggregateView(_))
}
}
Expand Down
Loading

0 comments on commit 8e4d4c2

Please sign in to comment.