Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ private[ror] object AuditSerializationHelper {
case AuditFieldValueDescriptor.InvolvedIndices => if (requestContext.involvesIndices) requestContext.indices.toList.asJava else List.empty.asJava
case AuditFieldValueDescriptor.AclHistory => requestContext.history
case AuditFieldValueDescriptor.ProcessingDurationMillis => eventData.duration.toMillis
case AuditFieldValueDescriptor.ProcessingDurationNanos => eventData.duration.toNanos
case AuditFieldValueDescriptor.Timestamp => timestampFormatter.format(requestContext.timestamp)
case AuditFieldValueDescriptor.Id => requestContext.id
case AuditFieldValueDescriptor.CorrelationId => requestContext.correlationId
Expand All @@ -119,7 +120,17 @@ private[ror] object AuditSerializationHelper {
case AuditFieldValueDescriptor.EsNodeName => eventData.requestContext.auditEnvironmentContext.esNodeName
case AuditFieldValueDescriptor.EsClusterName => eventData.requestContext.auditEnvironmentContext.esClusterName
case AuditFieldValueDescriptor.StaticText(text) => text
case AuditFieldValueDescriptor.NumericValue(value) => value
case AuditFieldValueDescriptor.BooleanValue(value) => value
case AuditFieldValueDescriptor.Combined(values) => values.map(resolver(eventData)).mkString
case AuditFieldValueDescriptor.Nested(values) =>
val resolveAuditFieldValue = resolver(eventData)
val nestedFields: Map[String, Any] = values.map { case (nestedName, nestedDescriptor) =>
nestedName.value -> resolveAuditFieldValue(nestedDescriptor)
}
nestedFields.foldLeft(new JSONObject()) { case (soFar, (key, value)) =>
soFar.put(key, value)
}
}
}

