diff --git a/app/fr/maif/izanami/datastores/ApiKeyDatastore.scala b/app/fr/maif/izanami/datastores/ApiKeyDatastore.scala index 4f94236d8..a45081607 100644 --- a/app/fr/maif/izanami/datastores/ApiKeyDatastore.scala +++ b/app/fr/maif/izanami/datastores/ApiKeyDatastore.scala @@ -9,6 +9,7 @@ import fr.maif.izanami.models.{ApiKey, ApiKeyProject, ApiKeyWithCompleteRights} import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.BetterSyntax import fr.maif.izanami.web.ImportController.{Fail, ImportConflictStrategy} +import fr.maif.izanami.web.UserInformation import io.vertx.pgclient.PgException import io.vertx.sqlclient.{Row, SqlConnection} @@ -19,7 +20,7 @@ import scala.concurrent.Future class ApiKeyDatastore(val env: Env) extends Datastore { def createApiKey( apiKey: ApiKey, - user: String + user: UserInformation ): Future[Either[IzanamiError, ApiKey]] = { createApiKeys(apiKey.tenant, apiKeys = Seq(apiKey), user=user, conflictStrategy = Fail, conn= None) .map(e => e.map(_.head).left.map(_.head)) @@ -35,7 +36,7 @@ class ApiKeyDatastore(val env: Env) extends Datastore { def createApiKeys( tenant: String, apiKeys: Seq[ApiKey], - user: String, + user: UserInformation, conflictStrategy: ImportConflictStrategy, conn: Option[SqlConnection] ): Future[Either[Seq[IzanamiError], Seq[ApiKey]]] = { @@ -101,7 +102,7 @@ class ApiKeyDatastore(val env: Env) extends Datastore { |VALUES (unnest($$1::text[]), unnest($$2::text[]), 'ADMIN') |RETURNING apikey |""".stripMargin, - List(Array.fill(apiKeys.size)(user), apiKeys.map(_.name).toArray), + List(Array.fill(apiKeys.size)(user.username), apiKeys.map(_.name).toArray), conn = Some(connection), schemas = Set(tenant) ) { r => apiKeys.find(k => k.name == r.getString("apikey")) } diff --git a/app/fr/maif/izanami/datastores/EventDatastore.scala b/app/fr/maif/izanami/datastores/EventDatastore.scala new file mode 100644 index 000000000..7c9f8ce4f --- /dev/null +++ b/app/fr/maif/izanami/datastores/EventDatastore.scala @@ -0,0 +1,168 @@ +package fr.maif.izanami.datastores + +import akka.actor.Cancellable +import fr.maif.izanami.datastores.EventDatastore.{AscOrder, FeatureEventRequest} +import fr.maif.izanami.env.Env +import fr.maif.izanami.env.pgimplicits.EnhancedRow +import fr.maif.izanami.errors.{EventNotFound, FailedToReadEvent, IzanamiError} +import fr.maif.izanami.events.EventService.{FeatureEventType, IZANAMI_CHANNEL, eventFormat} +import fr.maif.izanami.events.IzanamiEvent +import fr.maif.izanami.utils.Datastore + +import java.time.{Duration, Instant, ZoneOffset} +import scala.concurrent.Future +import scala.concurrent.duration.DurationInt + +class EventDatastore(val env: Env) extends Datastore { + var eventCleanerCancellation: Cancellable = Cancellable.alreadyCancelled + + override def onStart(): Future[Unit] = { + eventCleanerCancellation = env.actorSystem.scheduler.scheduleAtFixedRate(5.minutes, 5.minutes)(() =>{ + deleteExpiredEvents(env.configuration.get[Int]("app.audit.events-hours-ttl")) + }) + Future.successful(()) + } + + override def onStop(): Future[Unit] = { + eventCleanerCancellation.cancel() + Future.successful(()) + } + + def deleteExpiredEvents(hours: Int): Future[Unit] = { + env.datastores.tenants.readTenants() + .flatMap(tenants => Future.sequence(tenants.map(t => deleteExpiredEventsForTenant(t.name, hours)))) + .map(_ => ()) + } + + + def deleteExpiredEventsForTenant(tenant: String, hours: Int): Future[Unit] = { + env.postgresql.queryRaw( + s""" + |DELETE FROM events WHERE EXTRACT(HOUR FROM NOW() - emitted_at) > $$1 + |""".stripMargin, + params=List(java.lang.Integer.valueOf(hours)), + schemas = Set(tenant) + ){_ => Some(())} + } + + + + def readEventFromDb(tenant: String, id: Long): Future[Either[IzanamiError, IzanamiEvent]] = { + val global = tenant.equals(IZANAMI_CHANNEL) + env.postgresql + .queryOne( + s""" + |SELECT event FROM ${if (global) "izanami.global_events" else "events"} WHERE id=$$1 + |""".stripMargin, + List(java.lang.Long.valueOf(id)), + schemas = if (global) Set() else Set(tenant) + ) { r => r.optJsObject("event") } + .map(o => o.toRight(EventNotFound(tenant, id))) + .map(e => e.flatMap(js => eventFormat.reads(js).asOpt.toRight(FailedToReadEvent(js.toString())))) + + } + + def listEventsForProject(tenant: String, projectId: String, request: FeatureEventRequest): Future[(Seq[IzanamiEvent], Option[Long])] = { + + def queryBody(startIndex: Int): (String, List[AnyRef]) = { + var index = startIndex + val query = + s""" + |FROM events e, projects p + |WHERE e.event->>'projectId'=p.id::TEXT + |AND p.name=$$${index} + |${if (request.users.nonEmpty) s"AND e.username = ANY($$${index += 1; index}::TEXT[])" else ""} + |${request.begin.map(_ => s"AND e.emitted_at >= $$${index += 1; index}").getOrElse("")} + |${request.end.map(_ => s"AND e.emitted_at <= $$${index += 1; index}").getOrElse("")} + |${if (request.eventTypes.nonEmpty) s"AND e.event_type = ANY($$${index += 1; index}::izanami.LOCAL_EVENT_TYPES[])" else ""} + |${if (request.features.nonEmpty) s"AND e.entity_id = ANY($$${index += 1; index}::TEXT[])" else ""} + |""".stripMargin + + (query, List(projectId).concat(List( + Option(request.users.toArray).filter(_.nonEmpty), request.begin.map(_.atOffset(ZoneOffset.UTC)), request.end.map(_.atOffset(ZoneOffset.UTC)), Option(request.eventTypes.map(_.name).toArray).filter(_.nonEmpty), Option(request.features.toArray).filter(_.nonEmpty) + ).collect { case Some(t: AnyRef) => t })) + } + + val maybeFutureCount = if (request.total) { + val (body, params) = queryBody(1) + env.postgresql.queryOne( + s""" + |SELECT COUNT(*) as total + |${body} + |""".stripMargin, params = params, schemas = Set(tenant) + ) { r => r.optLong("total") } + } else { + Future.successful(None) + } + + val futureResult = { + val (body, ps) = queryBody(request.cursor.map(_ => 3).getOrElse(2)) + env.postgresql + .queryAll( + s""" + |SELECT e.event + |${body} + |${request.cursor.map(_ => s"AND e.id ${if (request.sortOrder == AscOrder) ">" else "<"} $$2").getOrElse("")} + |ORDER BY e.id ${request.sortOrder.toDb} + |LIMIT $$1 + |""".stripMargin, + params = List(Some(java.lang.Integer.valueOf(request.count)), request.cursor.map(java.lang.Long.valueOf)).collect { case Some(t) => t }.concat(ps), + schemas = Set(tenant) + ) { r => r.optJsObject("event") } + }.map(jsons => { + jsons + .map(json => { + val readResult = eventFormat.reads(json) + readResult.fold( + err => { + logger.error(s"Failed to read event : ${err}") + None + }, + evt => Some(evt) + ) + }) + .collect({ case Some(evt) => evt }) + }) + + maybeFutureCount.flatMap(maybeCount => { + futureResult.map(result => (result, maybeCount)) + }) + } + + +} + + +object EventDatastore { + + sealed trait SortOrder { + def toDb: String + } + + case object AscOrder extends SortOrder { + override def toDb: String = "ASC" + } + + case object DescOrder extends SortOrder { + override def toDb: String = "DESC" + } + + def parseSortOrder(order: String): Option[SortOrder] = { + Option(order).map(_.toUpperCase).collect { + case "ASC" => AscOrder + case "DESC" => DescOrder + } + } + + case class FeatureEventRequest( + sortOrder: SortOrder, + cursor: Option[Long], + count: Int, + users: Set[String] = Set(), + features: Set[String] = Set(), + begin: Option[Instant] = None, + end: Option[Instant] = None, + eventTypes: Set[FeatureEventType] = Set(), + total: Boolean + ) +} \ No newline at end of file diff --git a/app/fr/maif/izanami/datastores/FeatureContextDatastore.scala b/app/fr/maif/izanami/datastores/FeatureContextDatastore.scala index 531ec8f28..b7787b251 100644 --- a/app/fr/maif/izanami/datastores/FeatureContextDatastore.scala +++ b/app/fr/maif/izanami/datastores/FeatureContextDatastore.scala @@ -5,28 +5,15 @@ import fr.maif.izanami.env.Env import fr.maif.izanami.env.PostgresqlErrors.{FOREIGN_KEY_VIOLATION, RELATION_DOES_NOT_EXISTS, UNIQUE_VIOLATION} import fr.maif.izanami.env.pgimplicits.EnhancedRow import fr.maif.izanami.errors._ +import fr.maif.izanami.events.EventOrigin.NormalOrigin import fr.maif.izanami.events.SourceFeatureUpdated import fr.maif.izanami.models.FeatureContext.generateSubContextId import fr.maif.izanami.models._ -import fr.maif.izanami.models.features.{ - ActivationCondition, - BooleanActivationCondition, - BooleanResult, - BooleanResultDescriptor, - NumberActivationCondition, - NumberResult, - NumberResultDescriptor, - ResultType, - StringActivationCondition, - StringResult, - StringResultDescriptor, - ValuedActivationCondition, - ValuedResultDescriptor, - ValuedResultType -} +import fr.maif.izanami.models.features.{ActivationCondition, BooleanActivationCondition, BooleanResult, BooleanResultDescriptor, NumberActivationCondition, NumberResult, NumberResultDescriptor, ResultType, StringActivationCondition, StringResult, StringResultDescriptor, ValuedActivationCondition, ValuedResultDescriptor, ValuedResultType} import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.BetterSyntax import fr.maif.izanami.wasm.WasmConfig +import fr.maif.izanami.web.UserInformation import io.otoroshi.wasm4s.scaladsl.WasmSourceKind import io.vertx.core.json.JsonArray import io.vertx.pgclient.PgException @@ -391,7 +378,7 @@ class FeatureContextDatastore(val env: Env) extends Datastore { project: String, path: Seq[String], feature: String, - user: String + user: UserInformation ): Future[Either[IzanamiError, Unit]] = { val isLocal = env.postgresql .queryOne( @@ -440,9 +427,11 @@ class FeatureContextDatastore(val env: Env) extends Datastore { id = fid, project = project, tenant = tenant, - user = user, + user = user.username, previous = featureWithOverloads, - feature = featureWithOverloads.removeOverload(path.mkString("_")) + feature = featureWithOverloads.removeOverload(path.mkString("_")), + origin = NormalOrigin, + authentication = user.authentication ) ) .map(_ => Right(())) @@ -471,7 +460,7 @@ class FeatureContextDatastore(val env: Env) extends Datastore { path: Seq[String], feature: String, strategy: CompleteContextualStrategy, - user: String + user: UserInformation ): Future[Either[IzanamiError, Unit]] = { // TODO factorize this val isLocal = env.postgresql @@ -588,10 +577,12 @@ class FeatureContextDatastore(val env: Env) extends Datastore { id = fid, project = project, tenant = tenant, - user = user, + user = user.username, previous = oldFeature, feature = oldFeature - .updateConditionsForContext(path.mkString("_"), strategy.toLightWeightContextualStrategy) + .updateConditionsForContext(path.mkString("_"), strategy.toLightWeightContextualStrategy), + origin = NormalOrigin, + authentication = user.authentication ) ) .map(_ => Right(())) diff --git a/app/fr/maif/izanami/datastores/FeaturesDatastore.scala b/app/fr/maif/izanami/datastores/FeaturesDatastore.scala index 7532de808..62d87ba0c 100644 --- a/app/fr/maif/izanami/datastores/FeaturesDatastore.scala +++ b/app/fr/maif/izanami/datastores/FeaturesDatastore.scala @@ -2,42 +2,20 @@ package fr.maif.izanami.datastores import fr.maif.izanami.datastores.featureImplicits.FeatureRow import fr.maif.izanami.env.Env -import fr.maif.izanami.env.PostgresqlErrors.{ - FOREIGN_KEY_VIOLATION, - NOT_NULL_VIOLATION, - RELATION_DOES_NOT_EXISTS, - UNIQUE_VIOLATION -} +import fr.maif.izanami.env.PostgresqlErrors.{FOREIGN_KEY_VIOLATION, NOT_NULL_VIOLATION, RELATION_DOES_NOT_EXISTS, UNIQUE_VIOLATION} import fr.maif.izanami.env.pgimplicits.EnhancedRow import fr.maif.izanami.errors._ -import fr.maif.izanami.events.{EventService, SourceFeatureCreated, SourceFeatureDeleted, SourceFeatureUpdated} +import fr.maif.izanami.events.EventAuthentication.BackOfficeAuthentication +import fr.maif.izanami.events.EventOrigin.{ImportOrigin, NormalOrigin} +import fr.maif.izanami.events.{EventOrigin, EventService, SourceFeatureCreated, SourceFeatureDeleted, SourceFeatureUpdated} import fr.maif.izanami.models._ -import fr.maif.izanami.models.features.{ - ActivationCondition, - BooleanActivationCondition, - BooleanResult, - BooleanResultDescriptor, - EnabledFeaturePatch, - FeaturePatch, - LegacyCompatibleCondition, - NumberActivationCondition, - NumberResult, - NumberResultDescriptor, - ProjectFeaturePatch, - RemoveFeaturePatch, - ResultType, - StringActivationCondition, - StringResult, - StringResultDescriptor, - TagsFeaturePatch, - ValuedResultDescriptor, - ValuedResultType -} +import fr.maif.izanami.models.features.{ActivationCondition, BooleanActivationCondition, BooleanResult, BooleanResultDescriptor, EnabledFeaturePatch, FeaturePatch, LegacyCompatibleCondition, NumberActivationCondition, NumberResult, NumberResultDescriptor, ProjectFeaturePatch, RemoveFeaturePatch, ResultType, StringActivationCondition, StringResult, StringResultDescriptor, TagsFeaturePatch, ValuedResultDescriptor, ValuedResultType} import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.{BetterJsValue, BetterListEither, BetterSyntax} import fr.maif.izanami.v1.V1FeatureEvents import fr.maif.izanami.wasm.{WasmConfig, WasmConfigWithFeatures, WasmScriptAssociatedFeatures} import fr.maif.izanami.web.ImportController.{Fail, ImportConflictStrategy, MergeOverwrite, Skip} +import fr.maif.izanami.web.UserInformation import io.otoroshi.wasm4s.scaladsl.WasmSourceKind import io.vertx.core.json.{JsonArray, JsonObject} import io.vertx.core.shareddata.ClusterSerializable @@ -215,7 +193,8 @@ class FeaturesDatastore(val env: Env) extends Datastore { // TODO deduplicate def findActivationStrategiesForFeatures( tenant: String, - ids: Set[String] + ids: Set[String], + conn: Option[SqlConnection] = None ): Future[Map[String, Map[String, LightWeightFeature]]] = { env.postgresql .queryAll( @@ -247,7 +226,8 @@ class FeaturesDatastore(val env: Env) extends Datastore { |WHERE f.id=ANY($$1) |GROUP BY f.id""".stripMargin, params = List(ids.toArray), - schemas = Set(tenant) + schemas = Set(tenant), + conn=conn ) { r => { val maybeTuple = r @@ -394,7 +374,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { } } - def applyPatch(tenant: String, operations: Seq[FeaturePatch], user: String): Future[Unit] = { + def applyPatch(tenant: String, operations: Seq[FeaturePatch], user: UserInformation): Future[Unit] = { val featureToUpdateIds = operations.collect { case p: EnabledFeaturePatch => p.id case p: ProjectFeaturePatch => p.id @@ -421,18 +401,27 @@ class FeaturesDatastore(val env: Env) extends Datastore { ) yield (id, name, project, enabled) } .flatMap { - case Some((id, name, project, enabled)) => - env.eventService.emitEvent( - channel = tenant, - event = SourceFeatureUpdated( - id = id, - project = project, - tenant = tenant, - user = user, - previous = FeatureWithOverloads(oldFeatures(id)), - feature = FeatureWithOverloads(oldFeatures(id)).setEnabling(value) + case Some((id, name, project, enabled)) => { + val completeFeatureStrategies = FeatureWithOverloads(oldFeatures(id)) + val hasChanged = completeFeatureStrategies.baseFeature().enabled != value + if(hasChanged) { + env.eventService.emitEvent( + channel = tenant, + event = SourceFeatureUpdated( + id = id, + project = project, + tenant = tenant, + user = user.username, + previous = FeatureWithOverloads(oldFeatures(id)), + feature = FeatureWithOverloads(oldFeatures(id)).setEnabling(value), + origin = NormalOrigin, + authentication = user.authentication + ) ) - ) + } else { + Future.successful(()) + } + } case None => Future.successful(()) } } @@ -458,9 +447,11 @@ class FeaturesDatastore(val env: Env) extends Datastore { id = id, project = project, tenant = tenant, - user = user, + user = user.username, previous = FeatureWithOverloads(oldFeatures(id)), - feature = FeatureWithOverloads(oldFeatures(id)).setProject(value) + feature = FeatureWithOverloads(oldFeatures(id)).setProject(value), + origin = NormalOrigin, + authentication = user.authentication ) )(conn) case None => Future.successful(()) @@ -513,7 +504,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { case Some((id, name, project, enabled)) => env.eventService.emitEvent( channel = tenant, - event = SourceFeatureDeleted(id = id, project = project, tenant = tenant, user = user) + event = SourceFeatureDeleted(id = id, project = project, tenant = tenant, user = user.username, name=name, origin = NormalOrigin, authentication = BackOfficeAuthentication) )(conn) case None => Future.successful(()) } @@ -1051,7 +1042,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { tenant: String, features: Iterable[CompleteFeature], conflictStrategy: ImportConflictStrategy, - user: String, + user: UserInformation, conn: Option[SqlConnection] ): Future[Either[List[IzanamiError], Unit]] = { // TODO return seq[Error] instead of a single one @@ -1076,7 +1067,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { features: Iterable[CompleteFeature], conflictStrategy: ImportConflictStrategy, conn: SqlConnection, - user: String + user: UserInformation ): Future[Either[List[IzanamiError], Unit]] = { def insertFeatures[T <: ClusterSerializable]( params: ( @@ -1347,8 +1338,10 @@ class FeaturesDatastore(val env: Env) extends Datastore { id = f.id, project = f.project, tenant = tenant, - user = user, - feature = FeatureWithOverloads(f.toLightWeightFeature) + user = user.username, + feature = FeatureWithOverloads(f.toLightWeightFeature), + origin = ImportOrigin, + authentication = user.authentication ) )(conn) ) @@ -1362,7 +1355,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { tenant: String, project: String, feature: CompleteFeature, - user: String + user: UserInformation ): Future[Either[IzanamiError, String]] = { env.postgresql.executeInTransaction( implicit conn => doCreate(tenant, project, feature, conn, user), @@ -1375,7 +1368,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { project: String, feature: CompleteFeature, conn: SqlConnection, - user: String + user: UserInformation ): Future[Either[IzanamiError, String]] = { (feature match { case Feature(_, _, _, _, _, _, _, _) => Future(Right(())) @@ -1585,8 +1578,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { tenant: String, project: String, feature: CompleteFeature, - user: String, - importConflictStrategy: ImportConflictStrategy = Fail + user: UserInformation )(implicit conn: SqlConnection ): Future[Either[IzanamiError, String]] = { @@ -1670,8 +1662,10 @@ class FeaturesDatastore(val env: Env) extends Datastore { id = id, project = project, tenant = tenant, - user = user, - feature = FeatureWithOverloads(feature.toLightWeightFeature) + user = user.username, + feature = FeatureWithOverloads(feature.toLightWeightFeature), + authentication = user.authentication, + origin = NormalOrigin ) )(conn) .map(_ => Right(id)) @@ -1682,7 +1676,7 @@ class FeaturesDatastore(val env: Env) extends Datastore { tenant: String, id: String, feature: CompleteFeature, - user: String, + user: UserInformation, conn: Option[SqlConnection] = None ): Future[Either[IzanamiError, String]] = { @@ -1802,9 +1796,11 @@ class FeaturesDatastore(val env: Env) extends Datastore { id = id, project = feature.project, tenant = tenant, - user = user, + user = user.username, previous = oldFeature, - feature = oldFeature.setFeature(feature.toLightWeightFeature) + feature = oldFeature.setFeature(feature.toLightWeightFeature), + authentication = user.authentication, + origin = NormalOrigin ) )(conn) .map(_ => Right(id)) @@ -1857,19 +1853,20 @@ class FeaturesDatastore(val env: Env) extends Datastore { } } - def delete(tenant: String, id: String, user: String): Future[Either[IzanamiError, String]] = { + def delete(tenant: String, id: String, user: UserInformation): Future[Either[IzanamiError, String]] = { env.postgresql.executeInTransaction(conn => env.postgresql .queryOne( - s"""DELETE FROM features WHERE id=$$1 returning id, project""", + s"""DELETE FROM features WHERE id=$$1 returning id, project, name""", List(id), schemas = Set(tenant), conn = Some(conn) ) { row => for ( id <- row.optString("id"); - project <- row.optString("project") - ) yield (id, project) + project <- row.optString("project"); + name <- row.optString("name") + ) yield (id, project, name) } .map { _.toRight(InternalServerError()) } .recover { @@ -1878,11 +1875,11 @@ class FeaturesDatastore(val env: Env) extends Datastore { } .flatMap { case l @ Left(err) => Future.successful(Left(err)) - case Right((id, project)) => + case Right((id, project, name)) => env.eventService .emitEvent( channel = tenant, - event = SourceFeatureDeleted(id = id, project = project, tenant = tenant, user = user) + event = SourceFeatureDeleted(id = id, project = project, tenant = tenant, user = user.username, name=name, authentication = user.authentication, origin = NormalOrigin) )(conn) .map(_ => Right(id)) } diff --git a/app/fr/maif/izanami/datastores/ImportExportDatastore.scala b/app/fr/maif/izanami/datastores/ImportExportDatastore.scala index 5bc60ae33..0480f144f 100644 --- a/app/fr/maif/izanami/datastores/ImportExportDatastore.scala +++ b/app/fr/maif/izanami/datastores/ImportExportDatastore.scala @@ -1,16 +1,18 @@ package fr.maif.izanami.datastores -import fr.maif.izanami.datastores.ImportExportDatastore.TableMetadata +import fr.maif.izanami.datastores.ImportExportDatastore.{DBImportResult, TableMetadata} import fr.maif.izanami.env.Env import fr.maif.izanami.env.pgimplicits.EnhancedRow import fr.maif.izanami.errors.{InternalServerError, IzanamiError, PartialImportFailure} -import fr.maif.izanami.models.{ExportedType, KeyRightType, ProjectRightType, WebhookRightType} +import fr.maif.izanami.events.EventOrigin.ImportOrigin +import fr.maif.izanami.events.{SourceFeatureCreated, SourceFeatureUpdated} +import fr.maif.izanami.models.{ExportedType, FeatureType, FeatureWithOverloads, KeyRightType, ProjectRightType, WebhookRightType} import fr.maif.izanami.models.ExportedType.exportedTypeToString import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.BetterJsValue -import fr.maif.izanami.web.ExportController.TenantExportRequest -import fr.maif.izanami.web.ImportController.ImportConflictStrategy -import fr.maif.izanami.web.{ExportController, ImportController} +import fr.maif.izanami.web.ExportController.{ExportResult, TenantExportRequest} +import fr.maif.izanami.web.ImportController.{ImportConflictStrategy, MergeOverwrite} +import fr.maif.izanami.web.{ExportController, ImportController, ImportResult, UserInformation} import io.vertx.core.json.JsonArray import io.vertx.sqlclient.SqlConnection import play.api.libs.json.{JsObject, Json} @@ -40,17 +42,17 @@ class ImportExportDatastore(val env: Env) extends Datastore { ) { r => for ( constraint <- r.optString("pk_constraint"); - columns <- r.optStringArray("table_columns") + columns <- r.optStringArray("table_columns") ) yield TableMetadata(table = table, primaryKeyConstraint = constraint, tableColumns = columns.toSet) } } def importTenantData( - tenant: String, - entries: Map[ExportedType, Seq[JsObject]], - conflictStrategy: ImportConflictStrategy - ): Future[Either[IzanamiError, Unit]] = { - + tenant: String, + entries: Map[ExportedType, Seq[JsObject]], + conflictStrategy: ImportConflictStrategy, + user: UserInformation + ): Future[Either[IzanamiError, Unit]] = { Future .sequence( entries.toSeq @@ -65,100 +67,160 @@ class ImportExportDatastore(val env: Env) extends Datastore { Future.successful(Left(InternalServerError(s"Failed to fetch metadata for one table"))) } else { env.postgresql.executeInTransaction(conn => { + val previousFeatureStates: Future[Map[String, FeatureWithOverloads]] = if (conflictStrategy == MergeOverwrite && entries.get(FeatureType).exists(s => s.nonEmpty)) { + env.datastores.features.findActivationStrategiesForFeatures(tenant, entries(FeatureType).map(f => (f \ "id").as[String]).toSet) + .map(m => m.map { case (f, s) => (f, FeatureWithOverloads(s)) }) + } else { + Future.successful(Map()) + } s .collect { case (Some(metadata), jsons, exportedType) => (metadata, jsons, exportedType) } - .foldLeft(Future.successful(Right(())): Future[Either[Map[ExportedType, Seq[JsObject]], Unit]])( + .foldLeft(Future.successful(Right(Map())): Future[Either[IzanamiError, Map[ExportedType, DBImportResult]]])( (agg, t) => { - agg.flatMap(previousResult => { - val f = conflictStrategy match { - case ImportController.MergeOverwrite => importTenantDataWithMergeOnConflict(tenant, t._1, t._2) - case ImportController.Skip => importTenantDataWithSkipOnConflict(tenant, t._1, t._2) - case ImportController.Fail if previousResult.isRight => - importTenantDataWithFailOnConflict(tenant, t._1, t._2, conn) - case _ => Future.successful(Right(())) - } + val maybeId = if (t._3 == FeatureType) Some("id") else None + agg.flatMap { + case Right(previousResult) => { + val f: Future[Either[IzanamiError, DBImportResult]] = conflictStrategy match { + case ImportController.MergeOverwrite => importTenantDataWithMergeOnConflict(tenant, t._1, t._2, maybeId).map(r => Right(r)) + case ImportController.Skip => importTenantDataWithSkipOnConflict(tenant, t._1, t._2, maybeId).map(r => Right(r)) + case ImportController.Fail => importTenantDataWithFailOnConflict(tenant, t._1, t._2, conn).map { + case Right(_) => maybeId.map(idCol => t._2.map(json => (json \ idCol).asOpt[String]).collect { + case Some(id) => id + }).map(ids => Right(DBImportResult(createdElements = ids.toSet))).getOrElse(Right(DBImportResult())) + case Left(jsons) => Left(PartialImportFailure(Map(t._3 -> jsons))) + } + } - f - .flatMap(e => { - updateUserTenantRightIfNeeded( + f.flatMap { + case Left(err) => Future.successful(Left(err): Either[IzanamiError, DBImportResult]) + case Right(r) => updateUserTenantRightIfNeeded( tenant, entries, if (conflictStrategy == ImportController.Fail) Some(conn) else None - ) - .map(_ => e) - }) - .map(either => - either.left.map(jsons => - jsons.map(json => Json.obj("row" -> json, "_type" -> exportedTypeToString(t._3))) - ) - ) - .map(either => - (either, previousResult) match { - case (Left(err), Left(errs)) => { - Left(errs + (t._3 -> err)) - } - case (Left(err), Right(_)) => Left(Map(t._3 -> err)) - case (Right(_), Right(_)) => Right(()) - case (Right(_), Left(errs)) => Left(errs) - } - ) - }) + ).map(_ => Right(r): Either[IzanamiError, DBImportResult]) + }.map(e => e.map(v => previousResult + (t._3 -> v))) + } + case Left(err) => Future.successful(Left(err)) + } } ) - .map(either => either.left.map(failedElements => PartialImportFailure(failedElements))) + .flatMap { + case Left(err) => (Future.successful(Left(err)): Future[Either[IzanamiError, Unit]]) + case Right(vs) => { + vs.get(FeatureType).map(r => { + env.datastores.features.findActivationStrategiesForFeatures(tenant, r.createdElements ++ r.updatedElements, conn=Some(conn)) + .map(m => m.map{case (feature, strat) => (feature, FeatureWithOverloads(strat))}) + .flatMap(strategiesByFeature => { + Future.sequence(r.createdElements.map(created => { + strategiesByFeature.get(created).map(strategies => { + env.eventService.emitEvent(tenant, SourceFeatureCreated( + id = created, + project = strategies.baseFeature().project, + tenant = tenant, + user = user.username, + feature = strategies, + authentication = user.authentication, + origin = ImportOrigin + ))(conn = conn) + }).getOrElse(Future.successful()) + })) + .flatMap(_ => { + previousFeatureStates.flatMap(previousStates => { + Future.sequence(r.updatedElements.filter(updated => { + !strategiesByFeature.get(updated).exists(newStrat => previousStates.get(updated).contains(newStrat)) + }).map(updated => { + strategiesByFeature.get(updated).map(strategies => { + env.eventService.emitEvent(tenant, SourceFeatureUpdated( + id = updated, + project = strategies.baseFeature().project, + tenant = tenant, + user = user.username, + feature = strategies, + previous = previousStates.get(updated).orNull, + authentication = user.authentication, + origin = ImportOrigin + ))(conn = conn) + }).getOrElse(Future.successful()) + })) + }) + }) + }).map(_ => Right(vs)) + }).getOrElse(Future.successful(Right(vs))) + .map(e => e.map(_ => ())) + } + } }) } }) } def importTenantDataWithMergeOnConflict( - tenant: String, - metadata: TableMetadata, - rows: Seq[JsObject] - ): Future[Either[Seq[JsObject], Unit]] = { + tenant: String, + metadata: TableMetadata, + rows: Seq[JsObject], + maybeId: Option[String] + ): Future[DBImportResult] = { val vertxJsonArray = new JsonArray(Json.toJson(rows).toString()) - val cols = metadata.tableColumns.mkString(",") + val cols = metadata.tableColumns.mkString(",") env.postgresql - .queryOne( + .queryRaw( s""" - |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_recordset(null::${metadata.table}, $$1) - |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO UPDATE SET($cols)=(select $cols from json_populate_recordset(NULL::${metadata.table}, $$1)) - |""".stripMargin, + |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_recordset(null::${metadata.table}, $$1) + |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO UPDATE SET($cols)=(select $cols from json_populate_recordset(NULL::${metadata.table}, $$1)) + |RETURNING (xmax = 0) AS inserted ${maybeId.map(idCol => s", ${idCol}::TEXT as id").getOrElse("")} + |""".stripMargin, List(vertxJsonArray), schemas = Set(tenant) - ) { _ => Some(()) } - .map(_ => Right(())) + ) { rows => { + maybeId.map(_ => { + val insertedMap = rows.map(r => { + (for ( + id <- r.optString("id"); + inserted <- r.optBoolean("inserted") + ) yield (id, inserted)) + }) + .collect { + case Some(r) => r + } + .groupMap(_._2)(_._1) + + DBImportResult(createdElements = insertedMap.getOrElse(true, List[String]()).toSet, updatedElements = insertedMap.getOrElse(false, List[String]()).toSet) + }).getOrElse(DBImportResult()) + } + } .recoverWith { case _ => { logger.info(s"There has been import errors, switching to unit import mode for ${metadata.table}") - rows.foldLeft(Future.successful(Right(())): Future[Either[Seq[JsObject], Unit]])((facc, row) => { + rows.foldLeft(Future.successful(DBImportResult()))((facc, row) => { facc.flatMap(acc => env.postgresql .queryOne( s""" - |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_record(null::${metadata.table}, $$1) - |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO UPDATE SET($cols)=((select $cols from json_populate_record(NULL::${metadata.table}, $$1))) - |""".stripMargin, + |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_record(null::${metadata.table}, $$1) + |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO UPDATE SET($cols)=((select $cols from json_populate_record(NULL::${metadata.table}, $$1))) + |RETURNING (xmax = 0) AS inserted ${maybeId.map(idCol => s", ${idCol}::TEXT as id").getOrElse("")} + |""".stripMargin, List(row.vertxJsValue), schemas = Set(tenant) - ) { _ => Some(()) } - .map(_ => Right(())) + ) { r => { + (for ( + id <- r.optString("id"); + inserted <- r.optBoolean("inserted") + ) yield (id, inserted)) + .map { + case (id, false) => acc.addUpdatedElement(id) + case (id, true) => acc.addCreatedElement(id) + } + } + }.map(r => r.getOrElse(acc)) .recoverWith { case _ => { logger.info(s"Import of following row failed for table ${metadata.table} : ${row}") - Future.successful(Left(row)) + Future.successful(acc.addFailedElement(row)) } } - .map(either => { - (either, acc) match { - case (Left(failedRow), Left(failedRows)) => Left(failedRows.appended(failedRow)) - case (Right(_), Right(_)) => Right(()) - case (Left(failedRow), Right(_)) => Left(Seq(failedRow)) - case (Right(_), Left(failedRows)) => Left(failedRows) - } - }) ) }) } @@ -166,13 +228,13 @@ class ImportExportDatastore(val env: Env) extends Datastore { } def importTenantDataWithFailOnConflict( - tenant: String, - metadata: TableMetadata, - rows: Seq[JsObject], - conn: SqlConnection - ): Future[Either[Seq[JsObject], Unit]] = { + tenant: String, + metadata: TableMetadata, + rows: Seq[JsObject], + conn: SqlConnection + ): Future[Either[Seq[JsObject], Unit]] = { val vertxJsonArray = new JsonArray(Json.toJson(rows).toString()) - val cols = metadata.tableColumns.mkString(",") + val cols = metadata.tableColumns.mkString(",") env.postgresql .queryOne( s""" @@ -191,12 +253,12 @@ class ImportExportDatastore(val env: Env) extends Datastore { rows.foldLeft(Future.successful(Right(())): Future[Either[Seq[JsObject], Unit]])((facc, row) => { facc.flatMap { case Left(failedRow) => Future.successful((Left(failedRow))) - case Right(_) => { + case Right(_) => { env.postgresql .queryOne( s""" - |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_record(null::${metadata.table}, $$1) - |""".stripMargin, + |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_record(null::${metadata.table}, $$1) + |""".stripMargin, List(row.vertxJsValue), schemas = Set(tenant), conn = Some(conn) @@ -216,51 +278,64 @@ class ImportExportDatastore(val env: Env) extends Datastore { } def importTenantDataWithSkipOnConflict( - tenant: String, - metadata: TableMetadata, - rows: Seq[JsObject] - ): Future[Either[Seq[JsObject], Unit]] = { + tenant: String, + metadata: TableMetadata, + rows: Seq[JsObject], + maybeId: Option[String] + ): Future[DBImportResult] = { val vertxJsonArray = new JsonArray(Json.toJson(rows).toString()) - val cols = metadata.tableColumns.mkString(",") + val cols = metadata.tableColumns.mkString(",") env.postgresql - .queryOne( + .queryRaw( s""" - |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_recordset(null::${metadata.table}, $$1) - |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO NOTHING - |""".stripMargin, + |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_recordset(null::${metadata.table}, $$1) + |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO NOTHING + |RETURNING (xmax = 0) AS inserted ${maybeId.map(idCol => s", ${idCol}::TEXT as id").getOrElse("")} + |""".stripMargin, List(vertxJsonArray), schemas = Set(tenant) - ) { _ => Some(()) } - .map(_ => Right(())) + ) { rows => { + maybeId.map(_ => { + val insertedIds = rows.map(r => { + (for ( + id <- r.optString("id"); + inserted <- r.optBoolean("inserted") + ) yield (id, inserted)) + }) + .collect { + case Some(r@(featureId, true)) => featureId + }.toSet + + DBImportResult(createdElements = insertedIds) + }).getOrElse(DBImportResult()) + } + } .recoverWith { case _ => { logger.info(s"There has been import errors, switching to unit import mode for ${metadata.table}") - rows.foldLeft(Future.successful(Right(())): Future[Either[Seq[JsObject], Unit]])((facc, row) => { + rows.foldLeft(Future.successful(DBImportResult()): Future[DBImportResult])((facc, row) => { facc.flatMap(acc => env.postgresql .queryOne( s""" - |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_record(null::${metadata.table}, $$1) - |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO NOTHING - |""".stripMargin, + |INSERT INTO ${metadata.table} (${cols}) select ${cols} from json_populate_record(null::${metadata.table}, $$1) + |ON CONFLICT ON CONSTRAINT ${metadata.primaryKeyConstraint} DO NOTHING + |RETURNING (xmax = 0) AS inserted ${maybeId.map(idCol => s", ${idCol}::TEXT as id").getOrElse("")} + |""".stripMargin, List(row.vertxJsValue), schemas = Set(tenant) - ) { _ => Some(()) } - .map(_ => Right(())) + ) { r => { + maybeId.flatMap(_ => { + r.optBoolean("inserted").filter(r => r).flatMap(_ => r.optString("id")).map(id => acc.addCreatedElement(id)) + }) + } + }.map(r => r.getOrElse(acc)) .recoverWith { case _ => { logger.info(s"Import of following row failed for table ${metadata.table} : ${row}") - Future.successful(Left(row)) + Future.successful(acc.addFailedElement(row)) } } - .map(either => { - (either, acc) match { - case (Left(failedRow), Left(failedRows)) => Left(failedRows.appended(failedRow)) - case (Right(_), Right(_)) => Right(()) - case (Left(failedRow), Right(_)) => Left(Seq(failedRow)) - case (Right(_), Left(failedRows)) => Left(failedRows) - } - }) ) }) } @@ -268,23 +343,23 @@ class ImportExportDatastore(val env: Env) extends Datastore { } def exportTenantData( - tenant: String, - request: TenantExportRequest - ): Future[List[JsObject]] = { - val paramIndex = new AtomicInteger(1) + tenant: String, + request: TenantExportRequest + ): Future[List[JsObject]] = { + val paramIndex = new AtomicInteger(1) val projectFilter = request.projects match { case ExportController.ExportItemList(projects) => Some(projects) - case _ => None + case _ => None } val keyFilter = request.keys match { case ExportController.ExportItemList(keys) => Some(keys) - case _ => None + case _ => None } val webhookFilter = request.webhooks match { case ExportController.ExportItemList(webhooks) => Some(webhooks) - case _ => None + case _ => None } env.postgresql.queryAll( @@ -299,12 +374,15 @@ class ImportExportDatastore(val env: Env) extends Datastore { | WHERE f.project=project_results.pname |), tag_results AS ( | SELECT DISTINCT jsonb_build_object('_type', 'tag', 'row', row_to_json(t.*)::jsonb) as result - | FROM tags t ${projectFilter - .map(_ => """ - |, features_tags ft, feature_results - | WHERE ft.feature=feature_results.fid - | AND t.name=ft.tag""".stripMargin) - .getOrElse("")} + | FROM tags t ${ + projectFilter + .map(_ => + """ + |, features_tags ft, feature_results + | WHERE ft.feature=feature_results.fid + | AND t.name=ft.tag""".stripMargin) + .getOrElse("") + } |), features_tags_results AS ( | SELECT DISTINCT jsonb_build_object('_type', 'feature_tag', 'row', to_jsonb(ft.*)) as result | FROM features_tags ft, feature_results @@ -316,20 +394,26 @@ class ImportExportDatastore(val env: Env) extends Datastore { | AND fcs.project=p.pname |), local_context_results AS ( | SELECT DISTINCT jsonb_build_object('_type', 'local_context', 'row', to_jsonb(fc.*)) as result - | FROM feature_contexts fc${projectFilter - .map(_ => """ - | , overload_results ors - | WHERE fc.id = ors.local_context - | AND fc.project = ors.project""".stripMargin) - .getOrElse("")} + | FROM feature_contexts fc${ + projectFilter + .map(_ => + """ + | , overload_results ors + | WHERE fc.id = ors.local_context + | AND fc.project = ors.project""".stripMargin) + .getOrElse("") + } |), global_context_results AS ( | SELECT DISTINCT jsonb_build_object('_type', 'global_context', 'row', to_jsonb(fc.*)) as result - | FROM global_feature_contexts fc${projectFilter - .map(_ => """ - | , overload_results ors - | WHERE fc.id = ors.global_context - |""".stripMargin) - .getOrElse("")} + | FROM global_feature_contexts fc${ + projectFilter + .map(_ => + """ + | , overload_results ors + | WHERE fc.id = ors.global_context + |""".stripMargin) + .getOrElse("") + } |), key_results AS ( | SELECT DISTINCT k.name, jsonb_build_object('_type', 'key', 'row', to_jsonb(k.*)) as result | FROM apikeys k @@ -354,7 +438,9 @@ class ImportExportDatastore(val env: Env) extends Datastore { | SELECT DISTINCT jsonb_build_object('_type', 'script', 'row', to_jsonb(wsc.*)) as result | FROM wasm_script_configurations wsc, feature_results fr | WHERE wsc.id = fr.script_config - |)${if (request.userRights) """, users_webhooks_rights_result AS ( + |)${ + if (request.userRights) + """, users_webhooks_rights_result AS ( | SELECT DISTINCT jsonb_build_object('_type', 'user_webhook_right', 'row', to_jsonb(uwr.*)) as result | FROM users_webhooks_rights uwr, webhook_results | WHERE uwr.webhook=webhook_results.name @@ -366,7 +452,8 @@ class ImportExportDatastore(val env: Env) extends Datastore { | SELECT DISTINCT jsonb_build_object('_type', 'key_right', 'row', to_jsonb(ukr.*)) as result | FROM users_keys_rights ukr, key_results kr | WHERE ukr.apikey=kr.name - |)""" else ""} + |)""" else "" + } |SELECT result FROM tag_results |UNION ALL |SELECT result FROM project_results @@ -392,27 +479,29 @@ class ImportExportDatastore(val env: Env) extends Datastore { |SELECT result FROM webhooks_features_result |UNION ALL |SELECT result FROM script_results - |${if (request.userRights) s"""UNION ALL - |SELECT result FROM users_webhooks_rights_result - |UNION ALL - |SELECT result FROM project_rights - |UNION ALL - |SELECT result FROM key_rights""" else ""} + |${ + if (request.userRights) + s"""UNION ALL + |SELECT result FROM users_webhooks_rights_result + |UNION ALL + |SELECT result FROM project_rights + |UNION ALL + |SELECT result FROM key_rights""" else "" + } |""".stripMargin, List(projectFilter, keyFilter, webhookFilter).flatMap(_.toList).map(s => s.toArray), schemas = Set(tenant) - ) { r => - { - r.optJsObject("result") - } + ) { r => { + r.optJsObject("result") + } } } def updateUserTenantRightIfNeeded( - tenant: String, - importedData: Map[ExportedType, Seq[JsObject]], - maybeConn: Option[SqlConnection] - ): Future[Unit] = { + tenant: String, + importedData: Map[ExportedType, Seq[JsObject]], + maybeConn: Option[SqlConnection] + ): Future[Unit] = { val usernames = importedData .collect { case (WebhookRightType | ProjectRightType | KeyRightType, jsons) => { @@ -447,4 +536,26 @@ class ImportExportDatastore(val env: Env) extends Datastore { object ImportExportDatastore { case class TableMetadata(table: String, primaryKeyConstraint: String, tableColumns: Set[String]) + + case class DBImportResult( + failedElements: Set[JsObject] = Set(), + updatedElements: Set[String] = Set(), + createdElements: Set[String] = Set(), + previousStrategies: Map[String, FeatureWithOverloads] = Map() + ) { + def addFailedElement(json: JsObject) = copy(failedElements = failedElements + json) + + def addUpdatedElement(id: String) = copy(updatedElements = updatedElements + id) + + def addCreatedElement(id: String) = copy(createdElements = createdElements + id) + + def mergeWith(other: DBImportResult): DBImportResult = { + copy( + failedElements = failedElements ++ other.failedElements, + updatedElements = updatedElements ++ other.updatedElements, + createdElements = createdElements ++ other.createdElements + ) + } + } + } diff --git a/app/fr/maif/izanami/datastores/PersonnalAccessTokenDatastore.scala b/app/fr/maif/izanami/datastores/PersonnalAccessTokenDatastore.scala index 4e6d8f437..c75653388 100644 --- a/app/fr/maif/izanami/datastores/PersonnalAccessTokenDatastore.scala +++ b/app/fr/maif/izanami/datastores/PersonnalAccessTokenDatastore.scala @@ -1,31 +1,13 @@ package fr.maif.izanami.datastores import fr.maif.izanami.datastores.HashUtils.{bcryptCheck, bcryptHash} +import fr.maif.izanami.datastores.PersonnalAccessTokenDatastore.{TokenCheckFailure, TokenCheckResult, TokenCheckSuccess} import fr.maif.izanami.datastores.PersonnalAccessTokenDatastoreImplicits.PersonnalAccessTokenRow import fr.maif.izanami.env.Env import fr.maif.izanami.env.PostgresqlErrors.{FOREIGN_KEY_VIOLATION, UNIQUE_VIOLATION} import fr.maif.izanami.env.pgimplicits.EnhancedRow -import fr.maif.izanami.errors.{ - InternalServerError, - IzanamiError, - OneProjectDoesNotExists, - TenantDoesNotExists, - TokenDoesNotExist, - TokenWithThisNameAlreadyExists -} -import fr.maif.izanami.models.{ - AllRights, - CompletePersonnalAccessToken, - Expiration, - LimitedRights, - NoExpiration, - PersonnalAccessToken, - PersonnalAccessTokenCreationRequest, - PersonnalAccessTokenExpiration, - PersonnalAccessTokenRights, - ReadPersonnalAccessToken, - TenantTokenRights -} +import fr.maif.izanami.errors.{InternalServerError, IzanamiError, OneProjectDoesNotExists, TenantDoesNotExists, TokenDoesNotExist, TokenWithThisNameAlreadyExists} +import fr.maif.izanami.models.{AllRights, CompletePersonnalAccessToken, Expiration, LimitedRights, NoExpiration, PersonnalAccessToken, PersonnalAccessTokenCreationRequest, PersonnalAccessTokenExpiration, PersonnalAccessTokenRights, ReadPersonnalAccessToken, TenantTokenRights} import fr.maif.izanami.security.IdGenerator.token import fr.maif.izanami.utils.Datastore import io.vertx.pgclient.PgException @@ -37,17 +19,33 @@ import scala.concurrent.Future import scala.util.Try class PersonnalAccessTokenDatastore(val env: Env) extends Datastore { + def findAccessTokenByIds(ids: Set[UUID]): Future[Map[UUID, String]] = { + env.postgresql.queryAll( + s""" + |SELECT t.id, t.name + |FROM izanami.personnal_access_tokens t + |WHERE t.id=ANY($$1::UUID[]) + |""".stripMargin, + List(ids.toArray) + ){r => { + for( + id <- r.optUUID("id"); + name <- r.optString("name") + ) yield (id, name) + }}.map(l => l.toMap) + } + def checkAccessToken( username: String, token: String, tenant: String, operation: TenantTokenRights - ): Future[Boolean] = { + ): Future[TokenCheckResult] = { val parts = token.split("_") if (parts.length != 2) { - Future.successful(false) + Future.successful(TokenCheckFailure) } else { - val id = parts.head + val id = UUID.fromString(parts.head) val secret = parts.last env.postgresql .queryOne( @@ -89,10 +87,10 @@ class PersonnalAccessTokenDatastore(val env: Env) extends Datastore { } } .map { - case Some(t) => { - bcryptCheck(secret, t) + case Some(t) if bcryptCheck(secret, t) => { + TokenCheckSuccess(id) } - case None => false + case _ => TokenCheckFailure } } } @@ -289,6 +287,12 @@ class PersonnalAccessTokenDatastore(val env: Env) extends Datastore { } +object PersonnalAccessTokenDatastore { + sealed trait TokenCheckResult + case class TokenCheckSuccess(tokenId: UUID) extends TokenCheckResult + case object TokenCheckFailure extends TokenCheckResult +} + object PersonnalAccessTokenDatastoreImplicits { implicit class PersonnalAccessTokenRow(val row: Row) extends AnyVal { diff --git a/app/fr/maif/izanami/datastores/ProjectsDatastore.scala b/app/fr/maif/izanami/datastores/ProjectsDatastore.scala index 3211b3102..82e853932 100644 --- a/app/fr/maif/izanami/datastores/ProjectsDatastore.scala +++ b/app/fr/maif/izanami/datastores/ProjectsDatastore.scala @@ -5,14 +5,16 @@ import fr.maif.izanami.env.Env import fr.maif.izanami.env.PostgresqlErrors.{RELATION_DOES_NOT_EXISTS, UNIQUE_VIOLATION} import fr.maif.izanami.env.pgimplicits.EnhancedRow import fr.maif.izanami.errors._ +import fr.maif.izanami.events.EventOrigin.NormalOrigin import fr.maif.izanami.events.SourceFeatureDeleted import fr.maif.izanami.models.{Feature, Project, ProjectCreationRequest, RightLevels} import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.BetterSyntax -import fr.maif.izanami.web.ImportController +import fr.maif.izanami.web.{ImportController, UserInformation} import fr.maif.izanami.web.ImportController.{Fail, ImportConflictStrategy, MergeOverwrite, Skip} import io.vertx.pgclient.PgException import io.vertx.sqlclient.{Row, SqlConnection} +import play.api.libs.json.JsObject import java.util.UUID import java.util.regex.Pattern @@ -20,12 +22,22 @@ import scala.concurrent.Future class ProjectsDatastore(val env: Env) extends Datastore { + def findProjectId(tenant: String, projectName: String, conn:Option[SqlConnection] = None): Future[Option[UUID]] = { + env.postgresql + .queryOne( + s"""SELECT id FROM projects WHERE name=$$1""", + List(projectName), + schemas = Set(tenant), + conn=conn + ) { row => row.optUUID("id") } + } + def createProjects( - tenant: String, - names: Set[String], - conflictStrategy: ImportConflictStrategy, - user: String, - conn: Option[SqlConnection] = None + tenant: String, + names: Set[String], + conflictStrategy: ImportConflictStrategy, + user: UserInformation, + conn: Option[SqlConnection] = None ): Future[Either[IzanamiError, Unit]] = { env.postgresql .queryRaw( @@ -57,7 +69,7 @@ class ProjectsDatastore(val env: Env) extends Datastore { |ON CONFLICT(username, project) DO NOTHING |RETURNING 1 |""".stripMargin, - List(user, names.toArray, RightLevels.Admin.toString.toUpperCase), + List(user.username, names.toArray, RightLevels.Admin.toString.toUpperCase), conn = conn ) { _ => Some(value) } .map(o => { @@ -81,7 +93,7 @@ class ProjectsDatastore(val env: Env) extends Datastore { def createProject( tenant: String, projectCreationRequest: ProjectCreationRequest, - user: String + user: UserInformation ): Future[Either[IzanamiError, Project]] = { env.postgresql.executeInTransaction( conn => { @@ -108,7 +120,7 @@ class ProjectsDatastore(val env: Env) extends Datastore { env.postgresql .queryOne( s"""INSERT INTO users_projects_rights (username, project, level) VALUES ($$1, $$2, $$3) RETURNING project""", - List(user, projectCreationRequest.name, RightLevels.Admin.toString.toUpperCase), + List(user.username, projectCreationRequest.name, RightLevels.Admin.toString.toUpperCase), conn = Some(conn) ) { _ => Some(value) } .map(_.toRight(InternalServerError())) @@ -145,29 +157,49 @@ class ProjectsDatastore(val env: Env) extends Datastore { ) { row => row.optProject() } } - def deleteProject(tenant: String, project: String, user: String): Future[Either[IzanamiError, List[String]]] = { + def deleteProject(tenant: String, project: String, user: UserInformation): Future[Either[IzanamiError, List[String]]] = { env.postgresql.executeInTransaction(conn => { env.postgresql .queryOne( - s"""DELETE FROM projects p WHERE p.name=$$1 RETURNING (SELECT array_agg(f.id) AS ids FROM features f WHERE f.project=p.name);""", + s"""DELETE FROM projects p WHERE p.name=$$1 RETURNING (SELECT json_agg(json_build_object('id', f.id, 'name', f.name)) AS ids FROM features f WHERE f.project=p.name);""", List(project), schemas = Set(tenant), conn = Some(conn) - ) { row => row.optStringArray("ids").map(_.toList).orElse(Some(List())) } + ) { row => + row + .optJsArray("ids") + .flatMap(arr => + arr + .asOpt[Seq[JsObject]] + .map(jsons => { + jsons + .map(json => { + for ( + id <- (json \ "id").asOpt[String]; + name <- (json \ "name").asOpt[String] + ) yield (id, name) + }) + .collect { case Some(value) => + value + } + }) + ) + .orElse(Some(List())) + } .map(o => o.toRight(ProjectDoesNotExists(project))) .flatMap { - case Left(value) => Future.successful(Left(value)) - case Right(ids) => + case Left(value) => Future.successful(Left(value)) + case Right(featureInfos) => Future .sequence( - ids.map(id => + featureInfos.map { case (id, name) => env.eventService.emitEvent( channel = tenant, - event = SourceFeatureDeleted(id = id, project = project, tenant = tenant, user = user) + event = SourceFeatureDeleted(id = id, project = project, tenant = tenant, user = user.username, name=name, authentication = user.authentication, origin = NormalOrigin) )(conn) - ) + } ) - .map(_ => Right(ids)) + .map(_ => Right(featureInfos.map(_._1).toList)) } }) @@ -295,7 +327,9 @@ object projectImplicits { yield { val maybeFeatures = row .optJsArray("features") - .map(array => array.value.map(v => Feature.readLightWeightFeature(v, name).asOpt).flatMap(o => o.toList).toList) + .map(array => + array.value.map(v => Feature.readLightWeightFeature(v, name).asOpt).flatMap(o => o.toList).toList + ) Project(id = id, name = name, features = maybeFeatures.getOrElse(List()), description = description) } } diff --git a/app/fr/maif/izanami/datastores/TenantsDatastore.scala b/app/fr/maif/izanami/datastores/TenantsDatastore.scala index 7149e775a..afb1a3ca4 100644 --- a/app/fr/maif/izanami/datastores/TenantsDatastore.scala +++ b/app/fr/maif/izanami/datastores/TenantsDatastore.scala @@ -6,13 +6,14 @@ import fr.maif.izanami.env.Env import fr.maif.izanami.env.PostgresqlErrors.UNIQUE_VIOLATION import fr.maif.izanami.env.pgimplicits.EnhancedRow import fr.maif.izanami.errors.{InternalServerError, IzanamiError, TenantAlreadyExists, TenantDoesNotExists} +import fr.maif.izanami.events.EventOrigin.NormalOrigin import fr.maif.izanami.events.EventService.IZANAMI_CHANNEL import fr.maif.izanami.events.{SourceTenantCreated, SourceTenantDeleted} import fr.maif.izanami.models.{RightLevels, Tenant, TenantCreationRequest} import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.{BetterJsValue, BetterSyntax} import fr.maif.izanami.web.ImportState.{importFailureWrites, importResultReads, importSuccessWrites} -import fr.maif.izanami.web.{ImportFailure, ImportPending, ImportState, ImportSuccess} +import fr.maif.izanami.web.{ImportFailure, ImportPending, ImportState, ImportSuccess, UserInformation} import io.vertx.pgclient.PgException import io.vertx.sqlclient.Row import org.flywaydb.core.Flyway @@ -74,7 +75,7 @@ class TenantsDatastore(val env: Env) extends Datastore { .map(_.toRight(InternalServerError())) } - def createTenant(tenantCreationRequest: TenantCreationRequest, user: String): Future[Either[IzanamiError, Tenant]] = { + def createTenant(tenantCreationRequest: TenantCreationRequest, user: UserInformation): Future[Either[IzanamiError, Tenant]] = { val connectOptions = env.postgresql.connectOptions val config = new HikariConfig() config.setDriverClassName(classOf[org.postgresql.Driver].getName) @@ -113,14 +114,14 @@ class TenantsDatastore(val env: Env) extends Datastore { | INSERT INTO izanami.users_tenants_rights(username, tenant, level) VALUES ($$1, $$2, $$3) | RETURNING username |""".stripMargin, - List(user, value.name, RightLevels.Admin.toString.toUpperCase), + List(user.username, value.name, RightLevels.Admin.toString.toUpperCase), conn=Some(conn) ){_ => Some(value)} .map(maybeFeature => maybeFeature.toRight(InternalServerError())) }.flatMap { case Left(value) => Left(value).future case r@Right(tenant) => { - env.eventService.emitEvent(channel = IZANAMI_CHANNEL, event = SourceTenantCreated(tenant.name, user = user))(conn) + env.eventService.emitEvent(channel = IZANAMI_CHANNEL, event = SourceTenantCreated(tenant.name, user = user.username, authentication = user.authentication, origin = NormalOrigin))(conn) .map(_ => r) } } @@ -174,7 +175,7 @@ class TenantsDatastore(val env: Env) extends Datastore { .map { _.toRight(TenantDoesNotExists(name)) } } - def deleteTenant(name: String, user: String): Future[Either[IzanamiError, Unit]] = { + def deleteTenant(name: String, user: UserInformation): Future[Either[IzanamiError, Unit]] = { env.postgresql.executeInTransaction(conn => { env.postgresql @@ -193,7 +194,7 @@ class TenantsDatastore(val env: Env) extends Datastore { ){_ => Some(())} .map(_ => Right(())) }.flatMap(r => { - env.eventService.emitEvent(channel=IZANAMI_CHANNEL, event=SourceTenantDeleted(name, user = user))(conn) + env.eventService.emitEvent(channel=IZANAMI_CHANNEL, event=SourceTenantDeleted(name, user = user.username, authentication = user.authentication, origin = NormalOrigin))(conn) .map(_ => r) }) }) diff --git a/app/fr/maif/izanami/datastores/UsersDatastore.scala b/app/fr/maif/izanami/datastores/UsersDatastore.scala index 44a3d1a3f..46ba708b3 100644 --- a/app/fr/maif/izanami/datastores/UsersDatastore.scala +++ b/app/fr/maif/izanami/datastores/UsersDatastore.scala @@ -17,6 +17,7 @@ import io.vertx.pgclient.PgException import io.vertx.sqlclient.{Row, SqlConnection} import play.api.libs.json.{JsError, JsSuccess, Reads} +import java.util.UUID import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future import scala.concurrent.duration.DurationInt @@ -750,12 +751,12 @@ class UsersDatastore(val env: Env) extends Datastore { tenant: String, project: String, level: RightLevel - ): Future[Either[IzanamiError, Option[String]]] = { + ): Future[Either[IzanamiError, Option[(String, UUID)]]] = { env.postgresql .queryOne( s""" - |SELECT u.username - |FROM izanami.sessions s + |SELECT p.id, u.username + |FROM projects p, izanami.sessions s |LEFT JOIN izanami.users u ON u.username=s.username |LEFT JOIN izanami.users_tenants_rights utr ON u.username = utr.username AND utr.tenant=$$2 |LEFT JOIN users_projects_rights upr ON u.username = upr.username AND upr.project=$$3 @@ -765,10 +766,18 @@ class UsersDatastore(val env: Env) extends Datastore { | OR utr.level='ADMIN' | OR upr.level=ANY($$4) |) + |AND p.name=$$3 |""".stripMargin, List(session, tenant, project, superiorOrEqualLevels(level).map(l => l.toString.toUpperCase).toArray), schemas = Set(tenant) - ) { r => r.optString("username") } + ) { r => + { + for ( + username <- r.optString("username"); + projectId <- r.optUUID("id") + ) yield (username, projectId) + } + } .map(maybeUser => Right(maybeUser)) .recover { case f: PgException if f.getSqlState == RELATION_DOES_NOT_EXISTS => Left(TenantDoesNotExists(tenant)) @@ -1471,10 +1480,11 @@ class UsersDatastore(val env: Env) extends Datastore { ) { r => r.optString("username") } .map(userWithRights => { val userWithMissingRights = users.map { case (username, _) => username }.toSet.diff(userWithRights.toSet) - if(userWithMissingRights.isEmpty) { - Future.successful(()) - } else { - env.postgresql.queryAll( + if (userWithMissingRights.isEmpty) { + Future.successful(()) + } else { + env.postgresql + .queryAll( s""" |INSERT INTO izanami.users_tenants_rights(username, tenant, level) |VALUES (unnest($$1::text[]), $$2, 'READ') @@ -1482,19 +1492,21 @@ class UsersDatastore(val env: Env) extends Datastore { |""".stripMargin, List(userWithMissingRights.toArray, tenant) ) { r => { Some(()) } } - .map(_ => ()) - } - }).flatMap(_ => env.postgresql - .queryOne( - s""" + .map(_ => ()) + } + }) + .flatMap(_ => + env.postgresql + .queryOne( + s""" |INSERT INTO users_projects_rights (project, username, level) |VALUES($$1, unnest($$2::TEXT[]), unnest($$3::izanami.right_level[])) |ON CONFLICT (username, project) DO NOTHING |""".stripMargin, - List(project, users.map(_._1).toArray, users.map(_._2.toString.toUpperCase).toArray), - schemas = Set(tenant) - ) { r => Some(()) } - .map(_ => ()) + List(project, users.map(_._1).toArray, users.map(_._2.toString.toUpperCase).toArray), + schemas = Set(tenant) + ) { r => Some(()) } + .map(_ => ()) ) } diff --git a/app/fr/maif/izanami/datastores/WebhooksDatastore.scala b/app/fr/maif/izanami/datastores/WebhooksDatastore.scala index a52fa6d69..225ac8026 100644 --- a/app/fr/maif/izanami/datastores/WebhooksDatastore.scala +++ b/app/fr/maif/izanami/datastores/WebhooksDatastore.scala @@ -10,6 +10,7 @@ import fr.maif.izanami.models.RightLevels.RightLevel import fr.maif.izanami.models.{LightWebhook, Webhook, WebhookFeature, WebhookProject} import fr.maif.izanami.utils.Datastore import fr.maif.izanami.utils.syntax.implicits.BetterJsValue +import fr.maif.izanami.web.UserInformation import io.vertx.core.json.JsonObject import io.vertx.pgclient.PgException import io.vertx.sqlclient.Row @@ -315,7 +316,7 @@ class WebhooksDatastore(val env: Env) extends Datastore { } } } - def createWebhook(tenant: String, webhook: LightWebhook, username: String): Future[Either[IzanamiError, String]] = { + def createWebhook(tenant: String, webhook: LightWebhook, user: UserInformation): Future[Either[IzanamiError, String]] = { env.postgresql.executeInTransaction( conn => { env.datastores.featureContext.env.postgresql @@ -371,7 +372,7 @@ class WebhooksDatastore(val env: Env) extends Datastore { s""" |INSERT INTO users_webhooks_rights (webhook, username, level) VALUES ($$1, $$2, 'ADMIN') |""".stripMargin, - params = List(webhook.name, username), + params = List(webhook.name, user.username), conn = Some(conn) ) { _ => Some(id) } .map(_ => Right(id)) diff --git a/app/fr/maif/izanami/env/env.scala b/app/fr/maif/izanami/env/env.scala index dda63e135..3ea0e883c 100644 --- a/app/fr/maif/izanami/env/env.scala +++ b/app/fr/maif/izanami/env/env.scala @@ -36,16 +36,19 @@ class Datastores(env: Env) { val exportDatastore: ImportExportDatastore = new ImportExportDatastore(env) val search : SearchDatastore = new SearchDatastore(env) val personnalAccessToken : PersonnalAccessTokenDatastore = new PersonnalAccessTokenDatastore(env) + val events : EventDatastore = new EventDatastore(env) def onStart(): Future[Unit] = { for { _ <- users.onStart() + _ <- events.onStart() } yield () } def onStop(): Future[Unit] = { for { _ <- users.onStop() + _ <- events.onStop() } yield () } } diff --git a/app/fr/maif/izanami/events/EventService.scala b/app/fr/maif/izanami/events/EventService.scala index aa8daf7e5..2aaf21aaa 100644 --- a/app/fr/maif/izanami/events/EventService.scala +++ b/app/fr/maif/izanami/events/EventService.scala @@ -5,125 +5,240 @@ import akka.stream.scaladsl.{BroadcastHub, Keep, Source} import akka.stream.{KillSwitches, Materializer, SharedKillSwitch} import fr.maif.izanami.env.Env import fr.maif.izanami.env.pgimplicits.{EnhancedRow, VertxFutureEnhancer} -import fr.maif.izanami.errors.{EventNotFound, FailedToReadEvent, IzanamiError} -import fr.maif.izanami.events.EventService.{eventFormat, sourceEventWrites, IZANAMI_CHANNEL} -import fr.maif.izanami.models.{AbstractFeature, Feature, FeatureWithOverloads, LightWeightFeature, RequestContext} +import fr.maif.izanami.events.EventAuthentication.{eventAuthenticationReads} +import fr.maif.izanami.events.EventOrigin.{ORIGIN_NAME_MAP, eventOriginReads} +import fr.maif.izanami.events.EventService.{sourceEventWrites} +import fr.maif.izanami.models.{Feature, FeatureWithOverloads, LightWeightFeature, RequestContext} import io.vertx.pgclient.pubsub.PgSubscriber import io.vertx.sqlclient.SqlConnection -import play.api.libs.json.{Format, JsError, JsNumber, JsObject, JsResult, JsSuccess, JsValue, Json, Reads, Writes} -import fr.maif.izanami.models.Feature.{ - featureWrite, - lightweightFeatureRead, - lightweightFeatureWrite, - writeStrategiesForEvent -} +import play.api.libs.json.{Format, JsError, JsNumber, JsObject, JsResult, JsString, JsSuccess, JsValue, Json, Reads, Writes} +import fr.maif.izanami.models.Feature.{featureWrite, lightweightFeatureRead, lightweightFeatureWrite, writeStrategiesForEvent} import fr.maif.izanami.models.FeatureWithOverloads.featureWithOverloadWrite -import fr.maif.izanami.utils.syntax.implicits.BetterJsValue +import fr.maif.izanami.utils.syntax.implicits.{BetterJsValue, BetterSyntax} import fr.maif.izanami.v1.V2FeatureEvents.{createEventV2, deleteEventV2, updateEventV2} import play.api.Logger +import java.time.{Instant, OffsetDateTime, ZoneOffset} +import java.util.UUID import scala.concurrent.{ExecutionContext, Future} + +sealed trait EventOrigin + +object EventOrigin { + val ORIGIN_NAME_MAP: Map[EventOrigin, String] = Map((ImportOrigin, "IMPORT"), (NormalOrigin, "NORMAL")) + + case object ImportOrigin extends EventOrigin; + + case object NormalOrigin extends EventOrigin; + + def eventOriginReads: Reads[EventOrigin] = json => { + json.asOpt[String].map(_.toUpperCase) + .flatMap(upperName => { + ORIGIN_NAME_MAP.find { case (_, name) => name == upperName }.map(_._1) + }).map(origin => JsSuccess(origin)).getOrElse(JsError(s"Unknown origin $json")) + } + + def eventOriginWrites: Writes[EventOrigin] = o => JsString(ORIGIN_NAME_MAP(o)) +} + + +sealed trait EventAuthentication; + +object EventAuthentication { + def authenticationName(authentication: EventAuthentication): String = { + authentication match { + case TokenAuthentication(_) => "TOKEN" + case BackOfficeAuthentication => "BACKOFFICE" + case _ => throw new RuntimeException(s"Unknown authentication $authentication") + } + } + + case class TokenAuthentication(tokenId: UUID) extends EventAuthentication; + + case object BackOfficeAuthentication extends EventAuthentication; + + def eventAuthenticationReads: Reads[EventAuthentication] = json => { + (json \ "authentication").asOpt[String].map(_.toUpperCase) + .flatMap { + case "TOKEN" => (json \ "token").asOpt[UUID].map(token => TokenAuthentication(token)) + case "BACKOFFICE" => Some(BackOfficeAuthentication) + }.map(a => JsSuccess(a)).getOrElse(JsError(s"Unknown authentication $json")) + } + + def eventAuthenticationWrites: Writes[EventAuthentication] = { + case BackOfficeAuthentication => Json.obj("authentication" -> "BACKOFFICE") + case TokenAuthentication(token) => Json.obj("authentication" -> "TOKEN", "token" -> token) + } +} + + sealed trait SourceIzanamiEvent { val user: String val dbEventType: String val entityId: String + val origin: EventOrigin + val authentication: EventAuthentication } + sealed trait SourceFeatureEvent extends SourceIzanamiEvent { override val user: String val id: String val project: String val tenant: String + val projectId: Option[String] + val origin: EventOrigin + val authentication: EventAuthentication + + def withProjectId(projectId: String): SourceFeatureEvent } case class SourceFeatureCreated( - id: String, - project: String, - tenant: String, - override val user: String, - feature: FeatureWithOverloads -) extends SourceFeatureEvent { + id: String, + project: String, + tenant: String, + override val user: String, + feature: FeatureWithOverloads, + projectId: Option[String] = None, + origin: EventOrigin, + authentication: EventAuthentication + ) extends SourceFeatureEvent { override val dbEventType: String = "FEATURE_CREATED" - override val entityId: String = id + override val entityId: String = id + + override def withProjectId(projectId: String): SourceFeatureEvent = copy(projectId = Some(projectId)) } + case class SourceFeatureUpdated( - id: String, - project: String, - tenant: String, - override val user: String, - previous: FeatureWithOverloads, - feature: FeatureWithOverloads -) extends SourceFeatureEvent { + id: String, + project: String, + tenant: String, + override val user: String, + previous: FeatureWithOverloads, + feature: FeatureWithOverloads, + projectId: Option[String] = None, + origin: EventOrigin, + authentication: EventAuthentication + ) extends SourceFeatureEvent { override val dbEventType: String = "FEATURE_UPDATED" - override val entityId: String = id + override val entityId: String = id + + override def withProjectId(projectId: String): SourceFeatureEvent = copy(projectId = Some(projectId)) } -case class SourceFeatureDeleted(id: String, project: String, tenant: String, override val user: String) - extends SourceFeatureEvent { + +case class SourceFeatureDeleted( + id: String, + project: String, + tenant: String, + override val user: String, + projectId: Option[String] = None, + name: String, + origin: EventOrigin, + authentication: EventAuthentication + ) extends SourceFeatureEvent { override val dbEventType: String = "FEATURE_DELETED" - override val entityId: String = id + override val entityId: String = id + + override def withProjectId(projectId: String): SourceFeatureEvent = copy(projectId = Some(projectId)) } -case class SourceTenantDeleted(tenant: String, override val user: String) extends SourceIzanamiEvent { + +case class SourceTenantDeleted(tenant: String, override val user: String, origin: EventOrigin, + authentication: EventAuthentication) extends SourceIzanamiEvent { override val dbEventType: String = "TENANT_DELETED" - override val entityId: String = tenant + override val entityId: String = tenant } -case class SourceTenantCreated(tenant: String, override val user: String) extends SourceIzanamiEvent { +case class SourceTenantCreated(tenant: String, override val user: String, origin: EventOrigin, + authentication: EventAuthentication) extends SourceIzanamiEvent { override val dbEventType: String = "TENANT_CREATED" - override val entityId: String = tenant + override val entityId: String = tenant } sealed trait IzanamiEvent { val eventId: Long val user: String + val emittedAt: Option[Instant] + val origin: EventOrigin + val authentication: EventAuthentication } -sealed trait FeatureEvent extends IzanamiEvent { +sealed trait FeatureEvent extends IzanamiEvent { override val eventId: Long val id: String val project: String val tenant: String override val user: String + val origin: EventOrigin + val authentication: EventAuthentication } + // Condition by contetx is an option since feature may have been deleted between emission and reception of the event sealed trait ConditionFeatureEvent extends FeatureEvent { override val eventId: Long override val user: String val conditionByContext: Option[Map[String, LightWeightFeature]] } + case class FeatureCreated( - override val eventId: Long, - id: String, - project: String, - tenant: String, - override val user: String, - conditionByContext: Option[Map[String, LightWeightFeature]] = None -) extends ConditionFeatureEvent + override val eventId: Long, + id: String, + project: String, + tenant: String, + override val user: String, + conditionByContext: Option[Map[String, LightWeightFeature]] = None, + override val emittedAt: Option[Instant], + origin: EventOrigin, + authentication: EventAuthentication + ) extends ConditionFeatureEvent + case class FeatureUpdated( - override val eventId: Long, - id: String, - project: String, - tenant: String, - override val user: String, - conditionByContext: Option[Map[String, LightWeightFeature]] = None, - previous: Option[Map[String, LightWeightFeature]] = None -) extends ConditionFeatureEvent + override val eventId: Long, + id: String, + project: String, + tenant: String, + override val user: String, + conditionByContext: Option[Map[String, LightWeightFeature]] = None, + previous: Option[Map[String, LightWeightFeature]] = None, + override val emittedAt: Option[Instant], + origin: EventOrigin, + authentication: EventAuthentication + ) extends ConditionFeatureEvent case class FeatureDeleted( - override val eventId: Long, - id: String, - project: String, - tenant: String, - override val user: String -) extends FeatureEvent -case class TenantDeleted(override val eventId: Long, tenant: String, override val user: String) extends IzanamiEvent + override val eventId: Long, + id: String, + project: String, + tenant: String, + override val user: String, + override val emittedAt: Option[Instant], + name: String, + origin: EventOrigin, + authentication: EventAuthentication + ) extends FeatureEvent -case class TenantCreated(override val eventId: Long, tenant: String, override val user: String) extends IzanamiEvent +case class TenantDeleted( + override val eventId: Long, + tenant: String, + override val user: String, + override val emittedAt: Option[Instant], + origin: EventOrigin, + authentication: EventAuthentication + ) extends IzanamiEvent + +case class TenantCreated( + override val eventId: Long, + tenant: String, + override val user: String, + override val emittedAt: Option[Instant], + origin: EventOrigin, + authentication: EventAuthentication + ) extends IzanamiEvent case class SourceDescriptor( - source: Source[IzanamiEvent, NotUsed], - killswitch: SharedKillSwitch, - pgSubscriber: PgSubscriber -) { + source: Source[IzanamiEvent, NotUsed], + killswitch: SharedKillSwitch, + pgSubscriber: PgSubscriber + ) { def close(implicit executionContext: ExecutionContext): Future[Unit] = { killswitch.shutdown() pgSubscriber.close().scala.map(_ => ()) @@ -134,93 +249,147 @@ object EventService { val IZANAMI_CHANNEL = "izanami" //implicit val fWrite: Writes[FeatureWithOverloads] = featureWithOverloadWrite - implicit val sourceEventWrites: Writes[SourceIzanamiEvent] = { - case SourceFeatureCreated(id, project, tenant, user, feature) => - Json.obj( - "id" -> id, - "project" -> project, - "tenant" -> tenant, - "user" -> user, - "type" -> "FEATURE_CREATED", - "feature" -> Json.toJson(feature)(featureWithOverloadWrite) - ) - case SourceFeatureUpdated(id, project, tenant, user, previousFeature, feature) => - Json.obj( - "id" -> id, - "project" -> project, - "tenant" -> tenant, - "user" -> user, - "type" -> "FEATURE_UPDATED", - "feature" -> Json.toJson(feature)(featureWithOverloadWrite), - "previous" -> Json.toJson(previousFeature)(featureWithOverloadWrite) - ) - case SourceFeatureDeleted(id, project, user, tenant) => - Json.obj( - "id" -> id, - "project" -> project, - "tenant" -> tenant, - "user" -> user, - "type" -> "FEATURE_DELETED" - ) - case SourceTenantDeleted(tenant, user) => Json.obj("tenant" -> tenant, "type" -> "TENANT_DELETED", "user" -> user) - case SourceTenantCreated(tenant, user) => Json.obj("tenant" -> tenant, "type" -> "TENANT_CREATED", "user" -> user) + sealed trait FeatureEventType { + def name: String + } + + case object FeatureCreatedType extends FeatureEventType { + override def name: String = "FEATURE_CREATED" + } + + case object FeatureUpdatedType extends FeatureEventType { + override def name: String = "FEATURE_UPDATED" + } + + case object FeatureDeletedType extends FeatureEventType { + override def name: String = "FEATURE_DELETED" + } + + def parseFeatureEventType(typeStr: String): Option[FeatureEventType] = { + Option(typeStr).map(_.toUpperCase).collect { + case "FEATURE_UPDATED" => FeatureUpdatedType + case "FEATURE_CREATED" => FeatureCreatedType + case "FEATURE_DELETED" => FeatureDeletedType + } + } + + + + + implicit val sourceEventWrites: Writes[SourceIzanamiEvent] = { + case SourceFeatureCreated(id, project, tenant, user, feature, projectId, origin, authentication) => { + Json + .obj( + "id" -> id, + "project" -> project, + "tenant" -> tenant, + "user" -> user, + "type" -> "FEATURE_CREATED", + "feature" -> Json.toJson(feature)(featureWithOverloadWrite), + "origin" -> EventOrigin.eventOriginWrites.writes(origin), + ).applyOnWithOpt(projectId)((jsObj, id) => jsObj + ("projectId" -> JsString(id))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + } + case SourceFeatureUpdated(id, project, tenant, user, previousFeature, feature, projectId, origin, authentication) => + Json + .obj( + "id" -> id, + "project" -> project, + "tenant" -> tenant, + "user" -> user, + "type" -> "FEATURE_UPDATED", + "feature" -> Json.toJson(feature)(featureWithOverloadWrite), + "previous" -> Json.toJson(previousFeature)(featureWithOverloadWrite), + "origin" -> EventOrigin.eventOriginWrites.writes(origin) + ) + .applyOnWithOpt(projectId)((jsObj, id) => jsObj + ("projectId" -> JsString(id))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case SourceFeatureDeleted(id, project, tenant, user, projectId, name, origin, authentication) => + Json + .obj( + "id" -> id, + "project" -> project, + "tenant" -> tenant, + "user" -> user, + "type" -> "FEATURE_DELETED", + "name" -> name, + "origin" -> EventOrigin.eventOriginWrites.writes(origin) + ) + .applyOnWithOpt(projectId)((jsObj, id) => jsObj + ("projectId" -> JsString(id))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case SourceTenantDeleted(tenant, user, origin, authentication) => Json.obj("tenant" -> tenant, "type" -> "TENANT_DELETED", "user" -> user, "origin" -> EventOrigin.eventOriginWrites.writes(origin)) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case SourceTenantCreated(tenant, user, origin, authentication) => Json.obj("tenant" -> tenant, "type" -> "TENANT_CREATED", "user" -> user, "origin" -> EventOrigin.eventOriginWrites.writes(origin)) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] } implicit val lighweightFeatureWrites: Writes[LightWeightFeature] = lightweightFeatureWrite - implicit val eventFormat: Format[IzanamiEvent] = new Format[IzanamiEvent] { + implicit val eventFormat: Format[IzanamiEvent] = new Format[IzanamiEvent] { override def writes(o: IzanamiEvent): JsValue = { o match { - case FeatureCreated(eventId, id, project, tenant, user, conditions) => - Json.obj( - "eventId" -> eventId, - "id" -> id, - "project" -> project, - "tenant" -> tenant, - "user" -> user, - "conditions" -> conditions, - "type" -> "FEATURE_CREATED" - ) - case FeatureUpdated(eventId, id, project, tenant, user, conditions, previous) => - Json.obj( - "eventId" -> eventId, - "id" -> id, - "project" -> project, - "tenant" -> tenant, - "user" -> user, - "conditions" -> conditions, - "previousConditions" -> previous, - "type" -> "FEATURE_UPDATED" - ) - case FeatureDeleted(eventId, id, project, tenant, user) => - Json.obj( - "eventId" -> eventId, - "id" -> id, - "project" -> project, - "tenant" -> tenant, - "user" -> user, - "type" -> "FEATURE_DELETED" - ) - case TenantDeleted(eventId, tenant, user) => - Json.obj("eventId" -> eventId, "tenant" -> tenant, "type" -> "TENANT_DELETED", "user" -> user) - case TenantCreated(eventId, tenant, user) => - Json.obj("eventId" -> eventId, "tenant" -> tenant, "type" -> "TENANT_CREATED", "user" -> user) + case FeatureCreated(eventId, id, project, tenant, user, conditions, emittedAt, origin, authentication) => + Json + .obj( + "eventId" -> eventId, + "id" -> id, + "project" -> project, + "tenant" -> tenant, + "user" -> user, + "conditions" -> conditions, + "type" -> "FEATURE_CREATED", + "origin" -> EventOrigin.eventOriginWrites.writes(origin) + ) + .applyOnWithOpt(emittedAt)((obj, instant) => obj + ("emittedAt" -> JsString(instant.toString))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case FeatureUpdated(eventId, id, project, tenant, user, conditions, previous, emittedAt, origin, authentication) => + Json + .obj( + "eventId" -> eventId, + "id" -> id, + "project" -> project, + "tenant" -> tenant, + "user" -> user, + "conditions" -> conditions, + "previousConditions" -> previous, + "type" -> "FEATURE_UPDATED", + "origin" -> EventOrigin.eventOriginWrites.writes(origin) + ) + .applyOnWithOpt(emittedAt)((obj, instant) => obj + ("emittedAt" -> JsString(instant.toString))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case FeatureDeleted(eventId, id, project, tenant, user, emittedAt, name, origin, authentication) => + Json + .obj( + "eventId" -> eventId, + "id" -> id, + "project" -> project, + "tenant" -> tenant, + "user" -> user, + "type" -> "FEATURE_DELETED", + "name" -> name, + "origin" -> EventOrigin.eventOriginWrites.writes(origin) + ) + .applyOnWithOpt(emittedAt)((obj, instant) => obj + ("emittedAt" -> JsString(instant.toString))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case TenantDeleted(eventId, tenant, user, emittedAt, origin, authentication) => + Json + .obj("eventId" -> eventId, "tenant" -> tenant, "type" -> "TENANT_DELETED", "user" -> user, "origin" -> EventOrigin.eventOriginWrites.writes(origin)) + .applyOnWithOpt(emittedAt)((obj, instant) => obj + ("emittedAt" -> JsString(instant.toString))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] + case TenantCreated(eventId, tenant, user, emittedAt, origin, authentication) => + Json + .obj("eventId" -> eventId, "tenant" -> tenant, "type" -> "TENANT_CREATED", "user" -> user, "origin" -> EventOrigin.eventOriginWrites.writes(origin)) + .applyOnWithOpt(emittedAt)((obj, instant) => obj + ("emittedAt" -> JsString(instant.toString))) ++ EventAuthentication.eventAuthenticationWrites.writes(authentication).as[JsObject] } } override def reads(json: JsValue): JsResult[IzanamiEvent] = { + val emissionDate = (json \ "emittedAt").asOpt[Instant] (json \ "type").asOpt[String].map(_.toUpperCase).flatMap { - case eventType @ ("FEATURE_CREATED" | "FEATURE_UPDATED") => { + case eventType@("FEATURE_CREATED" | "FEATURE_UPDATED") => { (for ( - eventId <- (json \ "eventId").asOpt[Long]; - id <- (json \ "id").asOpt[String]; - tenant <- (json \ "tenant").asOpt[String]; - user <- (json \ "user").asOpt[String]; - project <- (json \ "project").asOpt[String]; + eventId <- (json \ "eventId").asOpt[Long]; + id <- (json \ "id").asOpt[String]; + tenant <- (json \ "tenant").asOpt[String]; + user <- (json \ "user").asOpt[String]; + project <- (json \ "project").asOpt[String]; conditions <- - (json \ "feature").asOpt[Map[String, LightWeightFeature]](Reads.map(lightweightFeatureRead)) - ) yield (eventId, id, tenant, project, user, conditions)).map { - case (eventId, id, tenant, project, user, conditions) => + (json \ "feature").asOpt[Map[String, LightWeightFeature]](Reads.map(lightweightFeatureRead)); + authentication <- json.asOpt[EventAuthentication](eventAuthenticationReads); + origin <- (json \ "origin").asOpt[EventOrigin](eventOriginReads) + ) yield (eventId, id, tenant, project, user, conditions, origin, authentication)).map { + case (eventId, id, tenant, project, user, conditions, origin, authentication) => if (eventType == "FEATURE_CREATED") { - FeatureCreated(eventId, id, project, tenant, user, Some(conditions)) + FeatureCreated(eventId, id, project, tenant, user, Some(conditions), emissionDate, origin = origin, authentication = authentication) } else { FeatureUpdated( eventId, @@ -230,50 +399,70 @@ object EventService { user, Some(conditions), previous = - (json \ "previous").asOpt[Map[String, LightWeightFeature]](Reads.map(lightweightFeatureRead)) + (json \ "previous").asOpt[Map[String, LightWeightFeature]](Reads.map(lightweightFeatureRead)), + emittedAt = emissionDate, + origin = origin, + authentication = authentication ) } } } - case "FEATURE_DELETED" => + case "FEATURE_DELETED" => for ( eventId <- (json \ "eventId").asOpt[Long]; - id <- (json \ "id").asOpt[String]; - tenant <- (json \ "tenant").asOpt[String]; - user <- (json \ "user").asOpt[String]; - project <- (json \ "project").asOpt[String] - ) yield FeatureDeleted(eventId, id, project, tenant, user) - case "TENANT_DELETED" => + id <- (json \ "id").asOpt[String]; + tenant <- (json \ "tenant").asOpt[String]; + user <- (json \ "user").asOpt[String]; + project <- (json \ "project").asOpt[String]; + authentication <- json.asOpt[EventAuthentication](eventAuthenticationReads); + origin <- (json \ "origin").asOpt[EventOrigin](eventOriginReads) + ) + yield FeatureDeleted( + eventId, + id, + project, + tenant, + user, + emittedAt = emissionDate, + name = (json \ "name").asOpt[String].getOrElse(""), + origin = origin, + authentication = authentication + ) + case "TENANT_DELETED" => for ( eventId <- (json \ "eventId").asOpt[Long]; - user <- (json \ "user").asOpt[String]; - tenant <- (json \ "tenant").asOpt[String] - ) yield TenantDeleted(eventId, tenant, user) - case "TENANT_CREATED" => + user <- (json \ "user").asOpt[String]; + tenant <- (json \ "tenant").asOpt[String]; + authentication <- json.asOpt[EventAuthentication](eventAuthenticationReads); + origin <- (json \ "origin").asOpt[EventOrigin](eventOriginReads) + ) yield TenantDeleted(eventId, tenant, user, emittedAt = emissionDate, origin = origin, authentication = authentication) + case "TENANT_CREATED" => for ( eventId <- (json \ "eventId").asOpt[Long]; - user <- (json \ "user").asOpt[String]; - tenant <- (json \ "tenant").asOpt[String] - ) yield TenantCreated(eventId, tenant, user) + user <- (json \ "user").asOpt[String]; + tenant <- (json \ "tenant").asOpt[String]; + authentication <- json.asOpt[EventAuthentication](eventAuthenticationReads); + origin <- (json \ "origin").asOpt[EventOrigin](eventOriginReads) + ) yield TenantCreated(eventId, tenant, user, emittedAt = emissionDate, origin = origin, authentication = authentication) } }.fold(JsError("Failed to read event"): JsResult[IzanamiEvent])(evt => JsSuccess(evt)) } def internalToExternalEvent( - event: IzanamiEvent, - context: RequestContext, - conditions: Boolean, - env: Env - ): Future[Option[JsObject]] = { - val logger = env.logger - val user = event.user + event: IzanamiEvent, + context: RequestContext, + conditions: Boolean, + env: Env + ): Future[Option[JsObject]] = { + val logger = env.logger + val user = event.user implicit val executionContext: ExecutionContext = env.executionContext event match { - case FeatureDeleted(_, id, _, _, _) => Future.successful(Some(deleteEventV2(id, user))) - case f: ConditionFeatureEvent => { + case FeatureDeleted(_, id, _, _, _, _, _, _, _) => Future.successful(Some(deleteEventV2(id, user))) + case f: ConditionFeatureEvent => { val maybeContextmap = f match { - case FeatureCreated(_, _, _, _, _, map) => map - case FeatureUpdated(_, _, _, _, _, map, _) => map + case FeatureCreated(_, _, _, _, _, map, _, _, _) => map + case FeatureUpdated(_, _, _, _, _, map, _, _, _, _) => map } Feature.processMultipleStrategyResult(maybeContextmap.get, context, conditions, env).map { case Left(error) => { @@ -282,58 +471,73 @@ object EventService { } case Right(json) => { f match { - case FeatureCreated(_, id, _, _, _, _) => Some(createEventV2(json, user) ++ Json.obj("id" -> id)) - case FeatureUpdated(_, id, _, _, _, _, previous) => { + case FeatureCreated(_, id, _, _, _, _, _, _, _) => Some(createEventV2(json, user) ++ Json.obj("id" -> id)) + case FeatureUpdated(_, id, _, _, _, _, previous, _, _, _) => { val maybePrevious = previous .filter(_ => conditions) .map(ctxs => writeStrategiesForEvent(ctxs)) - val finalJson = maybePrevious + val finalJson = maybePrevious .map(js => json ++ Json.obj("previousConditions" -> js)) .getOrElse(json) - val finalEvent = updateEventV2(finalJson, user) ++ Json.obj("id" -> id) + val finalEvent = updateEventV2(finalJson, user) ++ Json.obj("id" -> id) Some(finalEvent) } } } } } - case _ => Future.successful(None) + case _ => Future.successful(None) } } } class EventService(env: Env) { - implicit val executionContext: ExecutionContext = env.executionContext - implicit val materializer: Materializer = env.materializer - val logger: Logger = env.logger + implicit val executionContext: ExecutionContext = env.executionContext + implicit val materializer: Materializer = env.materializer + val logger: Logger = env.logger val sourceMap: scala.collection.mutable.Map[String, SourceDescriptor] = scala.collection.mutable.Map() def emitEvent(channel: String, event: SourceIzanamiEvent)(implicit conn: SqlConnection): Future[Unit] = { - val global = channel.equalsIgnoreCase("izanami") || event.isInstanceOf[SourceTenantDeleted] - val jsonEvent = Json.toJson(event)(sourceEventWrites).as[JsObject] - env.postgresql - .queryOne( - s""" - |WITH generated_id AS ( - | SELECT nextval('izanami.eventid') as next_id - |) - |INSERT INTO ${if (global) "izanami.global_events" else "events"} (id, event, event_type, entity_id) - |SELECT gid.next_id as id, (jsonb_build_object('eventId', gid.next_id) || $$1::jsonb) as event, $$2::${if ( - global - ) "izanami.GLOBAL_EVENT_TYPES" - else "izanami.LOCAL_EVENT_TYPES"}, $$3 - |FROM generated_id gid - |RETURNING id; - |""".stripMargin, - params = List(jsonEvent.vertxJsValue, event.dbEventType, event.entityId), - conn = Some(conn), - schemas = if (global) Set() else Set(channel) - ) { r => - { - r.optLong("id").map(id => (id, jsonEvent)) - } - } + val global = channel.equalsIgnoreCase("izanami") || event.isInstanceOf[SourceTenantDeleted] + val now = OffsetDateTime.now() + + val futureEvt: Future[SourceIzanamiEvent] = event match { + case event: SourceFeatureEvent => + env.datastores.projects + .findProjectId(event.tenant, event.project, conn = Some(conn)) + .map(maybeId => maybeId.map(_.toString).orNull) + .map(id => event.withProjectId(id)) + case e: SourceTenantDeleted => Future.successful(e) + case e: SourceTenantCreated => Future.successful(e) + } + futureEvt + .flatMap(evt => { + val jsonEvent = Json.toJson(evt)(sourceEventWrites).as[JsObject] + ("emittedAt" -> JsString(now.toString)) + env.postgresql + .queryOne( + s""" + |WITH generated_id AS ( + | SELECT nextval('izanami.eventid') as next_id + |) + |INSERT INTO ${if (global) "izanami.global_events" else "events"} (id, event, event_type, entity_id, emitted_at, origin, authentication, username) + |SELECT gid.next_id as id, (jsonb_build_object('eventId', gid.next_id) || $$1::jsonb) as event, $$2::${ + if ( + global + ) "izanami.GLOBAL_EVENT_TYPES" + else "izanami.LOCAL_EVENT_TYPES" + }, $$3, $$4, $$5, $$6, $$7 + |FROM generated_id gid + |RETURNING id; + |""".stripMargin, + params = List(jsonEvent.vertxJsValue, event.dbEventType, event.entityId, now, ORIGIN_NAME_MAP(event.origin), EventAuthentication.authenticationName(event.authentication), event.user), + conn = Some(conn), + schemas = if (global) Set() else Set(channel) + ) { r => { + r.optLong("id").map(id => (id, jsonEvent)) + } + } + }) .flatMap { case Some((id, jsonEvent)) => { val lightEvent = jsonEvent + ("eventId" -> JsNumber(id)) - "feature" @@ -345,31 +549,17 @@ class EventService(env: Env) { ) { _ => Some(()) } .map(_ => ()) } - case None => Future.successful(()) + case None => Future.successful(()) } } - private def readEventFromDb(tenant: String, id: Long): Future[Either[IzanamiError, IzanamiEvent]] = { - val global = tenant.equals(IZANAMI_CHANNEL) - env.postgresql - .queryOne( - s""" - |SELECT event FROM ${if (global) "izanami.global_events" else "events"} WHERE id=$$1 - |""".stripMargin, - List(java.lang.Long.valueOf(id)), - schemas = if (global) Set() else Set(tenant) - ) { r => r.optJsObject("event") } - .map(o => o.toRight(EventNotFound(tenant, id))) - .map(e => e.flatMap(js => eventFormat.reads(js).asOpt.toRight(FailedToReadEvent(js.toString())))) - - } def consume(channel: String): SourceDescriptor = { if (sourceMap.contains(channel)) { sourceMap(channel) } else { logger.info(s"Creating event source for $channel") - val sharedKillSwitch = KillSwitches.shared(s"$channel-killswitch") + val sharedKillSwitch = KillSwitches.shared(s"$channel-killswitch") lazy val (queue, source) = Source .queue[IzanamiEvent](bufferSize = 1024) .toMat(BroadcastHub.sink(bufferSize = 1024))(Keep.both) @@ -385,7 +575,7 @@ class EventService(env: Env) { .handler(payload => { val eventId = (Json.parse(payload) \ "eventId").asOpt[Long] eventId.fold(logger.error(s"Failed to read event id : $payload"))(id => { - readEventFromDb(channel, id) + env.datastores.events.readEventFromDb(channel, id) .map(e => e.fold( err => { @@ -400,7 +590,7 @@ class EventService(env: Env) { }) } }) - val descriptor = SourceDescriptor(source = source, killswitch = sharedKillSwitch, pgSubscriber = subscriber) + val descriptor = SourceDescriptor(source = source, killswitch = sharedKillSwitch, pgSubscriber = subscriber) sourceMap.put(channel, descriptor) descriptor } diff --git a/app/fr/maif/izanami/jobs/WebhookListener.scala b/app/fr/maif/izanami/jobs/WebhookListener.scala index 57335c8bc..e6a4c9e13 100644 --- a/app/fr/maif/izanami/jobs/WebhookListener.scala +++ b/app/fr/maif/izanami/jobs/WebhookListener.scala @@ -58,8 +58,8 @@ class WebhookListener(env: Env, eventService: EventService) { def handleGlobalEvent(event: IzanamiEvent): Unit = { event match { - case TenantCreated(eventId, tenant, _) => startListening(tenant) - case TenantDeleted(_, tenant, _) => cancelSwitch.get(tenant).map(c => c.cancel()) + case TenantCreated(eventId, tenant, _, _, _, _) => startListening(tenant) + case TenantDeleted(_, tenant, _, _, _, _) => cancelSwitch.get(tenant).map(c => c.cancel()) case _ => () } } diff --git a/app/fr/maif/izanami/models/Features.scala b/app/fr/maif/izanami/models/Features.scala index 6340cb4d9..6e83a14a1 100644 --- a/app/fr/maif/izanami/models/Features.scala +++ b/app/fr/maif/izanami/models/Features.scala @@ -466,8 +466,9 @@ case class FeatureRequest( noTagIn: Set[UUID] = Set(), context: Seq[String] = Seq() ) { - def isEmpty: Boolean = + def isEmpty: Boolean = { projects.isEmpty && oneTagIn.isEmpty && allTagsIn.isEmpty && noTagIn.isEmpty && features.isEmpty + } } object FeatureRequest { diff --git a/app/fr/maif/izanami/web/ApiKeyController.scala b/app/fr/maif/izanami/web/ApiKeyController.scala index e4572177c..4921d10ed 100644 --- a/app/fr/maif/izanami/web/ApiKeyController.scala +++ b/app/fr/maif/izanami/web/ApiKeyController.scala @@ -25,7 +25,7 @@ class ApiKeyController( env.datastores.users .hasRightFor( tenant, - username = request.user, + username = request.user.username, rights = key.projects.map(p => RightUnit(name = p, rightType = RightTypes.Project, rightLevel = RightLevels.Write)), tenantLevel = if(key.admin) Some(RightLevels.Admin) else None ) @@ -70,7 +70,7 @@ class ApiKeyController( case (newProjects, adminChanged) => {env.datastores.users .hasRightFor( tenant, - username = request.user, + username = request.user.username, rights = newProjects.map(p => RightUnit(name = p, rightType = RightTypes.Project, rightLevel = RightLevels.Write)), tenantLevel = if(adminChanged) Some(RightLevels.Admin) else None )} @@ -96,7 +96,7 @@ class ApiKeyController( def readApiKey(tenant: String): Action[AnyContent] = tenantAuthAction(tenant, RightLevels.Read).async { implicit request: UserNameRequest[AnyContent] => env.datastores.apiKeys - .readApiKeys(tenant, request.user) + .readApiKeys(tenant, request.user.username) .map(keys => Ok(Json.toJson(keys))) } diff --git a/app/fr/maif/izanami/web/AuthAction.scala b/app/fr/maif/izanami/web/AuthAction.scala index 326c32648..e87f2b604 100644 --- a/app/fr/maif/izanami/web/AuthAction.scala +++ b/app/fr/maif/izanami/web/AuthAction.scala @@ -1,17 +1,12 @@ package fr.maif.izanami.web +import fr.maif.izanami.datastores.PersonnalAccessTokenDatastore.{TokenCheckFailure, TokenCheckSuccess} import fr.maif.izanami.env.Env import fr.maif.izanami.errors.UserNotFound +import fr.maif.izanami.events.EventAuthentication +import fr.maif.izanami.events.EventAuthentication.{BackOfficeAuthentication, TokenAuthentication} import fr.maif.izanami.models.RightLevels.RightLevel -import fr.maif.izanami.models.{ - ApiKey, - ApiKeyWithCompleteRights, - RightLevels, - TenantTokenRights, - UserWithCompleteRightForOneTenant, - UserWithRights, - UserWithTenantRights -} +import fr.maif.izanami.models.{ApiKey, ApiKeyWithCompleteRights, RightLevels, TenantTokenRights, UserWithCompleteRightForOneTenant, UserWithRights, UserWithTenantRights} import fr.maif.izanami.security.JwtService.decodeJWT import fr.maif.izanami.utils.syntax.implicits.BetterSyntax import fr.maif.izanami.web.AuthAction.{extractAndCheckPersonnalAccessToken, extractClaims} @@ -20,19 +15,24 @@ import play.api.libs.json._ import play.api.mvc.Results.{BadRequest, Forbidden, Unauthorized} import play.api.mvc._ -import java.util.Base64 +import java.util.{Base64, UUID} import javax.crypto.spec.SecretKeySpec import scala.concurrent.{ExecutionContext, Future} +case class UserInformation(username: String, authentication: EventAuthentication) + + case class ClientKeyRequest[A](request: Request[A], key: ApiKeyWithCompleteRights) extends WrappedRequest[A](request) -case class UserRequestWithCompleteRights[A](request: Request[A], user: UserWithRights) +case class UserRequestWithCompleteRights[A](request: Request[A], user: UserWithRights, authentication: EventAuthentication) extends WrappedRequest[A](request) -case class UserRequestWithTenantRights[A](request: Request[A], user: UserWithTenantRights) +case class UserRequestWithTenantRights[A](request: Request[A], user: UserWithTenantRights, authentication: EventAuthentication) extends WrappedRequest[A](request) -case class UserRequestWithCompleteRightForOneTenant[A](request: Request[A], user: UserWithCompleteRightForOneTenant) +case class UserRequestWithCompleteRightForOneTenant[A](request: Request[A], user: UserWithCompleteRightForOneTenant, authentication: EventAuthentication) extends WrappedRequest[A](request) -case class UserNameRequest[A](request: Request[A], user: String) extends WrappedRequest[A](request) -case class HookAndUserNameRequest[A](request: Request[A], user: String, hookName: String) +case class UserNameRequest[A](request: Request[A], user: UserInformation) extends WrappedRequest[A](request) +case class ProjectIdUserNameRequest[A](request: Request[A], user: UserInformation, projectId: UUID) extends WrappedRequest[A](request) + +case class HookAndUserNameRequest[A](request: Request[A], user: UserInformation, hookName: String) extends WrappedRequest[A](request) case class SessionIdRequest[A](request: Request[A], sessionId: String) extends WrappedRequest[A](request) @@ -78,7 +78,7 @@ class TenantRightsAction(bodyParser: BodyParser[AnyContent], env: Env)(implicit .findSessionWithTenantRights(subject) .flatMap { case None => Unauthorized(Json.obj("message" -> "User is not connected")).future - case Some(user) => block(UserRequestWithTenantRights(request, user)) + case Some(user) => block(UserRequestWithTenantRights(request, user, BackOfficeAuthentication)) } }) } @@ -101,7 +101,7 @@ class DetailledAuthAction(bodyParser: BodyParser[AnyContent], env: Env)(implicit .findSessionWithCompleteRights(subject) .flatMap { case None => UserNotFound(subject).toHttpResponse.future - case Some(user) => block(UserRequestWithCompleteRights(request, user)) + case Some(user) => block(UserRequestWithCompleteRights(request, user, BackOfficeAuthentication)) } }) } @@ -120,7 +120,7 @@ class AdminAuthAction(bodyParser: BodyParser[AnyContent], env: Env)(implicit ec: env.datastores.users .findAdminSession(subject) .flatMap { - case Some(username) => block(UserNameRequest(request = request, user = username)) + case Some(username) => block(UserNameRequest(request = request, UserInformation(username = username, authentication = BackOfficeAuthentication))) case None => Future.successful(Forbidden(Json.obj("message" -> "User is not connected"))) } }) @@ -138,7 +138,7 @@ class AuthenticatedAction(bodyParser: BodyParser[AnyContent], env: Env)(implicit .flatMap(claims => claims.subject) .fold(Future.successful(Unauthorized(Json.obj("message" -> "Invalid token"))))(sessionId => env.datastores.users.findSession(sessionId).flatMap { - case Some(username) => block(UserNameRequest(request = request, user = username)) + case Some(username) => block(UserNameRequest(request = request, UserInformation(username = username, authentication = BackOfficeAuthentication))) case None => Future.successful(Unauthorized(Json.obj("message" -> "User is not connected"))) } ) @@ -178,7 +178,7 @@ class DetailledRightForTenantAction(bodyParser: BodyParser[AnyContent], env: Env .findSessionWithRightForTenant(subject, tenant) .flatMap { case Left(err) => err.toHttpResponse.toFuture - case Right(user) => block(UserRequestWithCompleteRightForOneTenant(request = request, user = user)) + case Right(user) => block(UserRequestWithCompleteRightForOneTenant(request = request, user = user, authentication = BackOfficeAuthentication)) } }) } @@ -201,10 +201,10 @@ class PersonnalAccessTokenTenantAuthAction( block: UserNameRequest[A] => Future[Result] ): Future[Result] = { - def maybeTokenAuth: Future[Either[Result, String]] = { + def maybeTokenAuth: Future[Either[Result, UserInformation]] = { extractAndCheckPersonnalAccessToken(request, env, tenant, operation) .flatMap { - case Some(username) => + case Some((username, tokenId)) => env.datastores.users .findUser(username) .map { @@ -212,7 +212,7 @@ class PersonnalAccessTokenTenantAuthAction( if user.admin || user.tenantRights .get(tenant) .exists(r => RightLevels.superiorOrEqualLevels(minimumLevel).contains(r)) => - Right(username) + Right(UserInformation(username = username, TokenAuthentication(tokenId = tokenId))) case Some(user) => Left(Forbidden(Json.obj("message" -> "User does not have enough rights for this operation"))) case None => Left(Unauthorized(Json.obj("message" -> "User not found"))) @@ -238,10 +238,10 @@ class PersonnalAccessTokenTenantAuthAction( } maybeCookieAuth.flatMap { - case Right(username) => block(UserNameRequest(request = request, user = username)) + case Right(username) => block(UserNameRequest(request = request, UserInformation(username = username, authentication = BackOfficeAuthentication))) case Left(result) => maybeTokenAuth.flatMap { - case Right(username) => block(UserNameRequest(request = request, user = username)) + case Right(userInformation) => block(UserNameRequest(request = request, userInformation)) case Left(result) => Future.successful(result) } } @@ -262,7 +262,7 @@ class TenantAuthAction(bodyParser: BodyParser[AnyContent], env: Env, tenant: Str env.datastores.users .hasRightForTenant(subject, tenant, minimumLevel) .flatMap { - case Some(username) => block(UserNameRequest(request = request, user = username)) + case Some(username) => block(UserNameRequest(request = request, UserInformation(username = username, authentication = BackOfficeAuthentication))) case None => Future.successful(Forbidden(Json.obj("message" -> "User does not have enough rights for this operation"))) } @@ -284,16 +284,17 @@ class ValidatePasswordAction(bodyParser: BodyParser[AnyContent], env: Env)(impli Future.successful(BadRequest(Json.obj("message" -> "Missing password"))) case Some(password) => val userRequest = request match { - case r: HookAndUserNameRequest[A] => Some(r.user) - case r: UserNameRequest[A] => Some(r.user) + case r: HookAndUserNameRequest[A] => Some(r.user.username) + case r: UserNameRequest[A] => Some(r.user.username) case r: UserRequestWithCompleteRights[A] => Some(r.user.username) case r: UserRequestWithTenantRights[A] => Some(r.user.username) + case r: ProjectIdUserNameRequest[A] => Some(r.user.username) case _ => None } userRequest match { case Some(user) => env.datastores.users.isUserValid(user, password).flatMap { - case Some(_) => block(UserNameRequest(request, user)) + case Some(_) => block(UserNameRequest(request, user=UserInformation(username = user, authentication = BackOfficeAuthentication))) case None => Future.successful(Unauthorized(Json.obj("message" -> "Your password is invalid."))) } case _ => Future.successful(BadRequest(Json.obj("message" -> "Invalid request"))) @@ -314,12 +315,12 @@ class ProjectAuthAction( project: String, minimumLevel: RightLevel )(implicit ec: ExecutionContext) - extends ActionBuilder[UserNameRequest, AnyContent] { + extends ActionBuilder[ProjectIdUserNameRequest, AnyContent] { override def parser: BodyParser[AnyContent] = bodyParser override protected def executionContext: ExecutionContext = ec - override def invokeBlock[A](request: Request[A], block: UserNameRequest[A] => Future[Result]): Future[Result] = { + override def invokeBlock[A](request: Request[A], block: ProjectIdUserNameRequest[A] => Future[Result]): Future[Result] = { extractClaims(request, env.configuration.get[String]("app.authentication.secret"), env.encryptionKey) .flatMap(claims => claims.subject) .fold(Future.successful(Unauthorized(Json.obj("message" -> "Invalid token"))))(subject => { @@ -328,13 +329,9 @@ class ProjectAuthAction( .flatMap(authorized => authorized.fold( err => Future.successful(Results.Status(err.status)(Json.toJson(err))), - maybeUser => { - if (maybeUser.isDefined) { - block(UserNameRequest(request = request, user = maybeUser.get)) - } else { - Future - .successful(Forbidden(Json.obj("message" -> "User does not have enough rights for this operation"))) - } + { + case Some((username, projectId)) => block(ProjectIdUserNameRequest(request = request, user = UserInformation(username=username, authentication = BackOfficeAuthentication), projectId = projectId)) + case None => Future.successful(Forbidden(Json.obj("message" -> "User does not have enough rights for this operation"))) } ) ) @@ -368,7 +365,7 @@ class WebhookAuthAction( err => Future.successful(Results.Status(err.status)(Json.toJson(err))), { case Some((username, hookName)) => - block(HookAndUserNameRequest(request = request, user = username, hookName = hookName)) + block(HookAndUserNameRequest(request = request, user = UserInformation(username=username, authentication = BackOfficeAuthentication), hookName = hookName)) case None => Future .successful(Forbidden(Json.obj("message" -> "User does not have enough rights for this operation"))) @@ -401,7 +398,7 @@ class KeyAuthAction( authorized.fold( err => Future.successful(Results.Status(err.status)(Json.toJson(err))), { - case Some(username) => block(UserNameRequest(request = request, user = username)) + case Some(username) => block(UserNameRequest(request = request, user = UserInformation(username=username, authentication = BackOfficeAuthentication))) case None => Future .successful(Forbidden(Json.obj("message" -> "User does not have enough rights for this operation"))) @@ -466,7 +463,7 @@ object AuthAction { env: Env, tenant: String, operation: TenantTokenRights - ): Future[Option[String]] = { + ): Future[Option[(String, UUID)]] = { request.headers .get("Authorization") .map(header => header.split("Basic ")) @@ -482,8 +479,8 @@ object AuthAction { env.datastores.personnalAccessToken .checkAccessToken(username, token, tenant, operation) .map { - case true => Some(username) - case false => None + case TokenCheckSuccess(tokenId) => Some(username, tokenId) + case TokenCheckFailure => None }(env.executionContext) } diff --git a/app/fr/maif/izanami/web/EventController.scala b/app/fr/maif/izanami/web/EventController.scala index c97f1b2f4..5d053729f 100644 --- a/app/fr/maif/izanami/web/EventController.scala +++ b/app/fr/maif/izanami/web/EventController.scala @@ -43,9 +43,9 @@ class EventController( def processForLegacyEndpoint(event: FeatureEvent): JsObject = { event match { - case FeatureCreated(_, _, _, _, _, Some(strategiesByContext)) => + case FeatureCreated(_, _, _, _, _, Some(strategiesByContext), _, _, _) => createEvent(event.id, Feature.writeFeatureInLegacyFormat(strategiesByContext.get("").get)) - case FeatureUpdated(_, _, _, _, _, Some(strategiesByContext), _) => + case FeatureUpdated(_, _, _, _, _, Some(strategiesByContext), _, _, _, _) => updateEvent(event.id, Feature.writeFeatureInLegacyFormat(strategiesByContext.get("").get)) case _ => deleteEvent(event.id) } diff --git a/app/fr/maif/izanami/web/FeatureContextController.scala b/app/fr/maif/izanami/web/FeatureContextController.scala index 0efbd80c6..387dc4654 100644 --- a/app/fr/maif/izanami/web/FeatureContextController.scala +++ b/app/fr/maif/izanami/web/FeatureContextController.scala @@ -34,7 +34,7 @@ class FeatureContextController( } def deleteFeatureStrategy(tenant: String, project: String, context: FeatureContextPath, name: String): Action[AnyContent] = authAction(tenant, project, RightLevels.Write).async { - implicit request: UserNameRequest[AnyContent] => + implicit request: ProjectIdUserNameRequest[AnyContent] => env.datastores.featureContext.deleteFeatureStrategy(tenant, project, context.elements, name, request.user) .map { case Left(err) => err.toHttpResponse diff --git a/app/fr/maif/izanami/web/FeatureController.scala b/app/fr/maif/izanami/web/FeatureController.scala index 2b6c25ecb..4597e8dc4 100644 --- a/app/fr/maif/izanami/web/FeatureController.scala +++ b/app/fr/maif/izanami/web/FeatureController.scala @@ -237,7 +237,7 @@ class FeatureController( featureRequest: FeatureRequest ): Action[AnyContent] = authenticatedAction.async { implicit request => val futureFeaturesByProject = - env.datastores.features.findByRequestV2(tenant, featureRequest, contexts = featureRequest.context, request.user) + env.datastores.features.findByRequestV2(tenant, featureRequest, contexts = featureRequest.context, request.user.username) futureFeaturesByProject.flatMap(featuresByProjects => { val resultingFeatures = featuresByProjects.values.flatMap(featSeq => featSeq.map(f => f.id)).toSet @@ -306,7 +306,7 @@ class FeatureController( ) ).toFuture } else { - env.datastores.features.applyPatch(tenant, fs, request.user.username).map(_ => NoContent) + env.datastores.features.applyPatch(tenant, fs, UserInformation(username=request.user.username, authentication = request.authentication)).map(_ => NoContent) } }) }) @@ -392,7 +392,7 @@ class FeatureController( tenant = tenant, id = id, feature = feature, - user = request.user.username, + user = UserInformation(username=request.user.username, authentication = request.authentication), conn = Some(conn) ) .flatMap { @@ -455,7 +455,7 @@ class FeatureController( if (canCreateOrModifyFeature(feature, request.user)) { env.datastores.features - .delete(tenant, id, request.user.username) + .delete(tenant, id, UserInformation(username=request.user.username, authentication = request.authentication)) .map(maybeFeature => maybeFeature .map(_ => NoContent) diff --git a/app/fr/maif/izanami/web/ImportController.scala b/app/fr/maif/izanami/web/ImportController.scala index c7e4510c4..c73c1d564 100644 --- a/app/fr/maif/izanami/web/ImportController.scala +++ b/app/fr/maif/izanami/web/ImportController.scala @@ -259,7 +259,7 @@ class ImportController( .map { case (messages, data) => { env.datastores.exportDatastore - .importTenantData(tenant, data, conflictStrategy) + .importTenantData(tenant, data, conflictStrategy, request.user) .map { case Left(PartialImportFailure(failedElements)) => Conflict( diff --git a/app/fr/maif/izanami/web/ProjectController.scala b/app/fr/maif/izanami/web/ProjectController.scala index 62b808546..a8bed7603 100644 --- a/app/fr/maif/izanami/web/ProjectController.scala +++ b/app/fr/maif/izanami/web/ProjectController.scala @@ -1,13 +1,18 @@ package fr.maif.izanami.web +import fr.maif.izanami.datastores.EventDatastore.{AscOrder, FeatureEventRequest, parseSortOrder} import fr.maif.izanami.env.Env +import fr.maif.izanami.events.{EventAuthentication, EventService, FeatureEvent, TenantCreated, TenantDeleted} import fr.maif.izanami.models.RightLevels.RightLevel import fr.maif.izanami.models.{Project, RightLevels} import fr.maif.izanami.utils.syntax.implicits.BetterSyntax -import play.api.libs.json.{JsError, JsSuccess, JsValue, Json} +import fr.maif.izanami.web.ProjectController.parseStringSet +import play.api.libs.json.{JsError, JsNull, JsNumber, JsObject, JsSuccess, JsValue, Json, Writes} import play.api.mvc._ +import java.time.Instant import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try class ProjectController( val env: Env, @@ -19,48 +24,102 @@ class ProjectController( ) extends BaseController { implicit val ec: ExecutionContext = env.executionContext; - def createProject(tenant: String): Action[JsValue] = tenantAuthAction(tenant, RightLevels.Write).async(parse.json) { implicit request => - Project.projectReads.reads(request.body) match { - case JsError(e) => BadRequest(Json.obj("message" -> "bad body format")).future - case JsSuccess(project, _) => { - env.datastores.projects - .createProject(tenant, project, request.user) - .map(maybeProject => - maybeProject.fold( - err => Results.Status(err.status)(Json.toJson(err)), - project => Created(Json.toJson(project)) + def readEventsForProject( + tenant: String, + project: String, + order: Option[String], + users: Option[String], + types: Option[String], + features: Option[String], + start: Option[String], + end: Option[String], + cursor: Option[Long], + count: Int, + total: Option[Boolean] + ): Action[AnyContent] = + projectAuthAction(tenant, project, RightLevels.Read).async { implicit request => + env.datastores.events + .listEventsForProject(tenant, project, FeatureEventRequest( + sortOrder = order.flatMap(o => parseSortOrder(o)).getOrElse(AscOrder), + cursor = cursor, + count = count, + users = parseStringSet(users), + begin = start.flatMap(s => Try{Instant.parse(s)}.toOption), + end = end.flatMap(e => Try{Instant.parse(e)}.toOption), + eventTypes = parseStringSet(types).map(t => EventService.parseFeatureEventType(t)).collect{case Some(t) => t}, + features = parseStringSet(features), + total = total.getOrElse(false) + )) + .flatMap{case (events, maybeCount) => { + val tokenIds = events.map(_.authentication).collect { + case EventAuthentication.TokenAuthentication(tokenId) => tokenId + }.toSet + + env.datastores.personnalAccessToken.findAccessTokenByIds(tokenIds).map(tokenNamesByIds => { + (events.map(e => { + val json = Json.toJson(e)(EventService.eventFormat.writes).as[JsObject] + e.authentication match { + case EventAuthentication.TokenAuthentication(tokenId) => { + val tokenName = tokenNamesByIds.getOrElse(tokenId, s" (token id was $tokenId)") + json ++ Json.obj("tokenName" -> tokenName) + } + case EventAuthentication.BackOfficeAuthentication => json + } + }), maybeCount) + }) + }} + .map{ case (events, maybeCount) => { + val jsonCount = maybeCount.map(JsNumber(_)).getOrElse(JsNull) + Ok(Json.obj("events" -> Json.toJson(events), "count" -> jsonCount)) + }} + } + + def createProject(tenant: String): Action[JsValue] = tenantAuthAction(tenant, RightLevels.Write).async(parse.json) { + implicit request => + Project.projectReads.reads(request.body) match { + case JsError(e) => BadRequest(Json.obj("message" -> "bad body format")).future + case JsSuccess(project, _) => { + env.datastores.projects + .createProject(tenant, project, request.user) + .map(maybeProject => + maybeProject.fold( + err => Results.Status(err.status)(Json.toJson(err)), + project => Created(Json.toJson(project)) + ) ) - ) + } } - } } - def updateProject(tenant: String, project: String): Action[JsValue] = projectAuthAction(tenant, project, RightLevels.Admin).async(parse.json) { implicit request => - Project.projectReads.reads(request.body) match { - case JsSuccess(updatedProject, _) => env.datastores.projects.updateProject(tenant, project, updatedProject).map(_ => NoContent) - case JsError(_) => BadRequest(Json.obj("message" -> "bad body format")).future + def updateProject(tenant: String, project: String): Action[JsValue] = + projectAuthAction(tenant, project, RightLevels.Admin).async(parse.json) { implicit request => + Project.projectReads.reads(request.body) match { + case JsSuccess(updatedProject, _) => + env.datastores.projects.updateProject(tenant, project, updatedProject).map(_ => NoContent) + case JsError(_) => BadRequest(Json.obj("message" -> "bad body format")).future + } } - } - def readProjects(tenant: String): Action[AnyContent] = detailledRightForTenanFactory(tenant).async { implicit request => - val isTenantAdmin = request.user.tenantRight.exists(right => right.level == RightLevels.Admin) - if(request.user.admin || isTenantAdmin) { - env.datastores.projects - .readProjects(tenant) - .map(projects => Ok(Json.toJson(projects))) - } else { - val filter = request.user.tenantRight - .map(tr => tr.projects.keys.toSet) - .getOrElse(Set()) - env.datastores.projects - .readProjectsFiltered(tenant, filter) - .map(projects => Ok(Json.toJson(projects))) - } + def readProjects(tenant: String): Action[AnyContent] = detailledRightForTenanFactory(tenant).async { + implicit request => + val isTenantAdmin = request.user.tenantRight.exists(right => right.level == RightLevels.Admin) + if (request.user.admin || isTenantAdmin) { + env.datastores.projects + .readProjects(tenant) + .map(projects => Ok(Json.toJson(projects))) + } else { + val filter = request.user.tenantRight + .map(tr => tr.projects.keys.toSet) + .getOrElse(Set()) + env.datastores.projects + .readProjectsFiltered(tenant, filter) + .map(projects => Ok(Json.toJson(projects))) + } } - def readProject(tenant: String, project: String): Action[AnyContent] = projectAuthAction(tenant, project, RightLevels.Read).async { - implicit request => + def readProject(tenant: String, project: String): Action[AnyContent] = + projectAuthAction(tenant, project, RightLevels.Read).async { implicit request => env.datastores.projects .readProject(tenant, project) .map(maybeProject => { @@ -69,15 +128,23 @@ class ProjectController( project => Ok(Json.toJson(project)) ) }) - } + } - def deleteProject(tenant: String, project: String): Action[JsValue] = (projectAuthAction(tenant, project, RightLevels.Admin) andThen validatePasswordAction()).async(parse.json) { - implicit request => - env.datastores.projects - .deleteProject(tenant, project, request.user).map { - case Left(err) => err.toHttpResponse - case Right(value) => NoContent - } + def deleteProject(tenant: String, project: String): Action[JsValue] = + (projectAuthAction(tenant, project, RightLevels.Admin) andThen validatePasswordAction()).async(parse.json) { + implicit request => + env.datastores.projects + .deleteProject(tenant, project, request.user) + .map { + case Left(err) => err.toHttpResponse + case Right(value) => NoContent + } + + } +} +object ProjectController { + def parseStringSet(str: Option[String]): Set[String] = { + str.map(s => s.split(",").toSet).getOrElse(Set()) } } diff --git a/app/fr/maif/izanami/web/SearchController.scala b/app/fr/maif/izanami/web/SearchController.scala index 46795b7ab..822f4a8b2 100644 --- a/app/fr/maif/izanami/web/SearchController.scala +++ b/app/fr/maif/izanami/web/SearchController.scala @@ -77,7 +77,7 @@ class SearchController( case Left(error) => error.toHttpResponse.future case Right(_) => env.datastores.search - .tenantSearch(tenant, request.user, query, filter.map(item => SearchEntityObject.parseSearchEntityType(item))) + .tenantSearch(tenant, request.user.username, query, filter.map(item => SearchEntityObject.parseSearchEntityType(item))) .flatMap(results => { Future.sequence(results.map { case (rowType, rowJson, _) => buildPath(rowType, rowJson, tenant) diff --git a/app/fr/maif/izanami/web/TenantController.scala b/app/fr/maif/izanami/web/TenantController.scala index 92de94817..c9301d574 100644 --- a/app/fr/maif/izanami/web/TenantController.scala +++ b/app/fr/maif/izanami/web/TenantController.scala @@ -1,7 +1,8 @@ package fr.maif.izanami.web import fr.maif.izanami.env.Env -import fr.maif.izanami.models.RightLevels.{RightLevel, superiorOrEqualLevels} +import fr.maif.izanami.events.EventService +import fr.maif.izanami.models.RightLevels.{superiorOrEqualLevels, RightLevel} import fr.maif.izanami.models._ import fr.maif.izanami.utils.syntax.implicits.BetterSyntax import fr.maif.izanami.v1.WasmManagerClient @@ -21,8 +22,7 @@ class TenantController( val adminAuthAction: AdminAuthAction, val tenantRightsAuthAction: TenantRightsAction, val validatePasswordAction: ValidatePasswordActionFactory, - val wasmManagerClient: WasmManagerClient, - val eventController: EventController + val wasmManagerClient: WasmManagerClient ) extends BaseController { implicit val ec: ExecutionContext = env.executionContext; @@ -76,12 +76,13 @@ class TenantController( } } - def deleteTenant(name: String): Action[JsValue] = (tenantAuthAction(name, RightLevels.Admin) andThen validatePasswordAction()).async(parse.json) { implicit request => - env.datastores.tenants.deleteTenant(name, request.user).map { - case Left(err) => err.toHttpResponse - case Right(_) => NoContent + def deleteTenant(name: String): Action[JsValue] = + (tenantAuthAction(name, RightLevels.Admin) andThen validatePasswordAction()).async(parse.json) { implicit request => + env.datastores.tenants.deleteTenant(name, request.user).map { + case Left(err) => err.toHttpResponse + case Right(_) => NoContent + } } - } def readTenant(name: String): Action[AnyContent] = tenantAuthAction(name, RightLevels.Read).async { implicit request => @@ -93,7 +94,7 @@ class TenantController( tenant => { for ( projects <- { - env.datastores.projects.readTenantProjectForUser(tenant.name, request.user) + env.datastores.projects.readTenantProjectForUser(tenant.name, request.user.username) }; tags <- env.datastores.tags.readTags(tenant.name) ) diff --git a/app/fr/maif/izanami/web/UserController.scala b/app/fr/maif/izanami/web/UserController.scala index ada6f53c0..2972634ff 100644 --- a/app/fr/maif/izanami/web/UserController.scala +++ b/app/fr/maif/izanami/web/UserController.scala @@ -101,7 +101,7 @@ class UserController( } def updateUser(user: String): Action[JsValue] = authAction.async(parse.json) { implicit request => - if (!request.user.equalsIgnoreCase(user)) { + if (!request.user.username.equalsIgnoreCase(user)) { Forbidden(Json.obj("message" -> "Modification of other users information is not allowed")).future } else { // TODO make special action that check password ? @@ -309,7 +309,7 @@ class UserController( } def updateUserPassword(user: String): Action[JsValue] = authAction.async(parse.json) { implicit request => - if (!request.user.equalsIgnoreCase(user)) { + if (!request.user.username.equalsIgnoreCase(user)) { Forbidden("Modification of other users information is not allowed").future } else { // TODO check password during update @@ -407,7 +407,7 @@ class UserController( def readUsers(): Action[AnyContent] = authAction.async { implicit request => env.datastores.users - .findUsers(request.user) + .findUsers(request.user.username) .map(users => { Ok(Json.toJson(users)) }) @@ -518,7 +518,7 @@ class UserController( def readRights(): Action[AnyContent] = authAction.async { implicit request => env.datastores.users - .findUserWithCompleteRights(request.user) + .findUserWithCompleteRights(request.user.username) .map { case Some(user) => Ok(Json.toJson(user)(User.userRightsWrites)) case None => NotFound(Json.obj("message" -> "User does not exist")) diff --git a/app/fr/maif/izanami/web/WebhookController.scala b/app/fr/maif/izanami/web/WebhookController.scala index 1f6ccd363..1aa70fe5c 100644 --- a/app/fr/maif/izanami/web/WebhookController.scala +++ b/app/fr/maif/izanami/web/WebhookController.scala @@ -58,7 +58,7 @@ class WebhookController( def listWebhooks(tenant: String): Action[AnyContent] = tenantAuthAction(tenant, RightLevels.Read).async { implicit request => - env.datastores.webhook.listWebhook(tenant, request.user).map(ws => Ok(Json.toJson(ws))) + env.datastores.webhook.listWebhook(tenant, request.user.username).map(ws => Ok(Json.toJson(ws))) } def deleteWebhook(tenant: String, id: String): Action[JsValue] = diff --git a/build.sbt b/build.sbt index 9720bc668..c9b2a7ef0 100644 --- a/build.sbt +++ b/build.sbt @@ -51,7 +51,7 @@ libraryDependencies += "commons-codec" % "commons-codec" % libraryDependencies += "io.dropwizard.metrics" % "metrics-json" % "4.2.23" excludeAll (excludesJackson: _*) libraryDependencies += "org.mozilla" % "rhino" % "1.7.14" libraryDependencies += "com.squareup.okhttp3" % "okhttp" % "4.12.0" excludeAll (excludesJackson: _*) -libraryDependencies += "fr.maif" %% "wasm4s" % "3.5.0" classifier "bundle" +libraryDependencies += "fr.maif" %% "wasm4s" % "3.7.0" classifier "bundle" libraryDependencies += "com.auth0" % "java-jwt" % "4.4.0" excludeAll (excludesJackson: _*) // needed by wasm4s libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.2.10" libraryDependencies += "com.github.jknack" % "handlebars" % "4.4.0" diff --git a/conf/application.conf b/conf/application.conf index 9a676c8bc..9e76dc30c 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -9,6 +9,10 @@ app { url = "https://reporting.otoroshi.io/izanami/ingest" url = ${?IZANAMI_REPORTING_URL} } + audit { + events-hours-ttl = 4344 + events-hours-ttl = ${?IZANAMI_AUDIT_EVENTS_HOURS_TTL} + } webhooks { retry { count = 5 diff --git a/conf/routes b/conf/routes index c0dacd34a..1ff9c1823 100644 --- a/conf/routes +++ b/conf/routes @@ -20,6 +20,7 @@ PUT /api/admin/tenants/:tenant/projects/:project GET /api/admin/tenants/:tenant/projects fr.maif.izanami.web.ProjectController.readProjects(tenant: String) GET /api/admin/tenants/:tenant/projects/:project fr.maif.izanami.web.ProjectController.readProject(tenant: String, project: String) DELETE /api/admin/tenants/:tenant/projects/:project fr.maif.izanami.web.ProjectController.deleteProject(tenant: String, project: String) +GET /api/admin/tenants/:tenant/projects/:project/logs fr.maif.izanami.web.ProjectController.readEventsForProject(tenant: String, project: String, order: Option[String], users: Option[String], types: Option[String], features: Option[String], start: Option[String], end: Option[String], cursor:Option[Long], count: Int ?= 50, total: Option[Boolean]) POST /api/admin/tenants/:tenant/projects/:project/features fr.maif.izanami.web.FeatureController.createFeature(tenant: String, project: String) PUT /api/admin/tenants/:tenant/features/:id fr.maif.izanami.web.FeatureController.updateFeature(tenant: String, id: String) diff --git a/conf/sql/globals/V14__event_timestamps.sql b/conf/sql/globals/V14__event_timestamps.sql new file mode 100644 index 000000000..c3775ec7d --- /dev/null +++ b/conf/sql/globals/V14__event_timestamps.sql @@ -0,0 +1,20 @@ +ALTER TABLE izanami.global_events ADD COLUMN emitted_at TIMESTAMP WITH TIME ZONE; +ALTER TABLE izanami.global_events ALTER COLUMN emitted_at SET DEFAULT now(); + +CREATE TYPE EVENT_ORIGIN AS ENUM ('IMPORT', 'NORMAL'); +CREATE TYPE AUTHENTICATION AS ENUM('TOKEN', 'BACKOFFICE'); + +ALTER TABLE izanami.global_events ADD COLUMN origin EVENT_ORIGIN DEFAULT 'NORMAL'; +ALTER table izanami.global_events + ALTER COLUMN origin DROP DEFAULT, + ALTER COLUMN origin SET NOT NULL; + +ALTER TABLE izanami.global_events ADD COLUMN authentication AUTHENTICATION DEFAULT 'BACKOFFICE'; +ALTER table izanami.global_events + ALTER COLUMN authentication DROP DEFAULT, + ALTER COLUMN authentication SET NOT NULL; + +ALTER TABLE izanami.global_events ADD COLUMN username TEXT; +UPDATE izanami.global_events SET username=coalesce(event->>user,''); +ALTER TABLE izanami.global_events + ALTER COLUMN username SET NOT NULL; diff --git a/conf/sql/tenants/V4__event_timestamp.sql b/conf/sql/tenants/V4__event_timestamp.sql new file mode 100644 index 000000000..1ce43c6ad --- /dev/null +++ b/conf/sql/tenants/V4__event_timestamp.sql @@ -0,0 +1,17 @@ +ALTER TABLE events ADD COLUMN emitted_at TIMESTAMP WITH TIME ZONE; +ALTER TABLE events ALTER COLUMN emitted_at SET DEFAULT now(); + +ALTER TABLE events ADD COLUMN origin izanami.EVENT_ORIGIN DEFAULT 'NORMAL'; +ALTER table events + ALTER COLUMN origin DROP DEFAULT, +ALTER COLUMN origin SET NOT NULL; + +ALTER TABLE events ADD COLUMN authentication izanami.AUTHENTICATION DEFAULT 'BACKOFFICE'; +ALTER table events + ALTER COLUMN authentication DROP DEFAULT, +ALTER COLUMN authentication SET NOT NULL; + +ALTER TABLE events ADD COLUMN username TEXT; +UPDATE events SET username=coalesce(event->>user,''); +ALTER TABLE events + ALTER COLUMN username SET NOT NULL; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a212e3921..1481ffb85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ version: '3.1' services: oidc-server-mock: container_name: oidc-server-mock - image: ghcr.io/soluto/oidc-server-mock:latest + image: ghcr.io/xdev-software/oidc-server-mock:1.0.4 ports: - '9001:8080' environment: diff --git a/izanami-frontend/package-lock.json b/izanami-frontend/package-lock.json index c88e77db8..73f6d2b46 100644 --- a/izanami-frontend/package-lock.json +++ b/izanami-frontend/package-lock.json @@ -13,6 +13,7 @@ "@hookform/error-message": "^2.0.1", "@maif/react-forms": "1.6.3", "@mui/material": "^5.13.6", + "@tanstack/react-query": "^5.62.2", "@tanstack/react-table": "^8.1.3", "@textea/json-viewer": "^3.1.1", "@uiw/react-codemirror": "^4.22.1", @@ -24,10 +25,10 @@ "handlebars": "^4.7.8", "lodash": "^4.17.21", "react": "18.1.0", + "react-codemirror-merge": "^4.23.6", "react-dom": "18.1.0", "react-hook-form": "^7.48.2", "react-hot-toast": "^2.4.1", - "react-query": "^3.39.1", "react-router-dom": "6.4.0", "react-select": "^5.7.7", "react-tooltip": "^5.14.0", @@ -1588,6 +1589,18 @@ "@lezer/common": "^0.15.0" } }, + "node_modules/@codemirror/merge": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.7.4.tgz", + "integrity": "sha512-9FpIFTgzkaxkZE93XKoFR6caAB6sCAfYCW2NT+atGEmdv/1Mt1ouxA+hKxGRYdMvdH9Ph0KMJtYnzEi+QCGAiQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/panel": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@codemirror/panel/-/panel-0.19.1.tgz", @@ -1730,25 +1743,14 @@ "deprecated": "As of 0.20.0, this package has been merged into @codemirror/state" }, "node_modules/@codemirror/theme-one-dark": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-0.19.1.tgz", - "integrity": "sha512-8gc4c2k2o/EhyHoWkghCxp5vyDT96JaFGtRy35PHwIom0LZdx7aU4AbDUnITvwiFB+0+i54VO+WQjBqgTyJvqg==", - "dependencies": { - "@codemirror/highlight": "^0.19.0", - "@codemirror/state": "^0.19.0", - "@codemirror/view": "^0.19.0" - } - }, - "node_modules/@codemirror/theme-one-dark/node_modules/@codemirror/view": { - "version": "0.19.48", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.19.48.tgz", - "integrity": "sha512-0eg7D2Nz4S8/caetCTz61rK0tkHI17V/d15Jy0kLOT8dTLGGNJUponDnW28h2B6bERmPlVHKh8MJIr5OCp1nGw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", "dependencies": { - "@codemirror/rangeset": "^0.19.5", - "@codemirror/state": "^0.19.3", - "@codemirror/text": "^0.19.0", - "style-mod": "^4.0.0", - "w3c-keyname": "^2.2.4" + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" } }, "node_modules/@codemirror/tooltip": { @@ -1774,9 +1776,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.1.tgz", - "integrity": "sha512-BUWr+zCJpMkA/u69HlJmR+YkV4yPpM81HeMkOMZuwFa8iM5uJdEPKAs1icIRZKkKmy0Ub1x9/G3PQLTXdpBxrQ==", + "version": "6.35.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.35.0.tgz", + "integrity": "sha512-I0tYy63q5XkaWsJ8QRv5h6ves7kvtrBWjBcnf/bzohFJQc5c14a1AQRdE8QpPF9eMp5Mq2FMm59TCj1gDfE7kw==", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -2893,6 +2895,16 @@ "@lezer/lr": "^0.15.0" } }, + "node_modules/@maif/react-forms/node_modules/@codemirror/theme-one-dark": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-0.19.1.tgz", + "integrity": "sha512-8gc4c2k2o/EhyHoWkghCxp5vyDT96JaFGtRy35PHwIom0LZdx7aU4AbDUnITvwiFB+0+i54VO+WQjBqgTyJvqg==", + "dependencies": { + "@codemirror/highlight": "^0.19.0", + "@codemirror/state": "^0.19.0", + "@codemirror/view": "^0.19.0" + } + }, "node_modules/@maif/react-forms/node_modules/@codemirror/view": { "version": "0.19.48", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.19.48.tgz", @@ -4056,6 +4068,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tanstack/query-core": { + "version": "5.62.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.2.tgz", + "integrity": "sha512-LcwVcC5qpsDpHcqlXUUL5o9SaOBwhNkGeV+B06s0GBoyBr8FqXPuXT29XzYXR36lchhnerp6XO+CWc84/vh7Zg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.62.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.2.tgz", + "integrity": "sha512-fkTpKKfwTJtVPKVR+ag7YqFgG/7TRVVPzduPAUF9zRCiiA8Wu305u+KJl8rCrh98Qce77vzIakvtUyzWLtaPGA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.62.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.17.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.17.3.tgz", @@ -4419,9 +4457,9 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.22.2.tgz", - "integrity": "sha512-zcHGkldLFN3cGoI5XdOGAkeW24yaAgrDEYoyPyWHODmPiNwybQQoZGnH3qUdzZwUaXtAcLWoAeOPzfNRW2yGww==", + "version": "4.23.6", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.6.tgz", + "integrity": "sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", @@ -4445,9 +4483,9 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup/node_modules/@codemirror/autocomplete": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz", - "integrity": "sha512-MjfDrHy0gHKlPWsvSsikhO1+BOh+eBHNgfH1OXs1+DAf30IonQldgMM3kxLDTG9ktE7kDLaA1j/l7KMPA4KNfw==", + "version": "6.18.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.3.tgz", + "integrity": "sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -4462,9 +4500,9 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup/node_modules/@codemirror/commands": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", - "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -4473,19 +4511,19 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup/node_modules/@codemirror/lint": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.0.tgz", - "integrity": "sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.3.tgz", + "integrity": "sha512-GSGfKxCo867P7EX1k2LoCrjuQFeqVgPGRRsSl4J4c0KMkD+k1y6WYvTQkzv0iZ8JhLJDujEvlnMchv4CZQLh3Q==", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "node_modules/@uiw/codemirror-extensions-basic-setup/node_modules/@codemirror/search": { - "version": "6.5.6", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", - "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "version": "6.5.8", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.8.tgz", + "integrity": "sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -4493,15 +4531,15 @@ } }, "node_modules/@uiw/react-codemirror": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.22.2.tgz", - "integrity": "sha512-okCSl+WJG63gRx8Fdz7v0C6RakBQnbb3pHhuzIgDB+fwhipgFodSnu2n9oOsQesJ5YQ7mSOcKMgX0JEsu4nnfQ==", + "version": "4.23.6", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.23.6.tgz", + "integrity": "sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==", "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.22.2", + "@uiw/codemirror-extensions-basic-setup": "4.23.6", "codemirror": "^6.0.0" }, "funding": { @@ -4528,17 +4566,6 @@ "@lezer/common": "^1.1.0" } }, - "node_modules/@uiw/react-codemirror/node_modules/@codemirror/theme-one-dark": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", - "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -4983,14 +5010,6 @@ } ] }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "engines": { - "node": ">=0.6" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5092,21 +5111,6 @@ "node": ">=8" } }, - "node_modules/broadcast-channel": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", - "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", - "dependencies": { - "@babel/runtime": "^7.7.2", - "detect-node": "^2.1.0", - "js-sha3": "0.8.0", - "microseconds": "0.2.0", - "nano-time": "1.0.0", - "oblivious-set": "1.0.0", - "rimraf": "3.0.2", - "unload": "2.2.0" - } - }, "node_modules/browserslist": { "version": "4.23.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", @@ -5444,7 +5448,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/convert-source-map": { "version": "1.9.0", @@ -5707,11 +5712,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6545,7 +6545,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.2", @@ -6659,6 +6660,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6690,6 +6692,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6699,6 +6702,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6987,6 +6991,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -7493,11 +7498,6 @@ "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", "integrity": "sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==" }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7679,15 +7679,6 @@ "yallist": "^3.0.2" } }, - "node_modules/match-sorter": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz", - "integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==", - "dependencies": { - "@babel/runtime": "^7.23.8", - "remove-accents": "0.5.0" - } - }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -7715,11 +7706,6 @@ "node": ">=8.6" } }, - "node_modules/microseconds": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", - "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==" - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -7810,14 +7796,6 @@ "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "optional": true }, - "node_modules/nano-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", - "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==", - "dependencies": { - "big-integer": "^1.6.16" - } - }, "node_modules/nanoclone": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", @@ -8041,15 +8019,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/oblivious-set": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", - "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==" - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, "dependencies": { "wrappy": "1" } @@ -8206,6 +8180,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -8684,6 +8659,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-codemirror-merge": { + "version": "4.23.6", + "resolved": "https://registry.npmjs.org/react-codemirror-merge/-/react-codemirror-merge-4.23.6.tgz", + "integrity": "sha512-tnbv/g8q37T3TP9AjPeKBUI1O67VQ2V2aaRU65alddFyPsrfT5f0fqDeoLRAeqUByhZy+PY4tUoCxauByHDQrw==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/merge": "^6.1.2", + "@uiw/react-codemirror": "4.23.6" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-copy-to-clipboard": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", @@ -8784,31 +8781,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, - "node_modules/react-query": { - "version": "3.39.3", - "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz", - "integrity": "sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g==", - "dependencies": { - "@babel/runtime": "^7.5.5", - "broadcast-channel": "^3.4.1", - "match-sorter": "^6.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, "node_modules/react-redux": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", @@ -9068,11 +9040,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/remove-accents": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", - "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" - }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -9159,6 +9126,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -10292,15 +10260,6 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, - "node_modules/unload": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", - "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", - "dependencies": { - "@babel/runtime": "^7.6.2", - "detect-node": "^2.0.4" - } - }, "node_modules/unraw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", @@ -10643,7 +10602,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true }, "node_modules/xml": { "version": "1.0.1", diff --git a/izanami-frontend/package.json b/izanami-frontend/package.json index 7cef6ee27..9eddb5ff1 100644 --- a/izanami-frontend/package.json +++ b/izanami-frontend/package.json @@ -15,6 +15,7 @@ "@hookform/error-message": "^2.0.1", "@maif/react-forms": "1.6.3", "@mui/material": "^5.13.6", + "@tanstack/react-query": "^5.62.2", "@tanstack/react-table": "^8.1.3", "@textea/json-viewer": "^3.1.1", "@uiw/react-codemirror": "^4.22.1", @@ -26,10 +27,10 @@ "handlebars": "^4.7.8", "lodash": "^4.17.21", "react": "18.1.0", + "react-codemirror-merge": "^4.23.6", "react-dom": "18.1.0", "react-hook-form": "^7.48.2", "react-hot-toast": "^2.4.1", - "react-query": "^3.39.1", "react-router-dom": "6.4.0", "react-select": "^5.7.7", "react-tooltip": "^5.14.0", diff --git a/izanami-frontend/playwright.config.ts b/izanami-frontend/playwright.config.ts index b89a14957..e5d3b7e03 100644 --- a/izanami-frontend/playwright.config.ts +++ b/izanami-frontend/playwright.config.ts @@ -13,8 +13,6 @@ export const STORAGE_STATE = path.join(__dirname, "playwright/.auth/user.json"); * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - timeout: 180_000, - expect: { timeout: 12_000 }, testDir: "./tests", /* Run tests in files in parallel */ fullyParallel: true, @@ -46,7 +44,13 @@ export default defineConfig({ { name: "chromium", dependencies: ["setup"], - use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE }, + use: { + ...devices["Desktop Chrome"], + storageState: STORAGE_STATE, + contextOptions: { + permissions: ["clipboard-read", "clipboard-write"], + }, + }, fullyParallel: false, }, diff --git a/izanami-frontend/src/App.tsx b/izanami-frontend/src/App.tsx index b519e44f9..66958c2f9 100644 --- a/izanami-frontend/src/App.tsx +++ b/izanami-frontend/src/App.tsx @@ -24,7 +24,7 @@ import { Toaster } from "react-hot-toast"; import { Project } from "./pages/project"; import { Menu } from "./pages/menu"; import { Tenant } from "./pages/tenant"; -import { QueryClientProvider, useQuery } from "react-query"; +import { QueryClientProvider, useQuery } from "@tanstack/react-query"; import queryClient from "./queryClient"; import { Tag } from "./pages/tag"; import { Login } from "./pages/login"; @@ -66,6 +66,7 @@ import Logo from "../izanami.png"; import { SearchModal } from "./components/SearchComponant/SearchModal"; import { WebHooks } from "./pages/webhooks"; import { PasswordModal } from "./components/PasswordModal"; +import { ProjectLogs } from "./pages/projectLogs"; function Wrapper({ element, @@ -285,6 +286,21 @@ const router = createBrowserRouter([ ), }, }, + { + path: "/tenants/:tenant/projects/:project/logs", + element: , + handle: { + crumb: (data: any) => ( + ""} + to={`/tenants/${data.tenant}/projects/${data.project}/logs`} + > + +  Logs + + ), + }, + }, { path: "/tenants/:tenant/projects/:project/settings", element: , @@ -423,7 +439,10 @@ function RedirectToFirstTenant(): JSX.Element { const context = useContext(IzanamiContext); const defaultTenant = context.user?.defaultTenant; let tenant = defaultTenant || context.user?.rights?.tenants?.[0]; - const tenantQuery = useQuery(MutationNames.TENANTS, () => queryTenants()); + const tenantQuery = useQuery({ + queryKey: [MutationNames.TENANTS], + queryFn: () => queryTenants() + }); if (tenant) { return ; diff --git a/izanami-frontend/src/components/AllContextSelect.tsx b/izanami-frontend/src/components/AllContextSelect.tsx index 0a8408584..622b09a56 100644 --- a/izanami-frontend/src/components/AllContextSelect.tsx +++ b/izanami-frontend/src/components/AllContextSelect.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useQuery } from "react-query"; +import { useQuery } from "@tanstack/react-query"; import { globalContextKey, queryGlobalContexts } from "../utils/queries"; import { useParams } from "react-router-dom"; import Select from "react-select"; @@ -15,9 +15,12 @@ export function AllContexts(props: { }) { const { tenant } = useParams(); const { value, onChange } = props; - const contextQuery = useQuery(globalContextKey(tenant!), () => - queryGlobalContexts(tenant!, true) - ); + const contextQuery = useQuery({ + queryKey: [globalContextKey(tenant!)], + + queryFn: () => + queryGlobalContexts(tenant!, true) + }); if (contextQuery.error) { return
Failed to fetch contexts
; diff --git a/izanami-frontend/src/components/Editor.tsx b/izanami-frontend/src/components/Editor.tsx index cfb0fff35..c1f002fee 100644 --- a/izanami-frontend/src/components/Editor.tsx +++ b/izanami-frontend/src/components/Editor.tsx @@ -106,7 +106,7 @@ function Editor(props: { value: string; onChange: (v: string) => void }) {