Switch JSON serialization framework

This commit is contained in:
Dominic Grimm 2024-02-19 18:00:25 +01:00
parent 2f6537dc8e
commit 2beeb3932f
7 changed files with 123 additions and 101 deletions

View file

@ -16,6 +16,13 @@
val scala3Version = "3.3.1" val scala3Version = "3.3.1"
val deps = Seq(
"io.bullet" %% "borer-core" % "1.14.0",
"io.bullet" %% "borer-derivation" % "1.14.0",
"com.lightbend.akka" %% "akka-stream-alpakka-mqtt" % "7.0.1",
"com.typesafe.akka" %% "akka-stream" % "2.9.0"
)
val keycloakVersion = "23.0.6" val keycloakVersion = "23.0.6"
val keycloakDeps = Seq( val keycloakDeps = Seq(
"org.keycloak" % "keycloak-core" % keycloakVersion % "provided", "org.keycloak" % "keycloak-core" % keycloakVersion % "provided",
@ -23,17 +30,11 @@ val keycloakDeps = Seq(
"org.keycloak" % "keycloak-server-spi-private" % keycloakVersion % "provided" "org.keycloak" % "keycloak-server-spi-private" % keycloakVersion % "provided"
) )
val deps = Seq(
"com.lihaoyi" %% "upickle" % "3.2.0",
"com.lightbend.akka" %% "akka-stream-alpakka-mqtt" % "7.0.1",
"com.typesafe.akka" %% "akka-stream" % "2.9.0"
)
lazy val root = project lazy val root = project
.in(file(".")) .in(file("."))
.settings( .settings(
name := "keycloak-event-listener-mqtt", name := "keycloak-event-listener-mqtt",
version := keycloakVersion, version := "0.1.0",
scalaVersion := scala3Version, scalaVersion := scala3Version,
resolvers += "Akka library repository".at("https://repo.akka.io/maven"), resolvers += "Akka library repository".at("https://repo.akka.io/maven"),
libraryDependencies ++= keycloakDeps, libraryDependencies ++= keycloakDeps,

View file

@ -1,3 +1,5 @@
package net.dergrimm.keycloak.providers.events.mqtt
/* /*
* Copyright 2024 Dominic Grimm * Copyright 2024 Dominic Grimm
* *
@ -14,18 +16,11 @@
* limitations under the License. * limitations under the License.
*/ */
package net.dergrimm.keycloak.providers.events.mqtt import org.keycloak.events.Event
import org.keycloak.events.admin.AdminEvent
import org.keycloak.models.KeycloakSession
import org.keycloak.events.EventType private trait FromEvent[+Self]:
import org.keycloak.events.admin.OperationType def fromEvent(event: Event, session: KeycloakSession): Self
import akka.stream.scaladsl.Sink def fromEvent(event: AdminEvent, session: KeycloakSession): Self
import akka.stream.alpakka.mqtt.MqttMessage end FromEvent
import akka.Done
import scala.concurrent.Future
final case class MqttEventListenerProviderFactoryData(
excludedEvents: Option[Set[EventType]],
excludedAdminOperations: Option[Set[OperationType]],
mqttOptions: MqttOptions,
mqttSink: Sink[MqttMessage, Future[Done]]
)

View file

@ -31,6 +31,7 @@ import akka.util.ByteString
import akka.stream.scaladsl.Source import akka.stream.scaladsl.Source
import scala.util.Success import scala.util.Success
import scala.util.Failure import scala.util.Failure
import io.bullet.borer.Json
class MqttEventListenerProvider( class MqttEventListenerProvider(
val session: KeycloakSession, val session: KeycloakSession,
@ -46,8 +47,9 @@ class MqttEventListenerProvider(
if excludedEvents.isDefined && excludedEvents.contains(event.getType()) if excludedEvents.isDefined && excludedEvents.contains(event.getType())
then return then return
val payload = Payload.fromEvent(event, session) val payload: Payload = Payload.fromEvent(event, session)
sendMessage(payload) sendMessage(payload)
end onEvent
override def onEvent( override def onEvent(
event: AdminEvent, event: AdminEvent,
@ -58,8 +60,9 @@ class MqttEventListenerProvider(
) )
then return then return
val payload = Payload.fromEvent(event, session) val payload: Payload = Payload.fromEvent(event, session)
sendMessage(payload) sendMessage(payload)
end onEvent
override def close(): Unit = {} override def close(): Unit = {}
@ -67,11 +70,14 @@ class MqttEventListenerProvider(
import concurrent.ExecutionContext.Implicits.global import concurrent.ExecutionContext.Implicits.global
import MqttEventListenerProviderFactory.system import MqttEventListenerProviderFactory.system
val topic = s"${mqttOptions.topic}/${payload.topic}" val topic: String = s"${mqttOptions.topic}/${payload.topic}"
val payloadStr = upickle.default.write(payload) val payloadBytes: Array[Byte] = Json.encode(payload).toByteArray
val msg = MqttMessage(topic, ByteString(payloadStr))
.withRetained(mqttOptions.retained) val msg: MqttMessage =
val future = Source.single(msg).runWith(mqttSink) MqttMessage(topic, ByteString.fromArray(payloadBytes))
.withRetained(mqttOptions.retained)
val future: Future[Done] = Source.single(msg).runWith(mqttSink)
future.onComplete { future.onComplete {
case Failure(exception) => case Failure(exception) =>
logger.log( logger.log(
@ -80,4 +86,5 @@ class MqttEventListenerProvider(
) )
case Success(_) => {} case Success(_) => {}
} }
end sendMessage
end MqttEventListenerProvider end MqttEventListenerProvider

View file

@ -31,70 +31,82 @@ import scala.concurrent.duration.FiniteDuration
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import akka.stream.alpakka.mqtt.scaladsl.MqttSink import akka.stream.alpakka.mqtt.scaladsl.MqttSink
import akka.actor.ActorSystem import akka.actor.ActorSystem
import akka.stream.scaladsl.Sink
import akka.stream.alpakka.mqtt.MqttMessage
import akka.Done
import scala.concurrent.Future
object MqttEventListenerProviderFactory: object MqttEventListenerProviderFactory:
private val PLUGIN_ID = "mqtt" private val PLUGIN_ID = "mqtt"
private val PUBLISHER_ID = "keycloak" private val PUBLISHER_ID = "keycloak"
implicit val system: ActorSystem = ActorSystem() given system: ActorSystem = ActorSystem()
end MqttEventListenerProviderFactory end MqttEventListenerProviderFactory
class MqttEventListenerProviderFactory( class MqttEventListenerProviderFactory extends EventListenerProviderFactory:
var data: MqttEventListenerProviderFactoryData
) extends EventListenerProviderFactory:
private final val logger = private final val logger =
logging.Logger.getLogger( logging.Logger.getLogger(
classOf[MqttEventListenerProviderFactory].getName() classOf[MqttEventListenerProviderFactory].getName()
) )
def this() = this(null) private var excludedEvents: Option[Set[EventType]] = None
private var excludedAdminEvents: Option[Set[OperationType]] = None
private var mqttOptions: MqttOptions = null
private var mqttSink: Sink[MqttMessage, Future[Done]] = null
override def create(session: KeycloakSession): MqttEventListenerProvider = override def create(session: KeycloakSession): MqttEventListenerProvider =
MqttEventListenerProvider( MqttEventListenerProvider(
session, session,
excludedEvents = data.excludedEvents, excludedEvents,
excludedAdminEvents = data.excludedAdminOperations, excludedAdminEvents,
mqttOptions = data.mqttOptions, mqttOptions,
mqttSink = data.mqttSink mqttSink
) )
end create
override def init(config: Config.Scope): Unit = override def init(config: Config.Scope): Unit =
val excludes = config.getArray("excludeEvents") val excludes: Array[String] = config.getArray("excludeEvents")
val excludedEvents = excludedEvents =
if excludes != null then if excludes != null
then
Some( Some(
immutable.HashSet.from(excludes.map(EventType.valueOf)) immutable.HashSet.from(excludes.map(EventType.valueOf))
) )
else None else None
val excludesOperations = config.getArray("excludesOperations") val excludesOperations: Array[String] =
val excludedAdminOperations = config.getArray("excludesOperations")
if excludesOperations != null then excludedAdminEvents =
if excludesOperations != null
then
Some( Some(
immutable.HashSet.from(excludesOperations.map(OperationType.valueOf)) immutable.HashSet.from(excludesOperations.map(OperationType.valueOf))
) )
else None else None
val uri = config.get("serverUri") val uri: String = config.get("serverUri")
if uri == null then if uri == null then
throw new IllegalArgumentException("MQTT server URI is null") throw new IllegalArgumentException("MQTT server URI is null")
val credentials = val credentials: Option[(String, String)] =
val username = config.get("username") val username = config.get("username")
val password = config.get("password") val password = config.get("password")
if username != null && password != null then Some((username, password))
if username != null && password != null
then Some(username -> password)
else None else None
val cleanSession = config.getBoolean("cleanSession", true) val cleanSession: Boolean = config.getBoolean("cleanSession", true)
val connectionTimeout = val connectionTimeout: FiniteDuration =
FiniteDuration(config.getLong("connectionTimeout", 10), TimeUnit.SECONDS) FiniteDuration(config.getLong("connectionTimeout", 10), TimeUnit.SECONDS)
val mqttOptions = MqttOptions.fromConfig(config) mqttOptions = MqttOptions.fromConfig(config)
var connectionSettings = MqttConnectionSettings( var connectionSettings = MqttConnectionSettings(
uri, uri,
"net.dergrimm.keycloak.providers.events.mqtt", "net.dergrimm.keycloak.providers.events.mqtt",
new MemoryPersistence new MemoryPersistence()
).withCleanSession(cleanSession) )
.withCleanSession(cleanSession)
.withConnectionTimeout(connectionTimeout) .withConnectionTimeout(connectionTimeout)
credentials match credentials match
@ -105,14 +117,8 @@ class MqttEventListenerProviderFactory(
) )
case None => {} case None => {}
val sink = MqttSink(connectionSettings, mqttOptions.qos) mqttSink = MqttSink(connectionSettings, mqttOptions.qos)
end init
data = MqttEventListenerProviderFactoryData(
excludedEvents = excludedEvents,
excludedAdminOperations = excludedAdminOperations,
mqttOptions,
sink
)
override def postInit(factory: KeycloakSessionFactory): Unit = {} override def postInit(factory: KeycloakSessionFactory): Unit = {}

View file

@ -21,23 +21,23 @@ import akka.stream.alpakka.mqtt.MqttQoS
object MqttOptions: object MqttOptions:
def fromConfig(config: Config.Scope): MqttOptions = def fromConfig(config: Config.Scope): MqttOptions =
val topic: String = config.get("topic")
val topic = config.get("topic")
if topic == null then if topic == null then
throw new IllegalArgumentException("MQTT topic is null") throw new IllegalArgumentException("MQTT topic is null")
val retained = config.getBoolean("retained") val retained: Boolean = config.getBoolean("retained")
val qos = config.getInt("qos", 0) match val qos: MqttQoS = config.getInt("qos", 0) match
case 0 => MqttQoS.atMostOnce case 0 => MqttQoS.atMostOnce
case 1 => MqttQoS.atLeastOnce case 1 => MqttQoS.atLeastOnce
case 2 => MqttQoS.exactlyOnce case 2 => MqttQoS.exactlyOnce
MqttOptions( MqttOptions(
topic = topic, topic,
retained, retained,
qos qos
) )
end fromConfig
end MqttOptions end MqttOptions
private final case class MqttOptions( private final case class MqttOptions(

View file

@ -19,47 +19,50 @@ package net.dergrimm.keycloak.providers.events.mqtt
import org.keycloak.events.Event import org.keycloak.events.Event
import org.keycloak.events.admin.AdminEvent import org.keycloak.events.admin.AdminEvent
import org.keycloak.models.KeycloakSession import org.keycloak.models.KeycloakSession
import upickle.default.ReadWriter import org.keycloak.events.admin.ResourceType
import io.bullet.borer.{Codec, Encoder}
import io.bullet.borer.derivation.MapBasedCodecs._
import io.bullet.borer.NullOptions.given
object Payload: object Payload extends FromEvent[Payload]:
def fromEvent(event: Event, session: KeycloakSession): Payload = def fromEvent(event: Event, session: KeycloakSession): Payload =
val error = event.getError() val error: Option[String] = Option(event.getError())
val realmId = event.getRealmId() val realmId: String = event.getRealmId()
Payload( Payload(
admin = false, admin = false,
time = event.getTime(), time = event.getTime(),
realm = session.realms().getRealm(realmId).getName(), realm = session.realms().getRealm(realmId).getName(),
realmId, realmId,
authDetails = PayloadAuthDetails.fromEvent(event), authDetails = PayloadAuthDetails.fromEvent(event, session),
resourceType = null, resourceType = None,
operationType = event.getType().toString(), operationType = event.getType().toString(),
resourcePath = null, resourcePath = None,
representation = null, representation = None,
error, error
resourceTypeAsString = null
) )
end fromEvent
def fromEvent(event: AdminEvent, session: KeycloakSession): Payload = def fromEvent(event: AdminEvent, session: KeycloakSession): Payload =
val resourceType = event.getResourceType() val resourceType: Option[String] =
val representation = event.getRepresentation() Option(event.getResourceType()).map(_.toString())
val error = event.getError() val representation: Option[String] = Option(event.getRepresentation())
val realmId = event.getRealmId() val error: Option[String] = Option(event.getError())
val realmId: String = event.getRealmId()
Payload( Payload(
admin = true, admin = true,
time = event.getTime(), time = event.getTime(),
realm = session.realms().getRealm(realmId).getName(), realm = session.realms().getRealm(realmId).getName(),
realmId, realmId,
authDetails = PayloadAuthDetails.fromEvent(event), authDetails = PayloadAuthDetails.fromEvent(event, session),
resourceType = resourceType,
if resourceType == null then null else resourceType.toString(),
operationType = event.getOperationType().toString(), operationType = event.getOperationType().toString(),
resourcePath = event.getResourcePath(), resourcePath = Some(event.getResourcePath()),
representation, representation,
error, error
resourceTypeAsString = event.getResourceTypeAsString()
) )
end fromEvent
end Payload end Payload
private final case class Payload( private final case class Payload(
@ -68,20 +71,22 @@ private final case class Payload(
realm: String, realm: String,
realmId: String, realmId: String,
authDetails: PayloadAuthDetails, authDetails: PayloadAuthDetails,
resourceType: String, resourceType: Option[String],
operationType: String, operationType: String,
resourcePath: String, resourcePath: Option[String],
representation: String, representation: Option[String],
error: String, error: Option[String]
resourceTypeAsString: String ) derives Codec:
) derives ReadWriter: private def result: String = if error.isDefined then "error" else "success"
private def result: String =
if error != null then "error" else "success"
def topic: String = def topic: String =
println(resourceType.toString())
if admin if admin
then then
s"admin/${realm}/${result}/${resourceType.toLowerCase()}/${operationType.toLowerCase()}" resourceType match
case Some(rType) =>
s"admin/${realm}/${result}/${rType.toLowerCase()}/${operationType.toLowerCase()}"
case None => throw new IllegalStateException
else else
s"client/${realm}/${result}/${authDetails.clientId}/${operationType.toLowerCase()}" s"client/${realm}/${result}/${authDetails.clientId}/${operationType.toLowerCase()}"
end topic
end Payload

View file

@ -17,20 +17,27 @@
package net.dergrimm.keycloak.providers.events.mqtt package net.dergrimm.keycloak.providers.events.mqtt
import org.keycloak.events.Event import org.keycloak.events.Event
import org.keycloak.events.admin.AdminEvent import org.keycloak.events.admin.{AdminEvent, AuthDetails}
import upickle.default.ReadWriter import org.keycloak.models.KeycloakSession
import io.bullet.borer.Codec
import io.bullet.borer.derivation.MapBasedCodecs._
import io.bullet.borer.NullOptions.given
object PayloadAuthDetails: object PayloadAuthDetails extends FromEvent[PayloadAuthDetails]:
def fromEvent(event: Event): PayloadAuthDetails = def fromEvent(event: Event, session: KeycloakSession): PayloadAuthDetails =
PayloadAuthDetails( PayloadAuthDetails(
realmId = event.getRealmId(), realmId = event.getRealmId(),
clientId = event.getClientId(), clientId = event.getClientId(),
userId = event.getUserId(), userId = event.getUserId(),
ipAddress = event.getIpAddress() ipAddress = event.getIpAddress()
) )
end fromEvent
def fromEvent(event: AdminEvent): PayloadAuthDetails = def fromEvent(
val auth = event.getAuthDetails() event: AdminEvent,
session: KeycloakSession
): PayloadAuthDetails =
val auth: AuthDetails = event.getAuthDetails()
PayloadAuthDetails( PayloadAuthDetails(
realmId = auth.getRealmId(), realmId = auth.getRealmId(),
@ -38,6 +45,7 @@ object PayloadAuthDetails:
userId = auth.getClientId(), userId = auth.getClientId(),
ipAddress = auth.getIpAddress() ipAddress = auth.getIpAddress()
) )
end fromEvent
end PayloadAuthDetails end PayloadAuthDetails
private final case class PayloadAuthDetails( private final case class PayloadAuthDetails(
@ -45,4 +53,4 @@ private final case class PayloadAuthDetails(
clientId: String, clientId: String,
userId: String, userId: String,
ipAddress: String ipAddress: String
) derives ReadWriter ) derives Codec