Expand Down Expand Up @@ -178,6 +189,8 @@ private[ror] object AuditSerializationHelper {

case object ProcessingDurationMillis extends AuditFieldValueDescriptor

case object ProcessingDurationNanos extends AuditFieldValueDescriptor

// Identifiers
case object Timestamp extends AuditFieldValueDescriptor

Expand Down Expand Up @@ -223,8 +236,18 @@ private[ror] object AuditSerializationHelper {

final case class StaticText(value: String) extends AuditFieldValueDescriptor

final case class BooleanValue(value: Boolean) extends AuditFieldValueDescriptor

final case class NumericValue(value: Double) extends AuditFieldValueDescriptor

final case class Combined(values: List[AuditFieldValueDescriptor]) extends AuditFieldValueDescriptor

final case class Nested(values: Map[AuditFieldName, AuditFieldValueDescriptor]) extends AuditFieldValueDescriptor
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's one level of nesting. Let's do it in a more generic way (to allow many nesting levels).

BTW. We define fields like that:

fields: Map[AuditFieldName, AuditFieldValueDescriptor]

So, maybe we should not add Nested as a descriptor, but maybe we should modify the AuditFieldName definition instead.

E.g.
AuditFieldName is:

  • a string OR
  • is a composition of AuditFieldNames (or strings)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, I see I was wrong about the nesting.
Nevertheless, let's consider the change above.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually that was my first version of implementation - the field name as list of strings, representing nesting. I have it in git history, so I can do it with no problems if we so decide. But I honestly think that the current version is better, because:

  • it represents 1:1 the structure of the JSON, so is easier to grasp
  • it forces to defined all fields inside the section, which improves readability
  • it is especially better when user defines "configurable" serializer:

Example from tests:

readonlyrest:
  audit:
    enabled: true
    outputs:
    - type: log
      serializer:
        type: configurable
        verbosity_level_serialization_mode: [INFO]
        fields:
          custom_section:
            nested_text: "nt"
            nested_number: 123
            nested_boolean: true
            double_nested:
              double_nested_next: "dnt"
              triple_nested:
                triple_nested_next: "tnt"
          node_name_with_static_suffix: "{ES_NODE_NAME} with suffix"
          another_field: "{ES_CLUSTER_NAME} {HTTP_METHOD}"
          tid: "{TASK_ID}"
          bytes: "{CONTENT_LENGTH_IN_BYTES}"

With the approach based on the list of strings, it would look like that:

fields:
  - path: ["custom_section", "nested_text"]
    value: "nt"

  - path: ["custom_section", "nested_number"]
    value: 123

  - path: ["custom_section", "nested_boolean"]
    value: true

  - path: ["custom_section", "double_nested", "double_nested_next"]
    value: "dnt"

  - path: ["custom_section", "double_nested", "triple_nested", "triple_nested_next"]
    value: "tnt"

  - path: ["node_name_with_static_suffix"]
    value: "{ES_NODE_NAME} with suffix"

  - path: ["another_field"]
    value: "{ES_CLUSTER_NAME} {HTTP_METHOD}"

  - path: ["tid"]
    value: "{TASK_ID}"

  - path: ["bytes"]
    value: "{CONTENT_LENGTH_IN_BYTES}"

I think that the nested approach is more readable in the code too.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I agree. I see it as follows:

  1. we have some representation of the fields and value descriptors in the ROR's YAML
  2. then we have our internal (domain) representation of it
  3. finally, we serialize the representation to the ES index document

And the only thing that is JSON here is dthe ocument.
The internal representation is not a JSON, and the "fields" representation in ROR's YAML is not a JSON either (we don't support arrays).

But I don't want you to change the YAML's representation. It's fine. But obivously we should detect invalid settings like:

readonlyrest:
  audit:
    enabled: true
    outputs:
    - type: log
      serializer:
        type: configurable
        verbosity_level_serialization_mode: [INFO]
        fields: 1 # or string, or bool, or array
readonlyrest:
  audit:
    enabled: true
    outputs:
    - type: log
      serializer:
        type: configurable
        verbosity_level_serialization_mode: [INFO]
        fields:
          custom_section:
            - sth: 1
            - sth: 2
readonlyrest:
  audit:
    enabled: true
    outputs:
    - type: log
      serializer:
        type: configurable
        verbosity_level_serialization_mode: [INFO]
        fields:
          custom_section: {}
          custom_section2: null

So, when we take into consideration the limitations, we could say that we have a map of simplified field path keys (without arrays) and value descriptor leafs.

In code, we represent it as Map[AuditFieldName, AuditFieldValueDescriptor], where

  • AuditFieldValueDescriptor is always a leaf (of type string, int, bool ... maybe null?!)
  • AuditFieldName is a path to this leaf (e.g., custom_section.double_nested.double_nested_next or node_name_with_static_suffix)

That's why I propose to define AuditFieldName (in the domain ... maybe we should rename it to AuditFieldPath) like this:

type AuditFieldPath = NonEmptyList[String].

And AuditFieldValueDescriptor will be free of sth like Nested.

WDYT?


object Nested {
def apply(elems: (AuditFieldName, AuditFieldValueDescriptor)*) = new Nested(elems.toMap)
}

}

