Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions agent/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
# dispatcher to be used (http, console, capture)
atum.dispatcher.type="http"

# Optional author/createdBy identity used for auditing (partitionings, checkpoints, additional data).
# If unset, it falls back to System.getProperty("user.name"). Useful when the JVM user is a generic
# system account (e.g. "yarn") and the application name is more meaningful.
#atum.author="my-application-name"

# The REST API URI of the atum server
#atum.dispatcher.http.url=

Expand Down
51 changes: 46 additions & 5 deletions agent/src/main/scala/za/co/absa/atum/agent/AtumAgent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,26 @@ trait AtumAgent {
val dispatcher: Dispatcher

/**
* Returns a user under whose security context the JVM is running.
* Its purpose is for auditing in author/createdBy fields.
* The user used for auditing in author/createdBy fields.
*
* Important: It's not supposed to be used for authorization as it can be spoofed!
* It is resolved once per agent (on first access) and then cached for the agent's lifetime:
* - if the `atum.author` configuration key is set (e.g. in `application.conf` or via the
* `-Datum.author=...` JVM system property), that value is used - this is useful when the JVM
* user is a generic system account (e.g. `yarn`) and the application name is more meaningful;
* - otherwise it falls back to the user under whose security context the JVM is running
* (`System.getProperty("user.name")`), which is platform independent.
*
* Overriding this method replaces the resolution strategy entirely (see `AtumAgent.fromConfig`).
*
* @return Current user.
* Important: It's not supposed to be used for authorization as it can be spoofed!
*/
private[agent] def currentUser: String = System.getProperty("user.name") // platform independent
private[agent] def currentUser: String = resolvedCurrentUser

// Cached, resolved-once default author identity. Ensures that agents which do not override
// `currentUser` keep a stable audit identity for their whole lifetime, even if the Typesafe
// Config caches are later invalidated or backing system properties change. The actual
// configuration loading is owned by the companion object (see `AtumAgent.defaultAuthor`).
private[this] lazy val resolvedCurrentUser: String = AtumAgent.defaultAuthor

/**
* Sends `CheckpointDTO` to the AtumService API
Expand Down Expand Up @@ -139,6 +151,9 @@ object AtumAgent extends AtumAgent {

override val dispatcher: Dispatcher = dispatcherFromConfig()

// `currentUser` is intentionally not overridden here: the trait default already delegates to
// `defaultAuthor` (below) and caches the result once, which is exactly what the singleton needs.

private[agent] def dispatcherFromConfig(config: Config = ConfigFactory.load()): Dispatcher = {
config.getString("atum.dispatcher.type") match {
case "http" => new HttpDispatcher(config)
Expand All @@ -148,7 +163,33 @@ object AtumAgent extends AtumAgent {
}
}

/**
* The default author (createdBy) identity, resolved from the globally loaded configuration.
* Callers cache the result (see the trait's `currentUser`), so this is evaluated once per agent.
*
* @return the default author identity for agents that do not override `currentUser`.
*/
private[agent] def defaultAuthor: String = resolveAuthor(ConfigFactory.load())

/**
* Resolves the author (createdBy) identity used for auditing from the given configuration.
*
* If the optional `atum.author` key is present and non-blank, its (trimmed) value is used;
* otherwise it falls back to `System.getProperty("user.name")`.
*
* @param config configuration to read the optional `atum.author` key from.
* @return the resolved author identity.
*/
private[agent] def resolveAuthor(config: Config): String = {
if (config.hasPath("atum.author") && config.getString("atum.author").trim.nonEmpty) {
config.getString("atum.author").trim
} else {
System.getProperty("user.name") // platform independent
}
}

def fromConfig(config: Config): AtumAgent = new AtumAgent {
override val dispatcher: Dispatcher = dispatcherFromConfig(config)
override val currentUser: String = resolveAuthor(config)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,84 @@ class AtumAgentUnitTests extends AnyFunSuiteLike {
assert(agentA.dispatcher ne agentB.dispatcher)
}

test("currentUser falls back to the JVM user when atum.author is not set") {
val agent = AtumAgent.fromConfig(configOf(Map(
"atum.dispatcher.type" -> "capture",
"atum.dispatcher.capture.capture-limit" -> 10
)))

assert(agent.currentUser == System.getProperty("user.name"))
}

test("currentUser uses atum.author from config when set (trimmed)") {
val agent = AtumAgent.fromConfig(configOf(Map(
"atum.dispatcher.type" -> "capture",
"atum.dispatcher.capture.capture-limit" -> 10,
"atum.author" -> " my-application "
)))

assert(agent.currentUser == "my-application")
}

test("currentUser falls back to the JVM user when atum.author is blank") {
val agent = AtumAgent.fromConfig(configOf(Map(
"atum.dispatcher.type" -> "capture",
"atum.dispatcher.capture.capture-limit" -> 10,
"atum.author" -> " "
)))

assert(agent.currentUser == System.getProperty("user.name"))
}

test("currentUser is resolved independently per config-backed agent") {
val agentA = AtumAgent.fromConfig(configOf(Map(
"atum.dispatcher.type" -> "capture",
"atum.dispatcher.capture.capture-limit" -> 10,
"atum.author" -> "alice"
)))
val agentB = AtumAgent.fromConfig(configOf(Map(
"atum.dispatcher.type" -> "capture",
"atum.dispatcher.capture.capture-limit" -> 10
)))

assert(agentA.currentUser == "alice")
assert(agentB.currentUser == System.getProperty("user.name"))
}

test("currentUser is resolved once and cached for agents that do not override it") {
val originalAuthor = Option(System.getProperty("atum.author"))
try {
System.setProperty("atum.author", "first-app")
ConfigFactory.invalidateCaches()

// a custom agent that does NOT override currentUser -> relies on the cached trait default
val agent = new AtumAgent {
override val dispatcher: CapturingDispatcher =
AtumAgent.dispatcherFromConfig(configOf(Map(
"atum.dispatcher.type" -> "capture",
"atum.dispatcher.capture.capture-limit" -> 10
))).asInstanceOf[CapturingDispatcher]
}

// first access resolves and caches the identity
assert(agent.currentUser == "first-app")

// change the backing system property and invalidate Typesafe caches:
// a recomputing `def` would observe the new value here
System.setProperty("atum.author", "second-app")
ConfigFactory.invalidateCaches()

// the resolved-once value must remain stable for the agent's lifetime
assert(agent.currentUser == "first-app")
} finally {
originalAuthor match {
case Some(value) => System.setProperty("atum.author", value)
case None => System.clearProperty("atum.author")
}
ConfigFactory.invalidateCaches()
}
}

private def configOf(configValues: Map[String, Any]): Config = {
val emptyConfig = ConfigFactory.empty()
configValues.foldLeft(emptyConfig) { case (acc, (configKey, value)) =>
Expand Down
Loading