Skip to content

Commit

Permalink
feat: audit log for projects
Browse files Browse the repository at this point in the history
This also fix missing feature (created/updated) events from import
  • Loading branch information
ptitFicus committed Dec 9, 2024
1 parent c6c2e47 commit 8511b47
Show file tree
Hide file tree
Showing 92 changed files with 97,872 additions and 95,692 deletions.
7 changes: 4 additions & 3 deletions app/fr/maif/izanami/datastores/ApiKeyDatastore.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -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))
Expand All @@ -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]]] = {
Expand Down Expand Up @@ -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")) }
Expand Down
168 changes: 168 additions & 0 deletions app/fr/maif/izanami/datastores/EventDatastore.scala
Original file line number Diff line number Diff line change
@@ -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
)
}
35 changes: 13 additions & 22 deletions app/fr/maif/izanami/datastores/FeatureContextDatastore.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(()))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(()))
Expand Down
Loading

0 comments on commit 8511b47

Please sign in to comment.