sealed trait AuditFieldGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ object AuditFieldValueDescriptorParser {
case "INVOLVED_INDICES" => Some(AuditFieldValueDescriptor.InvolvedIndices)
case "ACL_HISTORY" => Some(AuditFieldValueDescriptor.AclHistory)
case "PROCESSING_DURATION_MILLIS" => Some(AuditFieldValueDescriptor.ProcessingDurationMillis)
case "PROCESSING_DURATION_NANOS" => Some(AuditFieldValueDescriptor.ProcessingDurationNanos)
case "TIMESTAMP" => Some(AuditFieldValueDescriptor.Timestamp)
case "ID" => Some(AuditFieldValueDescriptor.Id)
case "CORRELATION_ID" => Some(AuditFieldValueDescriptor.CorrelationId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* This file is part of ReadonlyREST.
*
* ReadonlyREST is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ReadonlyREST is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ReadonlyREST. If not, see http://www.gnu.org/licenses/
*/
package tech.beshu.ror.accesscontrol.audit.ecs

import org.json.JSONObject
import tech.beshu.ror.accesscontrol.audit.ecs.EcsV1AuditLogSerializer.fields
import tech.beshu.ror.audit.utils.AuditSerializationHelper
import tech.beshu.ror.audit.utils.AuditSerializationHelper.{AllowedEventMode, AuditFieldName, AuditFieldValueDescriptor}
import tech.beshu.ror.audit.{AuditLogSerializer, AuditResponseContext}

class EcsV1AuditLogSerializer(val allowedEventMode: AllowedEventMode) extends AuditLogSerializer {

override def onResponse(responseContext: AuditResponseContext): Option[JSONObject] = {
AuditSerializationHelper.serialize(responseContext, fields, allowedEventMode)
}

}

object EcsV1AuditLogSerializer {
private val fields: Map[AuditFieldName, AuditFieldValueDescriptor] = Map(
AuditFieldName("@timestamp") -> AuditFieldValueDescriptor.Timestamp,
AuditFieldName("trace") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("id") -> AuditFieldValueDescriptor.CorrelationId,
),
AuditFieldName("url") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("path") -> AuditFieldValueDescriptor.HttpPath,
),
AuditFieldName("source") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("address") -> AuditFieldValueDescriptor.RemoteAddress,
),
AuditFieldName("destination") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("address") -> AuditFieldValueDescriptor.LocalAddress,
),
AuditFieldName("http") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("request") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("method") -> AuditFieldValueDescriptor.HttpMethod,
AuditFieldName("body") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("content") -> AuditFieldValueDescriptor.Content,
AuditFieldName("bytes") -> AuditFieldValueDescriptor.ContentLengthInBytes,
),
),
),
AuditFieldName("user") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("name") -> AuditFieldValueDescriptor.User,
AuditFieldName("effective") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("name") -> AuditFieldValueDescriptor.ImpersonatedByUser,
),
),
AuditFieldName("event") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("id") -> AuditFieldValueDescriptor.Id,
AuditFieldName("action") -> AuditFieldValueDescriptor.Action,
AuditFieldName("type") -> AuditFieldValueDescriptor.Type,
AuditFieldName("reason") -> AuditFieldValueDescriptor.FinalState,
AuditFieldName("duration") -> AuditFieldValueDescriptor.ProcessingDurationNanos,
),
Comment on lines 67 to 73
Copy link

@coderabbitai coderabbitai bot Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix event.reason mapping (use Reason, not FinalState).

event.reason should carry the explanatory reason text, not the final state. Map it to AuditFieldValueDescriptor.Reason.

Apply this diff:

-      AuditFieldName("reason") -> AuditFieldValueDescriptor.FinalState,
+      AuditFieldName("reason") -> AuditFieldValueDescriptor.Reason,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
AuditFieldName("event") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("id") -> AuditFieldValueDescriptor.Id,
AuditFieldName("action") -> AuditFieldValueDescriptor.Action,
AuditFieldName("type") -> AuditFieldValueDescriptor.Type,
AuditFieldName("reason") -> AuditFieldValueDescriptor.FinalState,
AuditFieldName("duration") -> AuditFieldValueDescriptor.ProcessingDurationNanos,
),
AuditFieldName("event") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("id") -> AuditFieldValueDescriptor.Id,
AuditFieldName("action") -> AuditFieldValueDescriptor.Action,
AuditFieldName("type") -> AuditFieldValueDescriptor.Type,
AuditFieldName("reason") -> AuditFieldValueDescriptor.Reason,
AuditFieldName("duration") -> AuditFieldValueDescriptor.ProcessingDurationNanos,
),
🤖 Prompt for AI Agents
In
core/src/main/scala/tech/beshu/ror/accesscontrol/audit/ecs/EcsV1AuditLogSerializer.scala
around lines 66 to 72, the mapping for AuditFieldName("reason") incorrectly uses
AuditFieldValueDescriptor.FinalState; change it to
AuditFieldValueDescriptor.Reason so event.reason carries the explanatory reason
text. Update the nested mapping entry to replace FinalState with Reason and
ensure imports/usage remain consistent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored the action section and used ECS 1.6 specification

 AuditFieldName("event") -> AuditFieldValueDescriptor.Nested(
      AuditFieldName("id") -> AuditFieldValueDescriptor.Id,
      AuditFieldName("dataset") -> AuditFieldValueDescriptor.Action, // ROR Action describes the resource being accessed, so it is dataset in ECS
      AuditFieldName("action") -> AuditFieldValueDescriptor.Type, // ROR Type describes operation that is executed, so it is ECS action
      AuditFieldName("reason") -> AuditFieldValueDescriptor.FinalState, // ROR Final state described the outcome, so it is the ECS reason of event happening, the ROR Reason is a custom detailed_reason label
      AuditFieldName("duration") -> AuditFieldValueDescriptor.ProcessingDurationNanos,
    ),

