diff --git a/delta/app/src/main/resources/app.conf b/delta/app/src/main/resources/app.conf index ee2534a173..f79776d9d5 100644 --- a/delta/app/src/main/resources/app.conf +++ b/delta/app/src/main/resources/app.conf @@ -225,6 +225,20 @@ app { event-log = ${app.defaults.event-log} # the realms pagination config pagination = ${app.defaults.pagination} + + # To provision realms at startup + # Only the name and the OpenId config url are mandatory + provisioning { + enabled = false + realms { + # my-realm = { + # name = "My realm name" + # open-id-config = "https://.../path/.well-known/openid-configuration" + # logo = "https://bbp.epfl.ch/path/favicon.png" + # accepted-audiences = ["audience1", "audience2"] + #} + } + } } # Organizations configuration diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala index 4becaf6af6..26edeaae8c 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutes.scala @@ -1,16 +1,13 @@ package ch.epfl.bluebrain.nexus.delta.routes -import akka.http.scaladsl.model.{StatusCode, StatusCodes, Uri} +import akka.http.scaladsl.model.{StatusCode, StatusCodes} import akka.http.scaladsl.server.{Directive1, Route} -import cats.data.NonEmptySet import cats.effect.IO import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.kernel.circe.CirceUnmarshalling import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering -import ch.epfl.bluebrain.nexus.delta.routes.RealmsRoutes.RealmInput -import ch.epfl.bluebrain.nexus.delta.routes.RealmsRoutes.RealmInput._ import ch.epfl.bluebrain.nexus.delta.sdk.RealmResource import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress @@ -18,16 +15,13 @@ import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ +import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchParams.RealmSearchParams 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.model.{BaseUri, Name} import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{realms => realmsPermissions} import ch.epfl.bluebrain.nexus.delta.sdk.realms.Realms -import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.{Realm, RealmRejection} -import io.circe.Decoder -import io.circe.generic.extras.Configuration -import io.circe.generic.extras.semiauto.deriveConfiguredDecoder +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.{Realm, RealmFields, RealmRejection} class RealmsRoutes(identities: Identities, realms: Realms, aclCheck: AclCheck)(implicit baseUri: BaseUri, @@ -74,16 +68,11 @@ class RealmsRoutes(identities: Identities, realms: Realms, aclCheck: AclCheck)(i parameter("rev".as[Int].?) { case Some(rev) => // Update a realm - entity(as[RealmInput]) { case RealmInput(name, openIdConfig, logo, acceptedAudiences) => - emitMetadata(realms.update(id, rev, name, openIdConfig, logo, acceptedAudiences)) - } + entity(as[RealmFields]) { fields => emitMetadata(realms.update(id, rev, fields)) } case None => // Create a realm - entity(as[RealmInput]) { case RealmInput(name, openIdConfig, logo, acceptedAudiences) => - emitMetadata( - StatusCodes.Created, - realms.create(id, name, openIdConfig, logo, acceptedAudiences) - ) + entity(as[RealmFields]) { fields => + emitMetadata(StatusCodes.Created, realms.create(id, fields)) } } } @@ -117,18 +106,6 @@ class RealmsRoutes(identities: Identities, realms: Realms, aclCheck: AclCheck)(i object RealmsRoutes { - implicit final private val configuration: Configuration = Configuration.default.withStrictDecoding - - final private[routes] case class RealmInput( - name: Name, - openIdConfig: Uri, - logo: Option[Uri], - acceptedAudiences: Option[NonEmptySet[String]] - ) - private[routes] object RealmInput { - implicit val realmDecoder: Decoder[RealmInput] = deriveConfiguredDecoder[RealmInput] - } - /** * @return * the [[Route]] for realms 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 7bcbb2b8dc..bbd17db19b 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 @@ -14,8 +14,9 @@ import ch.epfl.bluebrain.nexus.delta.routes.RealmsRoutes import ch.epfl.bluebrain.nexus.delta.sdk._ import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities -import ch.epfl.bluebrain.nexus.delta.sdk.model.MetadataContextValue -import ch.epfl.bluebrain.nexus.delta.sdk.realms.{Realms, RealmsImpl} +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.realms.{RealmProvisioning, Realms, RealmsConfig, RealmsImpl} import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} @@ -27,27 +28,35 @@ object RealmsModule extends ModuleDef { implicit private val loader: ClasspathResourceLoader = ClasspathResourceLoader.withContext(getClass) + make[RealmsConfig].from { (cfg: AppConfig) => cfg.realms } + make[Realms].from { ( - cfg: AppConfig, + cfg: RealmsConfig, clock: Clock[IO], hc: HttpClient @Id("realm"), xas: Transactors ) => val wellKnownResolver = realms.WellKnownResolver((uri: Uri) => hc.toJson(HttpRequest(uri = uri))) _ - RealmsImpl(cfg.realms, wellKnownResolver, xas, clock) + RealmsImpl(cfg, wellKnownResolver, xas, clock) + } + + make[RealmProvisioning].fromEffect { (realms: Realms, cfg: RealmsConfig, serviceAccount: ServiceAccount) => + RealmProvisioning(realms, cfg.provisioning, serviceAccount) + } make[RealmsRoutes].from { ( identities: Identities, realms: Realms, - cfg: AppConfig, + cfg: RealmsConfig, aclCheck: AclCheck, + baseUri: BaseUri, cr: RemoteContextResolution @Id("aggregate"), ordering: JsonKeyOrdering ) => - new RealmsRoutes(identities, realms, aclCheck)(cfg.http.baseUri, cfg.realms.pagination, cr, ordering) + new RealmsRoutes(identities, realms, aclCheck)(baseUri, cfg.pagination, cr, ordering) } make[HttpClient].named("realm").from { (as: ActorSystem) => diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutesSpec.scala index e476477161..87f24eb1c7 100644 --- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutesSpec.scala +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/RealmsRoutesSpec.scala @@ -14,7 +14,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ import ch.epfl.bluebrain.nexus.delta.sdk.model.Name import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{realms => realmsPermissions} import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmRejection.UnsuccessfulOpenIdConfigResponse -import ch.epfl.bluebrain.nexus.delta.sdk.realms.{RealmsConfig, RealmsImpl} +import ch.epfl.bluebrain.nexus.delta.sdk.realms.{RealmsConfig, RealmsImpl, RealmsProvisioningConfig} 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.Label @@ -28,7 +28,8 @@ class RealmsRoutesSpec extends BaseRouteSpec with IOFromMap { val githubLogo: Uri = "https://localhost/ghlogo" - val config: RealmsConfig = RealmsConfig(eventLogConfig, pagination) + private val provisioning = RealmsProvisioningConfig(enabled = false, Map.empty) + private val config = RealmsConfig(eventLogConfig, pagination, provisioning) val (githubOpenId, githubWk) = WellKnownGen.create(github.value) val (gitlabOpenId, gitlabWk) = WellKnownGen.create(gitlab.value) 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 22f44e5a54..0ca32d2709 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 @@ -18,8 +18,6 @@ import com.typesafe.config.Config import io.circe.parser._ import io.circe.syntax.KeyOps import io.circe.{Decoder, Encoder, JsonObject} -import pureconfig.configurable.genericMapReader -import pureconfig.error.CannotConvert import pureconfig.{ConfigReader, ConfigSource} import java.nio.file.{Files, Path} @@ -39,8 +37,7 @@ object SearchConfig { type Suites = Map[Label, Suite] case class NamedSuite(name: Label, suite: Suite) - implicit private val suitesMapReader: ConfigReader[Suites] = - genericMapReader(str => Label(str).leftMap(e => CannotConvert(str, classOf[Label].getSimpleName, e.getMessage))) + implicit private val suitesMapReader: ConfigReader[Suites] = Label.labelMapReader[Suite] implicit val suiteEncoder: Encoder[NamedSuite] = Encoder[JsonObject].contramap(s => JsonObject("projects" := s.suite, "name" := s.name)) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Name.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Name.scala index 3ca5c035d3..9b9b04400f 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Name.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/Name.scala @@ -1,11 +1,13 @@ package ch.epfl.bluebrain.nexus.delta.sdk.model +import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.kernel.error.FormatError import ch.epfl.bluebrain.nexus.delta.sdk.error.FormatErrors.IllegalNameFormatError import io.circe.{Decoder, Encoder} +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert import scala.util.matching.Regex -import cats.implicits._ /** * A valid name value that can be used to describe resources, like for example the display name of a realm. @@ -48,4 +50,9 @@ object Name { implicit final val nameDecoder: Decoder[Name] = Decoder.decodeString.emap(str => Name(str).leftMap(_.getMessage)) + implicit final val nameConfigReader: ConfigReader[Name] = + ConfigReader.fromString(str => + Name(str).leftMap(err => CannotConvert(str, classOf[Name].getSimpleName, err.getMessage)) + ) + } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/provisioning/AutomaticProvisioningConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/provisioning/AutomaticProvisioningConfig.scala index 76f03f94cf..03730a5059 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/provisioning/AutomaticProvisioningConfig.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/provisioning/AutomaticProvisioningConfig.scala @@ -6,7 +6,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.permissions.model.Permission import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, PrefixIri, ProjectFields} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label import pureconfig.ConfigReader -import pureconfig.configurable._ import pureconfig.error.CannotConvert /** @@ -43,8 +42,7 @@ object AutomaticProvisioningConfig { Permission(str).leftMap(err => CannotConvert(str, classOf[Permission].getSimpleName, err.getMessage)) ) - implicit private val mapReader: ConfigReader[Map[Label, Label]] = - genericMapReader(str => Label(str).leftMap(e => CannotConvert(str, classOf[Label].getSimpleName, e.getMessage))) + implicit private val mapReader: ConfigReader[Map[Label, Label]] = Label.labelMapReader[Label] implicit private val prefixIriReader: ConfigReader[PrefixIri] = ConfigReader[Iri].emap { iri => PrefixIri(iri).leftMap { e => CannotConvert(iri.toString, classOf[PrefixIri].getSimpleName, e.getMessage) } 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 new file mode 100644 index 0000000000..8e1d3ced11 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioning.scala @@ -0,0 +1,38 @@ +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.identities.model.ServiceAccount +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 + +object RealmProvisioning extends RealmProvisioning { + + private val logger = Logger[RealmProvisioning] + + def apply( + realms: Realms, + config: RealmsProvisioningConfig, + serviceAccount: ServiceAccount + ): IO[RealmProvisioning.type] = + if (config.enabled) { + implicit val serviceAccountSubject: Subject = serviceAccount.subject + for { + _ <- logger.info(s"Realm provisioning is active. Creating ${config.realms.size} realms...") + _ <- config.realms.toList.traverse { case (label, fields) => + realms.create(label, fields).recoverWith { + case r: RealmAlreadyExists => logger.debug(r)(s"Realm '$label' already exists") + case e => logger.error(e)(s"Realm '$label' could not be created: '${e.getMessage}'") + } + } + _ <- logger.info(s"Provisioning ${config.realms.size} realms is completed") + } yield RealmProvisioning + } else logger.info(s"Realm provisioning is inactive.").as(RealmProvisioning) + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/Realms.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/Realms.scala index 05a1793858..819730214b 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/Realms.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/Realms.scala @@ -1,15 +1,14 @@ package ch.epfl.bluebrain.nexus.delta.sdk.realms import akka.http.scaladsl.model.Uri -import cats.data.NonEmptySet import cats.effect.{Clock, IO} - +import cats.implicits._ import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination.FromPagination import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri import ch.epfl.bluebrain.nexus.delta.sdk.RealmResource +import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceUris import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchParams.RealmSearchParams import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults.UnscoredSearchResults -import ch.epfl.bluebrain.nexus.delta.sdk.model.{Name, ResourceUris} import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmCommand.{CreateRealm, DeprecateRealm, UpdateRealm} import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmEvent.{RealmCreated, RealmDeprecated, RealmUpdated} import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmRejection.{IncorrectRev, RealmAlreadyDeprecated, RealmAlreadyExists, RealmNotFound} @@ -18,7 +17,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Label} import ch.epfl.bluebrain.nexus.delta.sourcing.{GlobalEntityDefinition, StateMachine} -import cats.implicits._ /** * Operations pertaining to managing realms. @@ -30,21 +28,12 @@ trait Realms { * * @param label * the realm label - * @param name - * the name of the realm - * @param openIdConfig - * the address of the openid configuration - * @param logo - * an optional realm logo - * @param acceptedAudiences - * the optional set of audiences of this realm. JWT with `aud` which do not match this field will be rejected + * @param fields + * the realm information */ def create( label: Label, - name: Name, - openIdConfig: Uri, - logo: Option[Uri], - acceptedAudiences: Option[NonEmptySet[String]] + fields: RealmFields )(implicit caller: Subject): IO[RealmResource] /** @@ -54,22 +43,13 @@ trait Realms { * the realm label * @param rev * the current revision of the realm - * @param name - * the new name for the realm - * @param openIdConfig - * the new openid configuration address - * @param logo - * an optional new logo - * @param acceptedAudiences - * the optional set of audiences of this realm. JWT with `aud` which do not match this field will be rejected + * @param fields + * the realm information */ def update( label: Label, rev: Int, - name: Name, - openIdConfig: Uri, - logo: Option[Uri], - acceptedAudiences: Option[NonEmptySet[String]] + fields: RealmFields )(implicit caller: Subject): IO[RealmResource] /** diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsConfig.scala index 0d2afea088..64973673e6 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsConfig.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsConfig.scala @@ -12,10 +12,13 @@ import pureconfig.generic.semiauto.deriveReader * The event log configuration * @param pagination * configuration for how pagination should behave in listing operations + * @param provisioning + * configuration to provision realms at startup */ final case class RealmsConfig( eventLog: EventLogConfig, - pagination: PaginationConfig + pagination: PaginationConfig, + provisioning: RealmsProvisioningConfig ) object RealmsConfig { diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsImpl.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsImpl.scala index 2dd5767bc6..309c4d92e2 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsImpl.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsImpl.scala @@ -1,12 +1,10 @@ package ch.epfl.bluebrain.nexus.delta.sdk.realms import akka.http.scaladsl.model.Uri -import cats.data.NonEmptySet import cats.effect.{Clock, IO} import ch.epfl.bluebrain.nexus.delta.kernel.kamon.KamonMetricComponent import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination import ch.epfl.bluebrain.nexus.delta.sdk.RealmResource -import ch.epfl.bluebrain.nexus.delta.sdk.model._ import ch.epfl.bluebrain.nexus.delta.sdk.model.search.{SearchParams, SearchResults} import ch.epfl.bluebrain.nexus.delta.sdk.realms.Realms.entityType import ch.epfl.bluebrain.nexus.delta.sdk.realms.RealmsImpl.RealmsLog @@ -24,24 +22,19 @@ final class RealmsImpl private (log: RealmsLog) extends Realms { override def create( label: Label, - name: Name, - openIdConfig: Uri, - logo: Option[Uri], - acceptedAudiences: Option[NonEmptySet[String]] + fields: RealmFields )(implicit caller: Subject): IO[RealmResource] = { - val command = CreateRealm(label, name, openIdConfig, logo, acceptedAudiences, caller) + val command = CreateRealm(label, fields.name, fields.openIdConfig, fields.logo, fields.acceptedAudiences, caller) eval(command).span("createRealm") } override def update( label: Label, rev: Int, - name: Name, - openIdConfig: Uri, - logo: Option[Uri], - acceptedAudiences: Option[NonEmptySet[String]] + fields: RealmFields )(implicit caller: Subject): IO[RealmResource] = { - val command = UpdateRealm(label, rev, name, openIdConfig, logo, acceptedAudiences, caller) + val command = + UpdateRealm(label, rev, fields.name, fields.openIdConfig, fields.logo, fields.acceptedAudiences, caller) eval(command).span("updateRealm") } 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 new file mode 100644 index 0000000000..c909ab5f26 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfig.scala @@ -0,0 +1,24 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.realms + +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmFields +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import pureconfig.ConfigReader +import pureconfig.generic.semiauto.deriveReader + +/** + * Configuration to provision realms + * @param enabled + * flag to enable provisioning at startup + * @param realms + * the collection of realms to create + */ +final case class RealmsProvisioningConfig(enabled: Boolean, realms: Map[Label, RealmFields]) + +object RealmsProvisioningConfig { + + implicit private val mapReader: ConfigReader[Map[Label, RealmFields]] = Label.labelMapReader[RealmFields] + + implicit final val reamsProvisioningConfigReader: ConfigReader[RealmsProvisioningConfig] = + deriveReader[RealmsProvisioningConfig] + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/model/RealmFields.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/model/RealmFields.scala new file mode 100644 index 0000000000..5ab3ac4706 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/model/RealmFields.scala @@ -0,0 +1,27 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.realms.model + +import akka.http.scaladsl.model.Uri +import cats.data.NonEmptySet +import ch.epfl.bluebrain.nexus.delta.sdk.model.Name +import io.circe.Decoder +import ch.epfl.bluebrain.nexus.delta.sdk.instances._ +import pureconfig.module.cats._ +import io.circe.generic.extras.Configuration +import io.circe.generic.extras.semiauto.deriveConfiguredDecoder +import pureconfig.ConfigReader +import pureconfig.generic.semiauto.deriveReader + +final case class RealmFields( + name: Name, + openIdConfig: Uri, + logo: Option[Uri], + acceptedAudiences: Option[NonEmptySet[String]] +) + +object RealmFields { + + implicit final private val configuration: Configuration = Configuration.default.withStrictDecoding + implicit val realmFieldsDecoder: Decoder[RealmFields] = deriveConfiguredDecoder[RealmFields] + + implicit final val realmFieldsConfigReader: ConfigReader[RealmFields] = deriveReader[RealmFields] +} 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 new file mode 100644 index 0000000000..974cd6a071 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmProvisioningSuite.scala @@ -0,0 +1,74 @@ +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.generators.WellKnownGen +import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount +import ch.epfl.bluebrain.nexus.delta.sdk.model.Name +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmFields +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmRejection.{RealmNotFound, UnsuccessfulOpenIdConfigResponse} +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.ce.IOFromMap +import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite +import munit.AnyFixture + +class RealmProvisioningSuite extends NexusSuite with Doobie.Fixture with ConfigFixtures with IOFromMap { + + override def munitFixtures: Seq[AnyFixture[_]] = List(doobie) + + private val provisioning = RealmsProvisioningConfig(enabled = false, Map.empty) + private val config = RealmsConfig(eventLogConfig, pagination, provisioning) + + private val serviceAccount: ServiceAccount = ServiceAccount(User("nexus-sa", Label.unsafe("sa"))) + + private lazy val xas = doobie() + + private val github = Label.unsafe("github") + private val githubName = Name.unsafe("github-name") + + private val gitlab = Label.unsafe("gitlab") + private val gitlabName = Name.unsafe("gitlab-name") + + private val (githubOpenId, githubWk) = WellKnownGen.create(github.value) + private val (gitlabOpenId, gitlabWk) = WellKnownGen.create(gitlab.value) + + private lazy val realms = RealmsImpl( + config, + ioFromMap( + Map(githubOpenId -> githubWk, gitlabOpenId -> gitlabWk), + (uri: Uri) => UnsuccessfulOpenIdConfigResponse(uri) + ), + xas, + clock + ) + + 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) + _ <- 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) + _ <- 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) + _ <- realms.fetch(github).map(_.rev).assertEquals(1) + _ <- realms.fetch(gitlab).map(_.rev).assertEquals(1) + } yield () + } + + test("Fail for a invalid OpenId config") { + 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] + } +} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfigSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfigSuite.scala new file mode 100644 index 0000000000..21c8640861 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/realms/RealmsProvisioningConfigSuite.scala @@ -0,0 +1,76 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.realms + +import cats.data.NonEmptySet +import ch.epfl.bluebrain.nexus.delta.rdf.syntax.uriStringContextSyntax +import ch.epfl.bluebrain.nexus.delta.sdk.model.Name +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmFields +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite +import pureconfig.ConfigSource + +class RealmsProvisioningConfigSuite extends NexusSuite { + + private def parseConfig(value: String) = + ConfigSource.string(value).at("provisioning").load[RealmsProvisioningConfig] + + test("Parse successfully the config when no realm is configured") { + val obtained = parseConfig( + """ + |provisioning { + | enabled = false + | realms { + | } + |} + |""".stripMargin + ) + + val expected = RealmsProvisioningConfig(enabled = false, Map.empty) + assertEquals(obtained, Right(expected)) + } + + test("Parse successfully the config is enable with several realms") { + val obtained = parseConfig( + """ + |provisioning { + | enabled = true + | realms { + | bbp = { + | name = "BBP" + | open-id-config = "https://bbp.epfl.ch/path/.well-known/openid-configuration" + | logo = "https://bbp.epfl.ch/path/favicon.png" + | accepted-audiences = ["audience1", "audience2"] + | } + | obp = { + | name = "OBP" + | open-id-config = "https://openbluebrain.com/path/.well-known/openid-configuration" + | } + | } + |} + |""".stripMargin + ) + + val bbp = RealmFields( + Name.unsafe("BBP"), + uri"https://bbp.epfl.ch/path/.well-known/openid-configuration", + Some(uri"https://bbp.epfl.ch/path/favicon.png"), + Some(NonEmptySet.of("audience1", "audience2")) + ) + + val obp = RealmFields( + Name.unsafe("OBP"), + uri"https://openbluebrain.com/path/.well-known/openid-configuration", + None, + None + ) + + val expected = RealmsProvisioningConfig( + enabled = true, + Map( + Label.unsafe("bbp") -> bbp, + Label.unsafe("obp") -> obp + ) + ) + assertEquals(obtained, Right(expected)) + } + +} diff --git a/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/model/Label.scala b/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/model/Label.scala index 3be4806408..1df6d2d586 100644 --- a/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/model/Label.scala +++ b/delta/sourcing-psql/src/main/scala/ch/epfl/bluebrain/nexus/delta/sourcing/model/Label.scala @@ -8,6 +8,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.decoder.JsonLdDecoderError.Parsi import doobie.{Get, Put} import io.circe.{Decoder, Encoder, KeyDecoder, KeyEncoder} import pureconfig.ConfigReader +import pureconfig.configurable.genericMapReader import pureconfig.error.CannotConvert import scala.util.matching.Regex @@ -64,6 +65,9 @@ object Label { def sanitized(value: String): Either[FormatError, Label] = apply(value.replaceAll(s"[^$allowedChars]", "").take(64)) + private def configConvert(value: String): Either[CannotConvert, Label] = + apply(value).leftMap(e => CannotConvert(value, classOf[Label].getSimpleName, e.getMessage)) + implicit val labelGet: Get[Label] = Get[String].temap(Label(_).leftMap(_.getMessage)) implicit val labelPut: Put[Label] = Put[String].contramap(_.value) @@ -80,7 +84,8 @@ object Label { (cursor: ExpandedJsonLdCursor) => cursor.get[String].flatMap { Label(_).leftMap { e => ParsingFailure(e.getMessage) } } - implicit val labelConfigReader: ConfigReader[Label] = ConfigReader.fromString(str => - Label(str).leftMap(e => CannotConvert(str, classOf[Label].getSimpleName, e.getMessage)) - ) + implicit val labelConfigReader: ConfigReader[Label] = ConfigReader.fromString(configConvert) + + def labelMapReader[V](implicit readerV: ConfigReader[V]): ConfigReader[Map[Label, V]] = + genericMapReader[Label, V](configConvert) } diff --git a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/config/OrganizationCreationConfig.scala b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/config/OrganizationCreationConfig.scala index 50ab67dcf2..561ac3d4b9 100644 --- a/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/config/OrganizationCreationConfig.scala +++ b/ship/src/main/scala/ch/epfl/bluebrain/nexus/ship/config/OrganizationCreationConfig.scala @@ -1,10 +1,7 @@ package ch.epfl.bluebrain.nexus.ship.config -import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label import pureconfig.ConfigReader -import pureconfig.configurable._ -import pureconfig.error.CannotConvert import pureconfig.generic.semiauto.deriveReader final case class OrganizationCreationConfig(values: Map[Label, String]) @@ -12,8 +9,7 @@ final case class OrganizationCreationConfig(values: Map[Label, String]) object OrganizationCreationConfig { implicit final val organizationCreationConfigReader: ConfigReader[OrganizationCreationConfig] = { - implicit val mapReader: ConfigReader[Map[Label, String]] = - genericMapReader(str => Label(str).leftMap(e => CannotConvert(str, classOf[Label].getSimpleName, e.getMessage))) + implicit val mapReader: ConfigReader[Map[Label, String]] = Label.labelMapReader[String] deriveReader[OrganizationCreationConfig] }