diff --git a/delta/app/src/main/resources/app.conf b/delta/app/src/main/resources/app.conf index f79776d9d5..0273c5827e 100644 --- a/delta/app/src/main/resources/app.conf +++ b/delta/app/src/main/resources/app.conf @@ -154,6 +154,11 @@ app { acls { # the acls event log configuration event-log = ${app.defaults.event-log} + # configuration to provision acls at startup + provisioning { + enabled = false + #path = "/path-to-acl" + } } # Permissions configuration diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/provisioning/ProvisioningCoordinator.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/provisioning/ProvisioningCoordinator.scala new file mode 100644 index 0000000000..a3903e90f2 --- /dev/null +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/provisioning/ProvisioningCoordinator.scala @@ -0,0 +1,15 @@ +package ch.epfl.bluebrain.nexus.delta.provisioning + +import cats.syntax.all._ +import cats.effect.IO +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction + +trait ProvisioningCoordinator + +object ProvisioningCoordinator extends ProvisioningCoordinator { + + def apply(actions: Vector[ProvisioningAction]): IO[ProvisioningCoordinator] = { + actions.traverse(_.run).as(ProvisioningCoordinator) + } + +} diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutes.scala index 3e9d46d331..c5541f0270 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutes.scala @@ -31,8 +31,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceF._ import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults.searchResultsJsonLdEncoder import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{acls => aclsPermissions} -import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission -import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Identity, Label, ProjectRef} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef} import io.circe._ import io.circe.generic.extras.Configuration import io.circe.generic.extras.semiauto.deriveConfiguredDecoder @@ -196,23 +195,6 @@ class AclsRoutes(identities: Identities, acls: Acls, aclCheck: AclCheck)(implici object AclsRoutes { - final private case class IdentityPermissions(identity: Identity, permissions: Set[Permission]) - - final private[routes] case class AclValues(value: Seq[(Identity, Set[Permission])]) - - private[routes] object AclValues { - - implicit private val identityPermsDecoder: Decoder[IdentityPermissions] = { - implicit val config: Configuration = Configuration.default.withStrictDecoding - deriveConfiguredDecoder[IdentityPermissions] - } - - implicit val aclValuesDecoder: Decoder[AclValues] = - Decoder - .decodeSeq[IdentityPermissions] - .map(seq => AclValues(seq.map(value => value.identity -> value.permissions))) - } - final private[routes] case class ReplaceAcl(acl: AclValues) private[routes] object ReplaceAcl { 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 c701b9d827..7177aeefa8 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 @@ -10,9 +10,10 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteCon import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.routes.{AclsRoutes, UserPermissionsRoutes} import ch.epfl.bluebrain.nexus.delta.sdk._ -import ch.epfl.bluebrain.nexus.delta.sdk.acls.{AclCheck, Acls, AclsImpl} +import ch.epfl.bluebrain.nexus.delta.sdk.acls._ 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.identities.model.ServiceAccount import ch.epfl.bluebrain.nexus.delta.sdk.model.{BaseUri, MetadataContextValue} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.{Permissions, StoragePermissionProvider} import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors @@ -26,21 +27,17 @@ object AclsModule extends ModuleDef { implicit private val loader: ClasspathResourceLoader = ClasspathResourceLoader.withContext(getClass) - make[Acls].from { - ( - permissions: Permissions, - config: AppConfig, - xas: Transactors, - clock: Clock[IO] - ) => - acls.AclsImpl( - permissions.fetchPermissionSet, - AclsImpl.findUnknownRealms(xas), - permissions.minimum, - config.acls.eventLog, - xas, - clock - ) + make[AclsConfig].from { (config: AppConfig) => config.acls } + + make[Acls].from { (permissions: Permissions, config: AclsConfig, xas: Transactors, clock: Clock[IO]) => + acls.AclsImpl( + permissions.fetchPermissionSet, + AclsImpl.findUnknownRealms(xas), + permissions.minimum, + config.eventLog, + xas, + clock + ) } make[AclCheck].from { (acls: Acls) => AclCheck(acls) } @@ -57,6 +54,10 @@ object AclsModule extends ModuleDef { new AclsRoutes(identities, acls, aclCheck)(baseUri, cr, ordering) } + make[AclProvisioning].from { (acls: Acls, config: AclsConfig, serviceAccount: ServiceAccount) => + new AclProvisioning(acls, config.provisioning, serviceAccount) + } + many[ProjectDeletionTask].add { (acls: Acls) => Acls.projectDeletionTask(acls) } many[MetadataContextValue].addEffect(MetadataContextValue.fromFile("contexts/acls-metadata.json")) 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 ced405df0f..ddc312df6e 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 @@ -11,6 +11,7 @@ import ch.epfl.bluebrain.nexus.delta.Main.pluginsMaxPriority import ch.epfl.bluebrain.nexus.delta.config.{AppConfig, StrictEntity} import ch.epfl.bluebrain.nexus.delta.kernel.dependency.ComponentDescription.PluginDescription import ch.epfl.bluebrain.nexus.delta.kernel.utils.{ClasspathResourceLoader, IOFuture, UUIDF} +import ch.epfl.bluebrain.nexus.delta.provisioning.ProvisioningCoordinator import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} @@ -18,7 +19,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.routes.ErrorRoutes import ch.epfl.bluebrain.nexus.delta.sdk.IndexingAction.AggregateIndexingAction import ch.epfl.bluebrain.nexus.delta.sdk._ -import ch.epfl.bluebrain.nexus.delta.sdk.acls.Acls +import ch.epfl.bluebrain.nexus.delta.sdk.acls.{AclProvisioning, Acls} import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount import ch.epfl.bluebrain.nexus.delta.sdk.jws.JWSPayloadHelper @@ -26,6 +27,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.{RdfExceptionHandler, RdfRe import ch.epfl.bluebrain.nexus.delta.sdk.model._ import ch.epfl.bluebrain.nexus.delta.sdk.plugin.PluginDef import ch.epfl.bluebrain.nexus.delta.sdk.projects.{OwnerPermissionsScopeInitialization, ProjectsConfig, ScopeInitializationErrorStore} +import ch.epfl.bluebrain.nexus.delta.sdk.realms.RealmProvisioning import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import ch.epfl.bluebrain.nexus.delta.sourcing.config._ import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings @@ -89,6 +91,10 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class ScopeInitializer(inits, errorStore) } + make[ProvisioningCoordinator].fromEffect { (realmProvisioning: RealmProvisioning, aclProvisioning: AclProvisioning) => + ProvisioningCoordinator(Vector(realmProvisioning, aclProvisioning)) + } + make[RemoteContextResolution].named("aggregate").fromEffect { (otherCtxResolutions: Set[RemoteContextResolution]) => for { bulkOpCtx <- ContextValue.fromFile("contexts/bulk-operation.json") diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala index bbd17db19b..928db3ff53 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/RealmsModule.scala @@ -41,9 +41,8 @@ object RealmsModule extends ModuleDef { RealmsImpl(cfg, wellKnownResolver, xas, clock) } - make[RealmProvisioning].fromEffect { (realms: Realms, cfg: RealmsConfig, serviceAccount: ServiceAccount) => - RealmProvisioning(realms, cfg.provisioning, serviceAccount) - + make[RealmProvisioning].from { (realms: Realms, cfg: RealmsConfig, serviceAccount: ServiceAccount) => + new RealmProvisioning(realms, cfg.provisioning, serviceAccount) } make[RealmsRoutes].from { diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutesPayloadSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutesPayloadSpec.scala index 43f9d338e5..8448660eb3 100644 --- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutesPayloadSpec.scala +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/AclsRoutesPayloadSpec.scala @@ -1,7 +1,8 @@ package ch.epfl.bluebrain.nexus.delta.routes import ch.epfl.bluebrain.nexus.delta.routes.AclsRoutes.PatchAcl.{Append, Subtract} -import ch.epfl.bluebrain.nexus.delta.routes.AclsRoutes.{AclValues, PatchAcl, ReplaceAcl} +import ch.epfl.bluebrain.nexus.delta.routes.AclsRoutes.{PatchAcl, ReplaceAcl} +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclValues import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.resources import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity import ch.epfl.bluebrain.nexus.testkit.scalatest.BaseSpec diff --git a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/error/LoadFileError.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/error/LoadFileError.scala new file mode 100644 index 0000000000..5196be6326 --- /dev/null +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/error/LoadFileError.scala @@ -0,0 +1,24 @@ +package ch.epfl.bluebrain.nexus.delta.kernel.error + +import java.nio.file.Path + +/** + * Top level error type that represents error loading external files. + * + * @param reason + * the reason of the error + */ +abstract class LoadFileError(reason: String) extends Exception { self => + override def fillInStackTrace(): Throwable = self + final override def getMessage: String = reason +} + +object LoadFileError { + + final case class UnaccessibleFile(path: Path, throwable: Throwable) + extends LoadFileError(s"File at path '$path' could not be loaded because of '${throwable.getMessage}'.") + + final case class InvalidJson(path: Path, details: String) + extends LoadFileError(s"File at path '$path' does not contain the expected json input: '$details'.") + +} diff --git a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/FileUtils.scala b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/FileUtils.scala index afd8f4609f..f0e8aa9e34 100644 --- a/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/FileUtils.scala +++ b/delta/kernel/src/main/scala/ch/epfl/bluebrain/nexus/delta/kernel/utils/FileUtils.scala @@ -1,5 +1,14 @@ package ch.epfl.bluebrain.nexus.delta.kernel.utils +import cats.effect.IO +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.error.LoadFileError.{InvalidJson, UnaccessibleFile} +import io.circe.Decoder +import io.circe.parser.decode + +import java.nio.file.{Files, Path} +import scala.util.Try + object FileUtils { /** @@ -19,4 +28,24 @@ object FileUtils { } } + /** + * Load the content of the given file as a string + */ + def loadAsString(filePath: Path): IO[String] = IO.fromEither( + Try(Files.readString(filePath)).toEither.leftMap(UnaccessibleFile(filePath, _)) + ) + + /** + * Load the content of the given file as json and try to decode it as an A + * @param filePath + * the path of the target file + */ + def loadJsonAs[A: Decoder](filePath: Path): IO[A] = + for { + content <- IO.fromEither( + Try(Files.readString(filePath)).toEither.leftMap(UnaccessibleFile(filePath, _)) + ) + json <- IO.fromEither(decode[A](content).leftMap { e => InvalidJson(filePath, e.getMessage) }) + } yield json + } diff --git a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfig.scala b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfig.scala index 0ca32d2709..4dbef579ca 100644 --- a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfig.scala +++ b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfig.scala @@ -2,6 +2,8 @@ package ch.epfl.bluebrain.nexus.delta.plugins.search.model import cats.effect.IO import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.utils.FileUtils +import ch.epfl.bluebrain.nexus.delta.kernel.utils.FileUtils.loadJsonAs import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.CompositeView.{Interval, RebuildStrategy} import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.TemplateSparqlConstructQuery import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfig.IndexingConfig @@ -15,12 +17,11 @@ import ch.epfl.bluebrain.nexus.delta.sdk.Defaults import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields import ch.epfl.bluebrain.nexus.delta.sourcing.model.{IriFilter, Label, ProjectRef} import com.typesafe.config.Config -import io.circe.parser._ import io.circe.syntax.KeyOps -import io.circe.{Decoder, Encoder, JsonObject} +import io.circe.{Encoder, JsonObject} import pureconfig.{ConfigReader, ConfigSource} -import java.nio.file.{Files, Path} +import java.nio.file.Path import scala.concurrent.duration.FiniteDuration import scala.util.Try @@ -49,18 +50,19 @@ object SearchConfig { * Converts a [[Config]] into an [[SearchConfig]] */ def load(config: Config): IO[SearchConfig] = { - val pluginConfig = config.getConfig("plugins.search") + val pluginConfig = config.getConfig("plugins.search") + def getFilePath(configPath: String) = Path.of(pluginConfig.getString(configPath)) def loadSuites = { val suiteSource = ConfigSource.fromConfig(pluginConfig).at("suites") IO.fromEither(suiteSource.load[Suites].leftMap(InvalidSuites)) } for { - fields <- loadOption(pluginConfig, "fields", loadExternalConfig[JsonObject]) - resourceTypes <- loadExternalConfig[IriFilter](pluginConfig.getString("indexing.resource-types")) - mapping <- loadExternalConfig[JsonObject](pluginConfig.getString("indexing.mapping")) - settings <- loadOption(pluginConfig, "indexing.settings", loadExternalConfig[JsonObject]) - query <- loadSparqlQuery(pluginConfig.getString("indexing.query")) - context <- loadOption(pluginConfig, "indexing.context", loadExternalConfig[JsonObject]) + fields <- loadOption(pluginConfig, "fields", loadJsonAs[JsonObject]) + resourceTypes <- loadJsonAs[IriFilter](getFilePath("indexing.resource-types")) + mapping <- loadJsonAs[JsonObject](getFilePath("indexing.mapping")) + settings <- loadOption(pluginConfig, "indexing.settings", loadJsonAs[JsonObject]) + query <- loadSparqlQuery(getFilePath("indexing.query")) + context <- loadOption(pluginConfig, "indexing.context", loadJsonAs[JsonObject]) rebuild <- loadRebuildStrategy(pluginConfig) defaults <- loadDefaults(pluginConfig) suites <- loadSuites @@ -79,36 +81,21 @@ object SearchConfig { ) } - private def loadOption[A](config: Config, path: String, io: String => IO[A]) = + private def loadOption[A](config: Config, path: String, io: Path => IO[A]) = if (config.hasPath(path)) - io(config.getString(path)).map(Some(_)) + io(Path.of(config.getString(path))).map(Some(_)) else IO.none - private def loadExternalConfig[A: Decoder](filePath: String): IO[A] = + private def loadSparqlQuery(filePath: Path): IO[SparqlConstructQuery] = for { - content <- IO.fromEither( - Try(Files.readString(Path.of(filePath))).toEither.leftMap(LoadingFileError(filePath, _)) - ) - json <- IO.fromEither(decode[A](content).leftMap { e => InvalidJsonError(filePath, e.getMessage) }) - } yield json - - private def loadSparqlQuery(filePath: String): IO[SparqlConstructQuery] = - for { - content <- IO.fromEither( - Try(Files.readString(Path.of(filePath))).toEither.leftMap(LoadingFileError(filePath, _)) - ) + content <- FileUtils.loadAsString(filePath) json <- IO.fromEither(TemplateSparqlConstructQuery(content).leftMap { e => InvalidSparqlConstructQuery(filePath, e) }) } yield json private def loadDefaults(config: Config): IO[Defaults] = - IO.fromEither( - Try( - ConfigSource.fromConfig(config).at("defaults").loadOrThrow[Defaults] - // TODO: Use a correct error - ).toEither.leftMap(_ => InvalidJsonError("string", "string")) - ) + IO.pure(ConfigSource.fromConfig(config).at("defaults").loadOrThrow[Defaults]) /** * Load the rebuild strategy from the search config. If either of the required fields is null, missing, or not a diff --git a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigError.scala b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigError.scala index d479250f2c..d1667ce68f 100644 --- a/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigError.scala +++ b/delta/plugins/search/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigError.scala @@ -3,19 +3,13 @@ package ch.epfl.bluebrain.nexus.delta.plugins.search.model import ch.epfl.bluebrain.nexus.delta.sdk.error.SDKError import pureconfig.error.ConfigReaderFailures +import java.nio.file.Path import scala.concurrent.duration.FiniteDuration abstract class SearchConfigError(val reason: String) extends SDKError object SearchConfigError { - - final case class LoadingFileError(path: String, throwable: Throwable) - extends SearchConfigError(s"File at path '$path' could not be loaded because of '${throwable.getMessage}'.") - - final case class InvalidJsonError(path: String, details: String) - extends SearchConfigError(s"File at path '$path' does not contain a the expect json: '$details'.") - - final case class InvalidSparqlConstructQuery(path: String, details: String) + final case class InvalidSparqlConstructQuery(path: Path, details: String) extends SearchConfigError(s"File at path '$path' does not contain a valid SPARQL construct query: '$details'.") final case class InvalidSuites(failures: ConfigReaderFailures) diff --git a/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigSpec.scala b/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigSpec.scala index 59c4d9eb90..99e09b1d67 100644 --- a/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigSpec.scala +++ b/delta/plugins/search/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/search/model/SearchConfigSpec.scala @@ -1,7 +1,8 @@ package ch.epfl.bluebrain.nexus.delta.plugins.search.model +import ch.epfl.bluebrain.nexus.delta.kernel.error.LoadFileError.{InvalidJson, UnaccessibleFile} import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.CompositeView.Interval -import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfigError.{InvalidJsonError, InvalidSparqlConstructQuery, LoadingFileError} +import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfigError.InvalidSparqlConstructQuery import ch.epfl.bluebrain.nexus.delta.sdk.Defaults import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef} import ch.epfl.bluebrain.nexus.testkit.scalatest.ce.CatsEffectSpec @@ -79,7 +80,7 @@ class SearchConfigSpec extends CatsEffectSpec { val missingContextFile = config(validJson, validJson, Some(validJson), validQuery, Some(missingFile)) val all = List(missingFieldFile, missingEsMapping, missingEsSettings, missingSparqlFile, missingContextFile) forAll(all) { c => - SearchConfig.load(c).rejectedWith[LoadingFileError] + SearchConfig.load(c).rejectedWith[UnaccessibleFile] } } @@ -90,7 +91,7 @@ class SearchConfigSpec extends CatsEffectSpec { val invalidContext = config(validJson, validJson, Some(validJson), validQuery, Some(emptyFile)) val all = List(invalidFields, invalidEsMapping, invalidEsSettings, invalidContext) forAll(all) { c => - SearchConfig.load(c).rejectedWith[InvalidJsonError] + SearchConfig.load(c).rejectedWith[InvalidJson] } } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/ProvisioningAction.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/ProvisioningAction.scala new file mode 100644 index 0000000000..ad2a7b53fa --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/ProvisioningAction.scala @@ -0,0 +1,26 @@ +package ch.epfl.bluebrain.nexus.delta.sdk + +import cats.effect.IO +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction.Outcome + +/** + * Provisioning action to run at startup + */ +trait ProvisioningAction { + + def run: IO[Outcome] + +} + +object ProvisioningAction { + + sealed trait Outcome + + object Outcome { + case object Success extends Outcome + case object Skipped extends Outcome + case object Disabled extends Outcome + case object Error extends Outcome + } + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioning.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioning.scala new file mode 100644 index 0000000000..bada088fdd --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioning.scala @@ -0,0 +1,50 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.acls + +import cats.effect.IO +import cats.syntax.all._ +import ch.epfl.bluebrain.nexus.delta.kernel.Logger +import ch.epfl.bluebrain.nexus.delta.kernel.utils.FileUtils +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction.Outcome +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclProvisioning.logger +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclBatchReplace +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount + +/** + * Provision the different acls provided in the file defined by the configuration + */ +final class AclProvisioning(acls: Acls, config: AclProvisioningConfig, serviceAccount: ServiceAccount) + extends ProvisioningAction { + + override def run: IO[ProvisioningAction.Outcome] = + if (config.enabled) { + acls.isRootAclSet.flatMap { + case true => + logger.warn("Root acls are already set in this instance, skipping the provisioning...").as(Outcome.Skipped) + case false => + config.path + .traverse(FileUtils.loadJsonAs[AclBatchReplace]) + .flatMap { + case Some(input) => loadAcls(input, acls, serviceAccount).as(Outcome.Success) + case None => logger.error("No acl provisioning file has been defined.").as(Outcome.Error) + } + .recoverWith { e => + logger.error(e)(s"Acl provisionning failed because of '${e.getMessage}'.").as(Outcome.Error) + } + } + } else logger.info(s"Acl provisioning is inactive.").as(Outcome.Disabled) + + private def loadAcls(input: AclBatchReplace, acls: Acls, serviceAccount: ServiceAccount) = { + logger.info(s"Provisioning ${input.acls.size} acl entries...") >> + input.acls.traverse { acl => + acls.replace(acl, 0)(serviceAccount.subject).recoverWith { e => + logger.error(e)(s"Acl for address '${acl.address}' could not be set: '${e.getMessage}.") + } + }.void >> logger.info("Provisioning acls is completed.") + } +} + +object AclProvisioning { + + private val logger = Logger[AclProvisioning] +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioningConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioningConfig.scala new file mode 100644 index 0000000000..3427d112f3 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioningConfig.scala @@ -0,0 +1,13 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.acls + +import pureconfig.ConfigReader +import pureconfig.generic.semiauto.deriveReader + +import java.nio.file.Path + +final case class AclProvisioningConfig(enabled: Boolean, path: Option[Path]) + +object AclProvisioningConfig { + implicit final val aclProvisioningConfigReader: ConfigReader[AclProvisioningConfig] = + deriveReader[AclProvisioningConfig] +} 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 02ae0165c7..65520e5be0 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,6 +25,8 @@ import java.time.Instant */ trait Acls { + def isRootAclSet: IO[Boolean] + /** * Fetches the ACL resource for an ''address'' on the current revision. * diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsConfig.scala index 8c3220ae0f..005f88368f 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsConfig.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsConfig.scala @@ -9,9 +9,12 @@ import pureconfig.generic.semiauto.deriveReader * * @param eventLog * The event log configuration + * @param provisioning + * The provisioning */ final case class AclsConfig( - eventLog: EventLogConfig + eventLog: EventLogConfig, + provisioning: AclProvisioningConfig ) object AclsConfig { 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 306a34cc20..0fb8c9d777 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 @@ -25,6 +25,14 @@ final class AclsImpl private ( implicit private val kamonComponent: KamonMetricComponent = KamonMetricComponent(entityType.value) + override def isRootAclSet: IO[Boolean] = + log + .stateOr(AclAddress.Root, AclNotFound(AclAddress.Root)) + .redeem( + _ => false, + _ => true + ) + override def fetch(address: AclAddress): IO[AclResource] = log .stateOr(address, AclNotFound(address)) @@ -91,7 +99,7 @@ object AclsImpl { def findUnknownRealms(xas: Transactors)(labels: Set[Label]): IO[Unit] = { GlobalStateStore - .listIds(Realms.entityType, xas.read) + .listIds(Realms.entityType, xas.write) .compile .toList .flatMap { existing => diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclAddress.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclAddress.scala index 4f9fa0b7dc..49f41afcee 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclAddress.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclAddress.scala @@ -5,7 +5,7 @@ import ch.epfl.bluebrain.nexus.delta.kernel.error.FormatError import ch.epfl.bluebrain.nexus.delta.sdk.error.FormatErrors.IllegalAclAddressFormatError import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef} import doobie.{Get, Put} -import io.circe.{Decoder, Encoder} +import io.circe.{Decoder, Encoder, KeyDecoder} /** * Enumeration of possible ACL addresses. An ACL address is the address where a certain ACL is anchored. @@ -100,6 +100,8 @@ object AclAddress { implicit val aclAddressOrdering: Ordering[AclAddress] = Ordering.by(_.string) + implicit val aclAddressKeyDecoder: KeyDecoder[AclAddress] = KeyDecoder.instance(AclAddress.fromString(_).toOption) + implicit val aclAddressEncoder: Encoder[AclAddress] = Encoder.encodeString.contramap(_.string) implicit val aclAddressDecoder: Decoder[AclAddress] = Decoder.decodeString.emap { str => AclAddress.fromString(str).leftMap(_.getMessage) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclBatchReplace.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclBatchReplace.scala new file mode 100644 index 0000000000..513965434a --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclBatchReplace.scala @@ -0,0 +1,15 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.acls.model + +import io.circe.Decoder + +final case class AclBatchReplace(acls: Vector[Acl]) + +object AclBatchReplace { + + implicit val aclBatchReplaceDecoder: Decoder[AclBatchReplace] = Decoder[Map[AclAddress, AclValues]].map { valuesMap => + val acls = valuesMap.foldLeft(Vector.empty[Acl]) { case (acc, (address, values)) => + acc :+ Acl(address, values.value: _*) + } + AclBatchReplace(acls) + } +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclValues.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclValues.scala new file mode 100644 index 0000000000..c034673bea --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclValues.scala @@ -0,0 +1,25 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.acls.model + +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity +import io.circe.Decoder +import io.circe.generic.extras.Configuration +import io.circe.generic.extras.semiauto.deriveConfiguredDecoder + +final case class AclValues(value: Seq[(Identity, Set[Permission])]) + +object AclValues { + + final private case class IdentityPermissions(identity: Identity, permissions: Set[Permission]) + + implicit private val identityPermsDecoder: Decoder[IdentityPermissions] = { + implicit val config: Configuration = Configuration.default.withStrictDecoding + deriveConfiguredDecoder[IdentityPermissions] + } + + implicit val aclValuesDecoder: Decoder[AclValues] = + Decoder + .decodeSeq[IdentityPermissions] + .map(seq => AclValues(seq.map(value => value.identity -> value.permissions))) + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioning.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioning.scala index 8e1d3ced11..a25b2b6928 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioning.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioning.scala @@ -3,24 +3,20 @@ package ch.epfl.bluebrain.nexus.delta.sdk.realms import cats.effect.IO import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.kernel.Logger +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction.Outcome import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount +import ch.epfl.bluebrain.nexus.delta.sdk.realms.RealmProvisioning.logger import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmRejection.RealmAlreadyExists import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject /** * Provision the different realms provided in the configuration */ -trait RealmProvisioning +final class RealmProvisioning(realms: Realms, config: RealmsProvisioningConfig, serviceAccount: ServiceAccount) + extends ProvisioningAction { -object RealmProvisioning extends RealmProvisioning { - - private val logger = Logger[RealmProvisioning] - - def apply( - realms: Realms, - config: RealmsProvisioningConfig, - serviceAccount: ServiceAccount - ): IO[RealmProvisioning.type] = + override def run: IO[ProvisioningAction.Outcome] = if (config.enabled) { implicit val serviceAccountSubject: Subject = serviceAccount.subject for { @@ -32,7 +28,12 @@ object RealmProvisioning extends RealmProvisioning { } } _ <- logger.info(s"Provisioning ${config.realms.size} realms is completed") - } yield RealmProvisioning - } else logger.info(s"Realm provisioning is inactive.").as(RealmProvisioning) + } yield Outcome.Success + } else logger.info(s"Realm provisioning is inactive.").as(Outcome.Disabled) +} + +object RealmProvisioning { + + private val logger = Logger[RealmProvisioning] } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfig.scala index c909ab5f26..9a22b620e2 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfig.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfig.scala @@ -18,7 +18,7 @@ object RealmsProvisioningConfig { implicit private val mapReader: ConfigReader[Map[Label, RealmFields]] = Label.labelMapReader[RealmFields] - implicit final val reamsProvisioningConfigReader: ConfigReader[RealmsProvisioningConfig] = + implicit final val realmsProvisioningConfigReader: ConfigReader[RealmsProvisioningConfig] = deriveReader[RealmsProvisioningConfig] } 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 a45d7b5856..7510448458 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,9 +3,8 @@ 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.kernel.http.{HttpClientConfig, HttpClientWorthRetry} +import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig import ch.epfl.bluebrain.nexus.delta.sdk.model.search.PaginationConfig import ch.epfl.bluebrain.nexus.delta.sdk.projects.ProjectsConfig import ch.epfl.bluebrain.nexus.delta.sourcing.config.{EventLogConfig, QueryConfig} @@ -21,8 +20,6 @@ trait ConfigFixtures { def eventLogConfig: EventLogConfig = EventLogConfig(queryConfig, 5.seconds) - def aclsConfig: AclsConfig = AclsConfig(eventLogConfig) - def pagination: PaginationConfig = PaginationConfig( defaultSize = 30, diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioningSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioningSuite.scala new file mode 100644 index 0000000000..ec77ce2263 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclProvisioningSuite.scala @@ -0,0 +1,116 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.acls + +import cats.effect.IO +import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction.Outcome +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddressFilter +import ch.epfl.bluebrain.nexus.delta.sdk.generators.PermissionsGen +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.Doobie +import ch.epfl.bluebrain.nexus.testkit.file.TempDirectory +import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite +import munit.AnyFixture + +import java.nio.file.Files + +class AclProvisioningSuite extends NexusSuite with Doobie.Fixture with TempDirectory.Fixture with ConfigFixtures { + + override def munitFixtures: Seq[AnyFixture[_]] = List(tempDirectory, doobieTruncateAfterTest) + + private lazy val tempDir = tempDirectory().toNioPath + private lazy val xas = doobieTruncateAfterTest() + + private val realm: Label = Label.unsafe("realm") + + private val serviceAccount: ServiceAccount = ServiceAccount(User("nexus-sa", Label.unsafe("sa"))) + + private val minimumPermissions: Set[Permission] = PermissionsGen.minimum + + private lazy val acls: Acls = AclsImpl( + IO.pure(minimumPermissions), + Acls.findUnknownRealms(_, Set(realm)), + minimumPermissions, + eventLogConfig, + xas, + clock + ) + + private def generateAclFile = { + val acls = + json""" + { + "/" : [ + { + "permissions": [ + "projects/read", + "projects/write" + ], + "identity": { + "realm": "realm", + "group": "admins" + } + } + ], + "/bbp/atlas": [ + { + "permissions": [ + "resources/read", + "resources/write" + ], + "identity": { + "realm": "realm", + "group": "atlas-users" + } + } + ] + }""" + IO.blocking { Files.writeString(tempDir.resolve(genString()), acls.noSpaces) } + } + + private def generateInvalidFile = + IO.blocking { Files.writeString(tempDir.resolve(genString()), "{ FAIL }") } + + private def runProvisioning(config: AclProvisioningConfig) = + new AclProvisioning(acls, config, serviceAccount).run + + private def assertNoAclsDefined = + acls.isRootAclSet.assertEquals(false, "No acl should be defined") + + test("Do not run the provisioning if disabled") { + val inactiveConfig = AclProvisioningConfig(enabled = false, None) + runProvisioning(inactiveConfig).assertEquals(Outcome.Disabled) >> assertNoAclsDefined + } + + test("Return an error outcome if enabled and no path is provided") { + val invalidConfig = AclProvisioningConfig(enabled = true, None) + runProvisioning(invalidConfig).assertEquals(Outcome.Error) >> assertNoAclsDefined + } + + test("Return an error outcome if enabled and an invalid path is provided") { + val invalidFileConfig = AclProvisioningConfig(enabled = true, Some(tempDir.resolve(genString()))) + runProvisioning(invalidFileConfig).assertEquals(Outcome.Error) >> assertNoAclsDefined + } + + test("Return an error outcome if the provided file can't be parsed") { + for { + path <- generateInvalidFile + invalidFileConfig = AclProvisioningConfig(enabled = true, Some(path)) + _ <- runProvisioning(invalidFileConfig).assertEquals(Outcome.Error) + _ <- assertNoAclsDefined + } yield () + } + + test("Return an success outcome is the provided file can't be parsed and a second run should be skipped") { + for { + path <- generateAclFile + validConfig = AclProvisioningConfig(enabled = true, Some(path)) + _ <- runProvisioning(validConfig).assertEquals(Outcome.Success) + _ <- acls.list(AclAddressFilter.AnyOrganizationAnyProject(true)).assert(_.value.size == 2) + _ <- runProvisioning(validConfig).assertEquals(Outcome.Skipped) + } yield () + } + +} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImplSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImplSpec.scala index 6fac31b3f2..769ba71994 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImplSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/AclsImplSpec.scala @@ -77,10 +77,18 @@ class AclsImplSpec extends CatsEffectSpec with DoobieScalaTestFixture with Cance acls.fetchWithAncestors(AclAddress.Root).accepted shouldEqual expected } + "return false on `/` as no acl is set yet" in { + acls.isRootAclSet.accepted shouldEqual false + } + "append an ACL" in { acls.append(userR(AclAddress.Root), 0).accepted shouldEqual resourceFor(userR(AclAddress.Root), 1, subject) } + "return true on `/` now that an acl has been appended" in { + acls.isRootAclSet.accepted shouldEqual true + } + "should not return permissions for Anonymous after a new revision was recorded on Root" in { val expected = AclCollection(resourceFor(userR(AclAddress.Root), 1, subject)) acls.fetchWithAncestors(projectTarget).accepted shouldEqual expected diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclBatchReplaceSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclBatchReplaceSuite.scala new file mode 100644 index 0000000000..98ce4da33b --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/acls/model/AclBatchReplaceSuite.scala @@ -0,0 +1,82 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.acls.model + +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Group, User} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{acls, projects, resources} + +class AclBatchReplaceSuite extends NexusSuite { + + test("Deserialize properly the json payload") { + val payload = json""" + { + "/" : [ + { + "permissions": [ + "acls/read", + "acls/write" + ], + "identity": { + "realm": "realm", + "subject": "superuser" + } + }, + { + "permissions": [ + "projects/read", + "projects/write" + ], + "identity": { + "realm": "realm", + "group": "admins" + } + } + ], + "/bbp": [ + { + "permissions": [ + "resources/read", + "resources/write" + ], + "identity": { + "realm": "realm", + "group": "bbp-users" + } + } + ], + "/bbp/atlas": [ + { + "permissions": [ + "resources/read", + "resources/write" + ], + "identity": { + "realm": "realm", + "group": "atlas-users" + } + } + ] + }""" + + val realm = Label.unsafe("realm") + val bbp = Label.unsafe("bbp") + val atlas = Label.unsafe("atlas") + val expected = AclBatchReplace( + Vector( + Acl( + AclAddress.Root, + User("superuser", realm) -> Set(acls.read, acls.write), + Group("admins", realm) -> Set(projects.read, projects.write) + ), + Acl(AclAddress.Organization(bbp), Group("bbp-users", realm) -> Set(resources.read, resources.write)), + Acl(AclAddress.Project(bbp, atlas), Group("atlas-users", realm) -> Set(resources.read, resources.write)) + ) + ) + + assertEquals( + payload.as[AclBatchReplace], + Right(expected) + ) + } + +} 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 index dea48db877..a92dfc7a01 100644 --- 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 @@ -43,7 +43,7 @@ class OrganizationDeleterSuite extends NexusSuite with ConfigFixtures with Proje private val fields = ProjectFields(None, ApiMappings.empty, None, None) private lazy val orgs = OrganizationsImpl(ScopeInitializer.noop, eventLogConfig, xas, clock) private val permission = Permissions.resources.read - private lazy val acls = AclsImpl(IO.pure(Set(permission)), _ => IO.unit, Set(), aclsConfig.eventLog, xas, clock) + private lazy val acls = AclsImpl(IO.pure(Set(permission)), _ => IO.unit, Set(), eventLogConfig, xas, clock) implicit val subject: Subject = Identity.User("Bob", Label.unsafe("realm")) implicit val uuidF: UUIDF = UUIDF.fixed(UUID.randomUUID()) diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioningSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioningSuite.scala index 974cd6a071..f9d92737a2 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioningSuite.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioningSuite.scala @@ -3,6 +3,7 @@ package ch.epfl.bluebrain.nexus.delta.sdk.realms import akka.http.scaladsl.model.Uri import ch.epfl.bluebrain.nexus.delta.rdf.syntax.uriStringContextSyntax import ch.epfl.bluebrain.nexus.delta.sdk.ConfigFixtures +import ch.epfl.bluebrain.nexus.delta.sdk.ProvisioningAction.Outcome import ch.epfl.bluebrain.nexus.delta.sdk.generators.WellKnownGen import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount import ch.epfl.bluebrain.nexus.delta.sdk.model.Name @@ -45,21 +46,24 @@ class RealmProvisioningSuite extends NexusSuite with Doobie.Fixture with ConfigF clock ) + private def runProvisioning(config: RealmsProvisioningConfig) = + new RealmProvisioning(realms, config, serviceAccount).run + test("Provision the different realms according to the configuration") { val githubRealm = RealmFields(githubName, githubOpenId, None, None) val gitlabRealm = RealmFields(gitlabName, gitlabOpenId, None, None) val inactive = RealmsProvisioningConfig(enabled = false, Map(github -> githubRealm)) for { // Github realm should not be created as provisioning is disabled - _ <- RealmProvisioning(realms, inactive, serviceAccount) + _ <- runProvisioning(inactive).assertEquals(Outcome.Disabled) _ <- realms.fetch(github).intercept[RealmNotFound] - githubConfig = RealmsProvisioningConfig(enabled = true, Map(github -> githubRealm)) // Github realm should be created as provisioning is disabled - _ <- RealmProvisioning(realms, githubConfig, serviceAccount) + githubConfig = RealmsProvisioningConfig(enabled = true, Map(github -> githubRealm)) + _ <- runProvisioning(githubConfig).assertEquals(Outcome.Success) _ <- realms.fetch(github).map(_.rev).assertEquals(1) // Github realm should NOT be updated and the gitlab one should be created bothConfig = RealmsProvisioningConfig(enabled = true, Map(github -> githubRealm, gitlab -> gitlabRealm)) - _ <- RealmProvisioning(realms, bothConfig, serviceAccount) + _ <- runProvisioning(bothConfig).assertEquals(Outcome.Success) _ <- realms.fetch(github).map(_.rev).assertEquals(1) _ <- realms.fetch(gitlab).map(_.rev).assertEquals(1) } yield () @@ -69,6 +73,6 @@ class RealmProvisioningSuite extends NexusSuite with Doobie.Fixture with ConfigF val invalid = Label.unsafe("xxx") val invalidRealm = RealmFields(Name.unsafe("xxx"), uri"https://localhost/xxx", None, None) val invalidConfig = RealmsProvisioningConfig(enabled = true, Map(invalid -> invalidRealm)) - RealmProvisioning(realms, invalidConfig, serviceAccount) >> realms.fetch(invalid).intercept[RealmNotFound] + new RealmProvisioning(realms, invalidConfig, serviceAccount).run >> realms.fetch(invalid).intercept[RealmNotFound] } } diff --git a/docs/src/main/paradox/docs/releases/v1.11-release-notes.md b/docs/src/main/paradox/docs/releases/v1.11-release-notes.md index 52c0444679..35a3c2a51d 100644 --- a/docs/src/main/paradox/docs/releases/v1.11-release-notes.md +++ b/docs/src/main/paradox/docs/releases/v1.11-release-notes.md @@ -24,10 +24,16 @@ Nexus now supports conditional requests. ### Realm provisioning -Nexus now allows to provision realms at start up so as to simplify automated deployments. +Nexus now allows to provision realms at start up to simplify automated deployments. @ref[More here](../running-nexus/configuration/index.md#realm-provisioning) +### Acl provisioning + +Nexus now allows to provision acl at start up to simplify automated deployments. + +@ref[More here](../running-nexus/configuration/index.md#acl-provisioning) + ### Remote storage server The remote storage server part has been removed. diff --git a/docs/src/main/paradox/docs/running-nexus/configuration/index.md b/docs/src/main/paradox/docs/running-nexus/configuration/index.md index 1e9b049526..cf2cde24c8 100644 --- a/docs/src/main/paradox/docs/running-nexus/configuration/index.md +++ b/docs/src/main/paradox/docs/running-nexus/configuration/index.md @@ -89,7 +89,68 @@ If a realm with the same identifier exists it will not be updated. @@@ -@link:[The `realms.provisioning` section](https://github.com/BlueBrain/nexus/blob/$git.branch$/delta/app/src/main/resources/app.conf){ open=new } of the configuration defines the service account configuration. +@link:[The `realms.provisioning` section](https://github.com/BlueBrain/nexus/blob/$git.branch$/delta/app/src/main/resources/app.conf){ open=new } of the configuration defines it. + +## Acl provisioning + +Acl provisioning allows to create one or several acls at startup. + +It is useful to start a new deployment with having to call the @ref:[ACL API](../../delta/api/acls-api.md) to create those. + +Exemple: +```hocon +acls { + #... + + # To provision acls at startup + provisioning { + enabled = true + path = /path/to/initial/acl + } + } +``` + +where the file should be readable from Delta and follow the following json format similar to the ACL API: + +```json +{ + "/" : [ + { + "permissions": [ + "projects/read", + "projects/write" + ], + "identity": { + "realm": "realm", + "group": "admins" + } + } + ], + "/org/proj": [ + { + "permissions": [ + "resources/read", + "resources/write" + ], + "identity": { + "realm": "realm", + "group": "org-proj-users" + } + } + ] + } +``` +Like the + +@@@ note { .warning } + +The realms and the permissions defined in the file must exist in the deployment. + +Acl provisioning will only run if none is create at the root level. + +@@@ + +@link:[The `acls.provisioning` section](https://github.com/BlueBrain/nexus/blob/$git.branch$/delta/app/src/main/resources/app.conf){ open=new } of the configuration defines it. ## Automatic project provisioning diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/search/SearchWiring.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/search/SearchWiring.scala index d09832f385..5582d1f8eb 100644 --- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/search/SearchWiring.scala +++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/search/SearchWiring.scala @@ -7,7 +7,6 @@ import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.CompositeView. import ch.epfl.bluebrain.nexus.delta.plugins.compositeviews.model.TemplateSparqlConstructQuery import ch.epfl.bluebrain.nexus.delta.plugins.search.SearchScopeInitialization import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfig.IndexingConfig -import ch.epfl.bluebrain.nexus.delta.plugins.search.model.SearchConfigError.{InvalidJsonError, InvalidSparqlConstructQuery, LoadingFileError} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue.ContextObject import ch.epfl.bluebrain.nexus.delta.rdf.query.SparqlQuery.SparqlConstructQuery import ch.epfl.bluebrain.nexus.delta.sdk.Defaults @@ -32,22 +31,22 @@ object SearchWiring { private def getAsString(url: String) = { val request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build() - IO.fromEither( - Try(client.send(request, HttpResponse.BodyHandlers.ofString())).toEither.leftMap(LoadingFileError(url, _)) + IO.fromTry( + Try(client.send(request, HttpResponse.BodyHandlers.ofString())) ) } private def loadExternalConfig[A: Decoder](url: String): IO[A] = for { content <- getAsString(url) - value <- IO.fromEither(decode[A](content.body()).leftMap { e => InvalidJsonError(url, e.getMessage) }) + value <- IO.fromEither(decode[A](content.body())) } yield value private def loadSparqlQuery(url: String): IO[SparqlConstructQuery] = for { content <- getAsString(url) value <- IO.fromEither(TemplateSparqlConstructQuery(content.body()).leftMap { e => - InvalidSparqlConstructQuery(url, e) + new IllegalStateException(s"Construct query could not be loaded or is invalid: $e") }) } yield value