The ROR "reason" is now one of custom ECS labels:

    AuditFieldName("labels") -> AuditFieldValueDescriptor.Nested(
      ...
      AuditFieldName("detailed_reason") -> AuditFieldValueDescriptor.Reason,
    ),

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

ECS 1.6 event.reason event.action event.dataset field definitions

💡 Result:

Summary definitions (ECS v1.6 semantics are the same as current ECS event docs):

  • event.action — The action captured by the event; more specific than event.category (examples: "group-add", "process-started", "file-created"). Type: keyword. Level: core. [1]

  • event.reason — Why the event happened according to the source; explains the motive or cause for the action/outcome (example: "blocked site", "Terminated an unexpected process"). Type: keyword. Level: extended. [1]

  • event.dataset — Name of the dataset / which type of log the event comes from when a source publishes multiple log types; recommended convention is "." (example: "apache.access"). Type: keyword. Level: core. [2]

Sources:

  1. Elastic Common Schema — Event fields (event.action, event.reason). [Elastic docs]. [2]
  2. Elastic Common Schema — Event fields (event.dataset / implementation guidance). [Elastic docs]. [3]

@mgoworko, I appreciate the refactor to ECS 1.6, but I have concerns about the semantic alignment with the ECS specification:

Issues with current mappings:

  1. event.dataset → ROR Action: According to ECS, event.dataset identifies which log type/source the event comes from (e.g., "apache.access", "ror.audit"). Mapping it to ROR Action (like "cluster:internal_ror/user_metadata/get") misuses this field—it should be a static identifier like "ror.audit" or "readonlyrest.access".

  2. event.action → ROR Type: ECS defines event.action as "the action captured by the event" (e.g., "user-authentication", "resource-access"). ROR Type (e.g., "RRTestConfigRequest") seems too low-level. The ROR Action would be more semantically appropriate here as it describes what operation was attempted.

  3. event.reason → ROR FinalState: ECS defines event.reason as "why the event happened according to the source; explains the motive or cause." ROR FinalState (ALLOWED/FORBIDDEN) is an outcome, not a reason. This should map to event.outcome instead (which accepts values like "success", "failure", "unknown").

Recommended mapping:

AuditFieldName("event") -> AuditFieldValueDescriptor.Nested(
  AuditFieldName("id") -> AuditFieldValueDescriptor.Id,
  AuditFieldName("dataset") -> AuditFieldValueDescriptor.StaticText("ror.audit"),
  AuditFieldName("action") -> AuditFieldValueDescriptor.Action,
  AuditFieldName("reason") -> AuditFieldValueDescriptor.Reason,
  AuditFieldName("outcome") -> AuditFieldValueDescriptor.FinalState, // Map ALLOWED/FORBIDDEN to success/failure
  AuditFieldName("duration") -> AuditFieldValueDescriptor.ProcessingDurationNanos,
),
AuditFieldName("labels") -> AuditFieldValueDescriptor.Nested(
  AuditFieldName("request_type") -> AuditFieldValueDescriptor.Type,
  // ... other labels
),

This aligns better with ECS semantics where event.reason explains "why," event.outcome captures the result, and event.dataset identifies the log source.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mgoworko is it ok now?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

AuditFieldName("error") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("type") -> AuditFieldValueDescriptor.ErrorType,
AuditFieldName("message") -> AuditFieldValueDescriptor.ErrorMessage,
),
AuditFieldName("labels") -> AuditFieldValueDescriptor.Nested(
AuditFieldName("es_cluster_name") -> AuditFieldValueDescriptor.EsClusterName,
AuditFieldName("es_node_name") -> AuditFieldValueDescriptor.EsNodeName,
AuditFieldName("es_task_id") -> AuditFieldValueDescriptor.TaskId,
AuditFieldName("involved_indices") -> AuditFieldValueDescriptor.InvolvedIndices,
AuditFieldName("acl_history") -> AuditFieldValueDescriptor.AclHistory,
AuditFieldName("x_forwarded_for") -> AuditFieldValueDescriptor.XForwardedForHttpHeader,
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,26 @@ package tech.beshu.ror.accesscontrol.factory.decoders

import cats.data.NonEmptyList
import io.circe.Decoder.*
import io.circe.{Decoder, DecodingFailure, Json, HCursor, KeyDecoder}
import io.circe.*
import io.lemonlabs.uri.Uri
import org.apache.logging.log4j.scala.Logging
import tech.beshu.ror.accesscontrol.audit.AuditingTool
import tech.beshu.ror.accesscontrol.audit.AuditingTool.AuditSettings.AuditSink
import tech.beshu.ror.accesscontrol.audit.AuditingTool.AuditSettings.AuditSink.Config
import tech.beshu.ror.accesscontrol.audit.AuditingTool.AuditSettings.AuditSink.Config.{EsDataStreamBasedSink, EsIndexBasedSink, LogBasedSink}
import tech.beshu.ror.accesscontrol.audit.configurable.{AuditFieldValueDescriptorParser, ConfigurableAuditLogSerializer}
import tech.beshu.ror.accesscontrol.audit.ecs.EcsV1AuditLogSerializer
import tech.beshu.ror.accesscontrol.domain.RorAuditIndexTemplate.CreationError
import tech.beshu.ror.accesscontrol.domain.{AuditCluster, RorAuditDataStream, RorAuditIndexTemplate, RorAuditLoggerName}
import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.Reason.Message
import tech.beshu.ror.accesscontrol.factory.RawRorConfigBasedCoreFactory.CoreCreationError.{AuditingSettingsCreationError, Reason}
import tech.beshu.ror.accesscontrol.factory.decoders.common.{lemonLabsUriDecoder, nonEmptyStringDecoder}
import tech.beshu.ror.accesscontrol.utils.CirceOps.{AclCreationErrorCoders, DecodingFailureOps}
import tech.beshu.ror.accesscontrol.utils.SyncDecoderCreator
import tech.beshu.ror.audit.AuditLogSerializer
import tech.beshu.ror.audit.AuditResponseContext.Verbosity
import tech.beshu.ror.audit.utils.AuditSerializationHelper.{AllowedEventMode, AuditFieldName, AuditFieldValueDescriptor}
import tech.beshu.ror.audit.adapters.*
import tech.beshu.ror.audit.AuditLogSerializer
import tech.beshu.ror.audit.utils.AuditSerializationHelper.{AllowedEventMode, AuditFieldName, AuditFieldValueDescriptor}
import tech.beshu.ror.es.EsVersion
import tech.beshu.ror.implicits.*
import tech.beshu.ror.utils.yaml.YamlKeyDecoder
Expand Down Expand Up @@ -217,10 +218,26 @@ object AuditingSettingsDecoder extends Logging {
c.downField("serializer").as[Option[AuditLogSerializer]](extendedSyntaxStaticSerializerDecoder)
case SerializerType.ExtendedSyntaxConfigurableSerializer =>
c.downField("serializer").as[Option[AuditLogSerializer]](extendedSyntaxConfigurableSerializerDecoder)
case SerializerType.EcsSerializer =>
c.downField("serializer").as[Option[AuditLogSerializer]](ecsSerializerDecoder)
}
} yield result
}

private def ecsSerializerDecoder: Decoder[Option[AuditLogSerializer]] = Decoder.instance { c =>
for {
version <- c.downField("version").as[Option[EcsSerializerVersion]]
allowedEventMode <- c.downField("verbosity_level_serialization_mode").as[AllowedEventMode]
.left.map(withAuditingSettingsCreationErrorMessage(msg => s"Configurable serializer is used, but the 'verbosity_level_serialization_mode' setting is invalid: $msg"))
serializer = version match {
case None =>
new EcsV1AuditLogSerializer(allowedEventMode)
case Some(EcsSerializerVersion.V1) =>
new EcsV1AuditLogSerializer(allowedEventMode)
}
} yield Some(serializer)
}

private def extendedSyntaxConfigurableSerializerDecoder: Decoder[Option[AuditLogSerializer]] = Decoder.instance { c =>
for {
allowedEventMode <- c.downField("verbosity_level_serialization_mode").as[AllowedEventMode]
Expand Down Expand Up @@ -265,6 +282,8 @@ object AuditingSettingsDecoder extends Logging {
Right(SerializerType.ExtendedSyntaxStaticSerializer)
case "configurable" =>
Right(SerializerType.ExtendedSyntaxConfigurableSerializer)
case "ecs" =>
Right(SerializerType.EcsSerializer)
case other =>
Left(DecodingFailure(AclCreationErrorCoders.stringify(
AuditingSettingsCreationError(Message(s"Invalid serializer type '$other', allowed values [static, configurable]"))
Expand All @@ -283,6 +302,19 @@ object AuditingSettingsDecoder extends Logging {
case object ExtendedSyntaxStaticSerializer extends SerializerType

case object ExtendedSyntaxConfigurableSerializer extends SerializerType

case object EcsSerializer extends SerializerType
}

private given ccsSerializerVersionDecoder: Decoder[EcsSerializerVersion] = Decoder.decodeString.map(_.toLowerCase).emap {
case "v1" => Right(EcsSerializerVersion.V1)
case other => Left(s"Invalid ECS serializer version $other")
}

private sealed trait EcsSerializerVersion

private object EcsSerializerVersion {
case object V1 extends EcsSerializerVersion
}

private def withAuditingSettingsCreationErrorMessage(message: String => String)(decodingFailure: DecodingFailure) = {
Expand Down Expand Up @@ -341,11 +373,23 @@ object AuditingSettingsDecoder extends Logging {
KeyDecoder.decodeKeyString.map(AuditFieldName.apply)
}

given auditFieldValueDecoder: Decoder[AuditFieldValueDescriptor] = {
SyncDecoderCreator
.from(Decoder.decodeString)
.emap(AuditFieldValueDescriptorParser.parse)
.decoder
given auditFieldValueDecoder: Decoder[AuditFieldValueDescriptor] = Decoder.instance { cursor =>
cursor.value.fold(
jsonNull = Left(DecodingFailure("Expected AuditFieldValueDescriptor, got null", cursor.history)),
jsonBoolean = b => Right(AuditFieldValueDescriptor.BooleanValue(b)),
jsonNumber = n => Right(AuditFieldValueDescriptor.NumericValue(n.toDouble)),
jsonString = s => AuditFieldValueDescriptorParser.parse(s).left.map(err => DecodingFailure(err, cursor.history)),
jsonArray = _ => Left(DecodingFailure("AuditFieldValueDescriptor cannot be an array", cursor.history)),
jsonObject = obj => {
val decoded = obj.toMap.toList.traverse { case (k, v) =>
for {
name <- Right(AuditFieldName(k))
value <- v.as[AuditFieldValueDescriptor](using auditFieldValueDecoder)
} yield name -> value
}
decoded.map(pairs => AuditFieldValueDescriptor.Nested(pairs.toMap))
}
)
}

given verbosityDecoder: Decoder[Verbosity] = {
Expand Down
Loading