This is an automated email from the ASF dual-hosted git repository.
diqiu50 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 9cc9084c14 [#13857] fix(server): Mask secrets in --updater-options and
--spark-conf job arguments (#13858)
9cc9084c14 is described below
commit 9cc9084c14629af3f563b35a6482dbd678213430
Author: MaSai <[email protected]>
AuthorDate: Fri Oct 16 21:40:11 2026 +0800
[#13857] fix(server): Mask secrets in --updater-options and --spark-conf
job arguments (#13858)
### What changes were proposed in this pull request?
Mask sensitive-named keys inside the JSON values of two built-in Iceberg
job arguments, on the job get/list/run/cancel response and the job event
path:
- `--updater-options` (credentials such as `password` and OAuth fields)
- `--spark-conf` (Spark catalog passwords such as `*.password`)
The whole JSON value stays one argument. Only sensitive-named keys
inside it are replaced with `******`. Stored runtime JSON and the
plaintext used to run the job are unchanged.
`RunJobPreEvent`, `RunJobEvent`, and `RunJobFailureEvent` carry a masked
copy of `jobConf`. The `updater_options` and `spark_conf` entries in
that copy are masked the same way. The map passed to job execution stays
plaintext.
Out of scope: other argument forms (`--password`, `-D`, `--conf`, shell
`-c`) and map fields (`environments`, `customFields`, `configs`).
### Why are the changes needed?
`builtin-iceberg-update-stats` substitutes `updater_options` and
`spark_conf` from `jobConf` into `runtimeJobTemplate.arguments`. Job
APIs and the run page then return those credentials in clear text. The
same JSON is also present on the run-job events' `jobConf`.
Fix: #13857
### Does this PR introduce _any_ user-facing change?
Yes. Job API responses and job events that include a resolved
`--updater-options` or `--spark-conf` argument now replace
sensitive-named values inside that JSON with `******`. `RunJobPreEvent`,
`RunJobEvent`, and `RunJobFailureEvent` do the same for the
`updater_options` and `spark_conf` entries of `jobConf`. Other arguments
and map fields are unchanged.
### How was this patch tested?
- Unit tests for `--updater-options` and `--spark-conf` JSON arguments:
sensitive keys are masked, non-sensitive keys in the same JSON are kept,
and an empty value stays empty.
- Unit tests for run-job events: the `jobConf` copy masks
`updater_options` and `spark_conf`, and the map passed to job execution
stays plaintext.
- Job get/list response test: a runtime template with those two
arguments returns masked JSON, and the stored runtime JSON stays
plaintext.
---------
Co-authored-by: Cursor <[email protected]>
---
.../connector/HiddenPropertyMaskUtils.java | 133 +++++++++++
.../gravitino/job/JobRuntimeTemplateMaskUtils.java | 214 +++++++++++++++++
.../gravitino/listener/JobEventDispatcher.java | 10 +-
.../gravitino/listener/api/info/JobInfo.java | 24 +-
.../connector/TestHiddenPropertyMaskUtils.java | 43 ++++
.../job/TestJobRuntimeTemplateMaskUtils.java | 264 +++++++++++++++++++++
.../listener/api/event/TestJobEventDispatcher.java | 54 +++++
.../gravitino/listener/api/info/TestJobInfo.java | 49 ++++
.../gravitino/server/web/rest/JobOperations.java | 12 +-
.../server/web/rest/TestJobOperations.java | 53 +++++
10 files changed, 842 insertions(+), 14 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/connector/HiddenPropertyMaskUtils.java
b/core/src/main/java/org/apache/gravitino/connector/HiddenPropertyMaskUtils.java
index d3e1c11c81..8301e73639 100644
---
a/core/src/main/java/org/apache/gravitino/connector/HiddenPropertyMaskUtils.java
+++
b/core/src/main/java/org/apache/gravitino/connector/HiddenPropertyMaskUtils.java
@@ -18,14 +18,25 @@
*/
package org.apache.gravitino.connector;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.secret.SecretPropertyUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Read/write helpers for sensitive and system-managed entity properties.
@@ -56,9 +67,18 @@ import org.apache.gravitino.secret.SecretPropertyUtils;
*/
public final class HiddenPropertyMaskUtils {
+ private static final Logger LOG =
LoggerFactory.getLogger(HiddenPropertyMaskUtils.class);
+
/** Placeholder returned instead of editable hidden / credential / secret
property values. */
public static final String MASKED_VALUE = "******";
+ /**
+ * Mapper for nest-JSON masking. Uses {@link JsonNode} with big-decimal
floats so rewritten JSON
+ * keeps numeric fidelity better than {@code Map}/{@code List} round-trips.
+ */
+ private static final ObjectMapper NEST_JSON_MAPPER =
+
JsonUtils.anyFieldMapper().copy().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
+
private HiddenPropertyMaskUtils() {}
/** Returns true when {@code value} is the read-path masked placeholder. */
@@ -176,4 +196,117 @@ public final class HiddenPropertyMaskUtils {
Map<String, String> properties, PropertiesMetadata metadata) {
return maskHiddenProperties(properties,
classifyHiddenProperties(properties, metadata));
}
+
+ /**
+ * Parses {@code value} as JSON and masks sensitive-named keys in objects
(recursively through
+ * nested objects and arrays). Blank strings and non-JSON plain text
(trimmed value does not start
+ * with '{' or '[') are returned unchanged. Values that look like a JSON
object/array but fail to
+ * parse are replaced with {@link #MASKED_VALUE}. Scalar JSON roots are
returned unchanged.
+ *
+ * <p>Useful when a caller already knows a string is a nested JSON map (for
example a CLI argument
+ * following {@code --spark-conf}).
+ *
+ * @param value JSON string to mask; may be null
+ * @return masked JSON, {@link #MASKED_VALUE} on object/array parse failure,
or the original value
+ * when blank, plain non-JSON, scalar JSON, or null
+ */
+ @Nullable
+ public static String maskSensitiveJsonValue(@Nullable String value) {
+ if (value == null) {
+ return null;
+ }
+ return maskNestedJsonValue(value);
+ }
+
+ /**
+ * Parses {@code value} as JSON and masks sensitive-named keys in objects
(recursively through
+ * nested objects and arrays). Blank / plain non-JSON strings are returned
unchanged. Malformed
+ * object/array-looking JSON is replaced with {@link #MASKED_VALUE}.
+ */
+ private static String maskNestedJsonValue(String value) {
+ // Empty / whitespace-only values carry no secret (common for unresolved
{{spark_conf:-}}).
+ if (StringUtils.isBlank(value)) {
+ return value;
+ }
+ // Bare words (e.g. --options=verbose) are not JSON maps; do not treat
parse failure as secret.
+ String trimmed = value.trim();
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
+ return value;
+ }
+ try {
+ JsonNode root = NEST_JSON_MAPPER.readTree(value);
+ if (!root.isObject() && !root.isArray()) {
+ // Scalar JSON (string/number/boolean/null) — nothing to walk by key
name.
+ return value;
+ }
+ MaskedJson masked = maskJsonNode(root);
+ return masked.changed ?
NEST_JSON_MAPPER.writeValueAsString(masked.value) : value;
+ } catch (Exception e) {
+ // Malformed object/array JSON on a caller-declared nest key — do not
leave a secret blob.
+ // Do not log the value; it may contain credentials.
+ LOG.warn(
+ "Replacing malformed nest JSON with masked placeholder after parse
failure: {}",
+ e.toString());
+ return MASKED_VALUE;
+ }
+ }
+
+ private static MaskedJson maskJsonNode(JsonNode node) {
+ if (node.isObject()) {
+ ObjectNode out = JsonNodeFactory.instance.objectNode();
+ boolean changed = false;
+ Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
+ while (fields.hasNext()) {
+ Map.Entry<String, JsonNode> entry = fields.next();
+ String nestedKey = entry.getKey();
+ JsonNode nestedRaw = entry.getValue();
+ if (nestedRaw.isObject() || nestedRaw.isArray()) {
+ if (SecretPropertyUtils.isSensitivePropertyKey(nestedKey)) {
+ out.put(nestedKey, MASKED_VALUE);
+ changed = true;
+ } else {
+ MaskedJson child = maskJsonNode(nestedRaw);
+ out.set(nestedKey, child.value);
+ changed |= child.changed;
+ }
+ } else {
+ String nestedValue = nestedRaw.isNull() ? "" : nestedRaw.asText();
+ if (SecretPropertyUtils.isSensitivePropertyKey(nestedKey)
+ || SecretPropertyUtils.isSecretProperty(nestedKey, nestedValue))
{
+ out.put(nestedKey, MASKED_VALUE);
+ changed = true;
+ } else {
+ out.set(nestedKey, nestedRaw);
+ }
+ }
+ }
+ return new MaskedJson(out, changed);
+ }
+ if (node.isArray()) {
+ ArrayNode out = JsonNodeFactory.instance.arrayNode();
+ boolean changed = false;
+ for (JsonNode item : node) {
+ if (item.isObject() || item.isArray()) {
+ MaskedJson child = maskJsonNode(item);
+ out.add(child.value);
+ changed |= child.changed;
+ } else {
+ out.add(item);
+ }
+ }
+ return new MaskedJson(out, changed);
+ }
+ return new MaskedJson(node, false);
+ }
+
+ /** Result of walking a JSON tree for sensitive-key masking. */
+ private static final class MaskedJson {
+ private final JsonNode value;
+ private final boolean changed;
+
+ private MaskedJson(JsonNode value, boolean changed) {
+ this.value = value;
+ this.changed = changed;
+ }
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/job/JobRuntimeTemplateMaskUtils.java
b/core/src/main/java/org/apache/gravitino/job/JobRuntimeTemplateMaskUtils.java
new file mode 100644
index 0000000000..71ae5dcfed
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/job/JobRuntimeTemplateMaskUtils.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job;
+
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.gravitino.connector.HiddenPropertyMaskUtils;
+import org.apache.gravitino.dto.job.JobTemplateDTO;
+import org.apache.gravitino.dto.job.ShellJobTemplateDTO;
+import org.apache.gravitino.dto.job.SparkJobTemplateDTO;
+
+/**
+ * Display-masks credentials in a Spark job's {@code updater_options} and
{@code spark_conf} JSON
+ * before that JSON is returned by an API or placed on an event.
Sensitive-named keys inside the
+ * JSON are replaced with {@link HiddenPropertyMaskUtils#MASKED_VALUE}. This
is not encryption: the
+ * stored runtime template and the map passed to job execution stay plaintext.
+ *
+ * <p>Only those two JSON bags are masked.
+ *
+ * <ul>
+ * <li>Spark template arguments {@code --updater-options} and {@code
--spark-conf}. One list
+ * element is one argument, so the JSON is the whole next element (or
the text after {@code =}
+ * in {@code --flag=json}). It is not split on whitespace.
+ * <li>{@code jobConf} entries {@code updater_options} and {@code
spark_conf} on the run-job
+ * events. Those names are the Spark template placeholders {@code
{{updater_options}}} and
+ * {@code {{spark_conf}}}, not the hyphenated CLI flags.
+ * </ul>
+ *
+ * <p>Shell jobs have neither bag. Other arguments ({@code --password}, {@code
--conf}, {@code -c},
+ * {@code --options}) and map fields ({@code environments}, {@code
customFields}, Spark {@code
+ * configs}) are copied unchanged. A blank value, which is what {@code
{{spark_conf:-}}} becomes
+ * when unset, is left unchanged.
+ */
+public final class JobRuntimeTemplateMaskUtils {
+
+ /**
+ * Argument flags whose following value is a JSON map that may contain
credentials.
+ *
+ * <ul>
+ * <li>{@code --updater-options} — Gravitino / updater credentials
+ * <li>{@code --spark-conf} — Spark configs, including catalog passwords
+ * </ul>
+ */
+ static final Set<String> JSON_ARGUMENT_FLAGS =
+ ImmutableSet.of("--updater-options", "--spark-conf");
+
+ /**
+ * {@code jobConf} keys for the Spark placeholders {@code
{{updater_options}}} and {@code
+ * {{spark_conf}}}. The CLI flags in {@link #JSON_ARGUMENT_FLAGS} use
hyphens and are a different
+ * spelling of the same two JSON bags.
+ */
+ static final Set<String> JSON_JOB_CONF_KEYS =
ImmutableSet.of("updater_options", "spark_conf");
+
+ private JobRuntimeTemplateMaskUtils() {}
+
+ /**
+ * Returns a copy of {@code jobConf} for run-job events. Only the Spark
placeholder values {@code
+ * updater_options} and {@code spark_conf} are masked. A top-level key such
as {@code password} is
+ * copied unchanged, because this method does not scan every entry. Does not
mutate the input;
+ * callers must pass the original map to job execution.
+ *
+ * @param jobConf runtime configuration from the runJob caller; may be null
+ * @return a masked copy, or null when {@code jobConf} is null
+ */
+ @Nullable
+ public static Map<String, String> maskSensitiveJobConf(@Nullable Map<String,
String> jobConf) {
+ if (jobConf == null) {
+ return null;
+ }
+ Map<String, String> masked = new LinkedHashMap<>();
+ for (Map.Entry<String, String> entry : jobConf.entrySet()) {
+ String key = entry.getKey();
+ String value = entry.getValue();
+ if (key != null && JSON_JOB_CONF_KEYS.contains(key)) {
+ masked.put(key, HiddenPropertyMaskUtils.maskSensitiveJsonValue(value));
+ } else {
+ masked.put(key, value);
+ }
+ }
+ return masked;
+ }
+
+ /**
+ * Returns a copy of a Spark runtime template with {@code --updater-options}
and {@code
+ * --spark-conf} JSON masked, or {@code null} when the input is null. Spark
{@code configs},
+ * {@code environments}, and {@code customFields} are not masked. A Shell
template is rebuilt with
+ * the same argument walk so a hand-written template that uses these flags
does not leak; built-in
+ * credential JSON lives on the Spark template.
+ *
+ * <p>The DTO is rebuilt field by field (there is no DTO copy helper). When
adding a field to
+ * {@link SparkJobTemplateDTO} or {@link ShellJobTemplateDTO}, update this
method and the
+ * field-carry-over tests in {@code TestJobRuntimeTemplateMaskUtils}.
+ *
+ * @param jobTemplateDTO the resolved runtime job template DTO
+ * @return a masked copy, or null
+ */
+ @Nullable
+ public static JobTemplateDTO maskSensitiveFields(@Nullable JobTemplateDTO
jobTemplateDTO) {
+ if (jobTemplateDTO == null) {
+ return null;
+ }
+ List<String> maskedArguments =
maskSensitiveArguments(jobTemplateDTO.arguments());
+ if (jobTemplateDTO instanceof ShellJobTemplateDTO) {
+ ShellJobTemplateDTO shell = (ShellJobTemplateDTO) jobTemplateDTO;
+ return ShellJobTemplateDTO.builder()
+ .withName(shell.name())
+ .withComment(shell.comment())
+ .withJobType(shell.jobType())
+ .withExecutable(shell.executable())
+ .withArguments(maskedArguments)
+ .withEnvironments(shell.environments())
+ .withCustomFields(shell.customFields())
+ .withScripts(shell.scripts())
+ .withAudit(shell.audit())
+ .build();
+ }
+ if (jobTemplateDTO instanceof SparkJobTemplateDTO) {
+ SparkJobTemplateDTO spark = (SparkJobTemplateDTO) jobTemplateDTO;
+ return SparkJobTemplateDTO.builder()
+ .withName(spark.name())
+ .withComment(spark.comment())
+ .withJobType(spark.jobType())
+ .withExecutable(spark.executable())
+ .withArguments(maskedArguments)
+ .withEnvironments(spark.environments())
+ .withCustomFields(spark.customFields())
+ .withClassName(spark.className())
+ .withJars(spark.jars())
+ .withFiles(spark.files())
+ .withArchives(spark.archives())
+ .withConfigs(spark.configs())
+ .withAudit(spark.audit())
+ .build();
+ }
+ throw new IllegalArgumentException(
+ "Unsupported job template type: " + jobTemplateDTO.jobType());
+ }
+
+ /**
+ * Masks the JSON value of {@link #JSON_ARGUMENT_FLAGS}. The value is one
list element, including
+ * any spaces inside the JSON. Other arguments are copied unchanged.
+ */
+ @Nullable
+ private static List<String> maskSensitiveArguments(@Nullable List<String>
arguments) {
+ if (arguments == null) {
+ return null;
+ }
+ if (arguments.isEmpty()) {
+ return arguments;
+ }
+ List<String> result = new ArrayList<>(arguments.size());
+ boolean maskNextJson = false;
+ for (String arg : arguments) {
+ if (maskNextJson) {
+ maskNextJson = false;
+ if (arg != null && !arg.startsWith("--")) {
+ result.add(HiddenPropertyMaskUtils.maskSensitiveJsonValue(arg));
+ continue;
+ }
+ // Missing value for the previous flag; this element may itself be a
flag.
+ }
+ String equalsForm = maskEqualsForm(arg);
+ if (equalsForm != null) {
+ result.add(equalsForm);
+ continue;
+ }
+ result.add(arg);
+ if (arg != null && JSON_ARGUMENT_FLAGS.contains(arg)) {
+ maskNextJson = true;
+ }
+ }
+ return result;
+ }
+
+ /**
+ * @return the argument with its JSON payload masked, or {@code null} when
{@code arg} is not
+ * {@code --flag=json} for {@link #JSON_ARGUMENT_FLAGS}
+ */
+ @Nullable
+ private static String maskEqualsForm(@Nullable String arg) {
+ if (arg == null) {
+ return null;
+ }
+ for (String flag : JSON_ARGUMENT_FLAGS) {
+ String prefix = flag + "=";
+ if (arg.startsWith(prefix)) {
+ return prefix
+ +
HiddenPropertyMaskUtils.maskSensitiveJsonValue(arg.substring(prefix.length()));
+ }
+ }
+ return null;
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/JobEventDispatcher.java
b/core/src/main/java/org/apache/gravitino/listener/JobEventDispatcher.java
index f8f4896fcd..4a54b583ab 100644
--- a/core/src/main/java/org/apache/gravitino/listener/JobEventDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/listener/JobEventDispatcher.java
@@ -27,6 +27,7 @@ import
org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
import org.apache.gravitino.exceptions.NoSuchJobException;
import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
import org.apache.gravitino.job.JobOperationDispatcher;
+import org.apache.gravitino.job.JobRuntimeTemplateMaskUtils;
import org.apache.gravitino.job.JobTemplateChange;
import org.apache.gravitino.listener.api.event.job.AlterJobTemplateEvent;
import
org.apache.gravitino.listener.api.event.job.AlterJobTemplateFailureEvent;
@@ -224,9 +225,12 @@ public class JobEventDispatcher implements
JobOperationDispatcher {
@Override
public JobEntity runJob(String metalake, String jobTemplateName, Map<String,
String> jobConf)
throws NoSuchJobTemplateException {
+ // Events carry a masked copy of the Spark updater_options / spark_conf
JSON. Execution below
+ // still uses the caller's plaintext jobConf.
+ Map<String, String> maskedJobConf =
JobRuntimeTemplateMaskUtils.maskSensitiveJobConf(jobConf);
eventBus.dispatchEvent(
new RunJobPreEvent(
- PrincipalUtils.getCurrentUserName(), metalake, jobTemplateName,
jobConf));
+ PrincipalUtils.getCurrentUserName(), metalake, jobTemplateName,
maskedJobConf));
try {
JobEntity job = jobOperationDispatcher.runJob(metalake, jobTemplateName,
jobConf);
@@ -235,13 +239,13 @@ public class JobEventDispatcher implements
JobOperationDispatcher {
PrincipalUtils.getCurrentUserName(),
metalake,
jobTemplateName,
- jobConf,
+ maskedJobConf,
JobInfo.fromJobEntity(job)));
return job;
} catch (Exception e) {
eventBus.dispatchEvent(
new RunJobFailureEvent(
- PrincipalUtils.getCurrentUserName(), metalake, jobTemplateName,
jobConf, e));
+ PrincipalUtils.getCurrentUserName(), metalake, jobTemplateName,
maskedJobConf, e));
throw e;
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
b/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
index 5771324269..4d489b99fe 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
@@ -26,6 +26,7 @@ import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.dto.job.JobTemplateDTO;
import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.job.JobRuntimeTemplateMaskUtils;
import org.apache.gravitino.job.JobTemplate;
import org.apache.gravitino.meta.JobEntity;
import org.slf4j.Logger;
@@ -89,18 +90,22 @@ public final class JobInfo {
/**
* Deserializes the job entity's stored runtime job template JSON, if any,
back into a {@link
- * JobTemplate}. This is called from inside the event dispatcher's try block
for getJob/runJob/
- * cancelJob, after the underlying operation has already succeeded (or, for
cancelJob, after the
- * external cancel call and entity update have already happened) - a
malformed or
- * forward-incompatible stored value (e.g. a job type unknown to this server
version) must not
- * turn that already-completed operation into a 500, so failures here are
logged and swallowed
- * rather than propagated.
+ * JobTemplate}. For a Spark runtime template, sensitive-named keys inside
{@code
+ * --updater-options} and {@code --spark-conf} JSON arguments are masked via
{@link
+ * JobRuntimeTemplateMaskUtils} before the template is exposed on the event
path. This does not
+ * mask the separate {@code jobConf} map on run-job events. This is called
from inside the event
+ * dispatcher's try block for getJob/runJob/cancelJob, after the underlying
operation has already
+ * succeeded (or, for cancelJob, after the external cancel call and entity
update have already
+ * happened) - a malformed or forward-incompatible stored value (e.g. a job
type unknown to this
+ * server version) must not turn that already-completed operation into a
500, so failures here are
+ * logged and swallowed rather than propagated.
*/
private static JobTemplate toRuntimeJobTemplate(JobEntity jobEntity) {
try {
JobTemplateDTO runtimeJobTemplateDTO =
- DTOConverters.fromRuntimeJobTemplateJson(
- jobEntity.runtimeJobTemplate(), jobEntity.name());
+ JobRuntimeTemplateMaskUtils.maskSensitiveFields(
+ DTOConverters.fromRuntimeJobTemplateJson(
+ jobEntity.runtimeJobTemplate(), jobEntity.name()));
return runtimeJobTemplateDTO == null ? null :
DTOConverters.fromDTO(runtimeJobTemplateDTO);
} catch (Exception e) {
LOG.warn(
@@ -183,7 +188,8 @@ public final class JobInfo {
/**
* Returns the resolved job template that was actually submitted for
execution, with placeholders
- * replaced. Its executable, scripts, jars, files and archives are the URIs
from the template.
+ * replaced. For a Spark template, sensitive-named keys inside {@code
--updater-options} and
+ * {@code --spark-conf} JSON arguments are masked for the event path.
*
* @return the runtime job template, or null for jobs run before this field
was introduced
*/
diff --git
a/core/src/test/java/org/apache/gravitino/connector/TestHiddenPropertyMaskUtils.java
b/core/src/test/java/org/apache/gravitino/connector/TestHiddenPropertyMaskUtils.java
index 913d9ef358..c646e47fc1 100644
---
a/core/src/test/java/org/apache/gravitino/connector/TestHiddenPropertyMaskUtils.java
+++
b/core/src/test/java/org/apache/gravitino/connector/TestHiddenPropertyMaskUtils.java
@@ -18,12 +18,15 @@
*/
package org.apache.gravitino.connector;
+import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
+import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.gravitino.Config;
import org.apache.gravitino.Configs;
+import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.secret.SecretPropertyUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@@ -203,4 +206,44 @@ public class TestHiddenPropertyMaskUtils {
Assertions.assertEquals("root", masked.get("jdbc-user"));
Assertions.assertEquals(HiddenPropertyMaskUtils.MASKED_VALUE,
masked.get("jdbc-passwrod"));
}
+
+ @Test
+ void testMaskSensitiveJsonValueLeavesPlainNonJsonUnchanged() {
+ Assertions.assertEquals("verbose",
HiddenPropertyMaskUtils.maskSensitiveJsonValue("verbose"));
+ Assertions.assertEquals("5",
HiddenPropertyMaskUtils.maskSensitiveJsonValue("5"));
+ Assertions.assertEquals(
+ HiddenPropertyMaskUtils.MASKED_VALUE,
+
HiddenPropertyMaskUtils.maskSensitiveJsonValue("{not-json,\"password\":\"x\"}"));
+ }
+
+ @Test
+ void testMaskSensitiveJsonValuePreservesNumericLiteralWhenMaskingSibling()
throws Exception {
+ String masked =
+ HiddenPropertyMaskUtils.maskSensitiveJsonValue(
+ "{\"count\":1e2,\"password\":\"pw\",\"flag\":true}");
+ Assertions.assertFalse(masked.contains("\"pw\""));
+
Assertions.assertTrue(masked.contains(HiddenPropertyMaskUtils.MASKED_VALUE));
+ Assertions.assertTrue(masked.contains("true"));
+ // JsonNode + USE_BIG_DECIMAL_FOR_FLOATS preserves the numeric value (not
a leaked secret).
+ JsonNode root = JsonUtils.anyFieldMapper().readTree(masked);
+ Assertions.assertEquals(0, root.get("count").decimalValue().compareTo(new
BigDecimal("100")));
+ }
+
+ @Test
+ void testMaskSensitiveJsonValueMasksSensitiveKeys() {
+ String masked =
+ HiddenPropertyMaskUtils.maskSensitiveJsonValue(
+
"{\"gravitino_uri\":\"http://localhost\",\"password\":\"pw\",\"oauth_token\":\"tok\"}");
+ Assertions.assertTrue(masked.contains("http://localhost"));
+ Assertions.assertFalse(masked.contains("\"pw\""));
+ Assertions.assertFalse(masked.contains("\"tok\""));
+
Assertions.assertTrue(masked.contains(HiddenPropertyMaskUtils.MASKED_VALUE));
+ }
+
+ @Test
+ void testMaskSensitiveJsonValueLeavesBlankUnchanged() {
+ Assertions.assertEquals("",
HiddenPropertyMaskUtils.maskSensitiveJsonValue(""));
+ Assertions.assertEquals(" ",
HiddenPropertyMaskUtils.maskSensitiveJsonValue(" "));
+
Assertions.assertNull(HiddenPropertyMaskUtils.maskSensitiveJsonValue(null));
+ }
}
diff --git
a/core/src/test/java/org/apache/gravitino/job/TestJobRuntimeTemplateMaskUtils.java
b/core/src/test/java/org/apache/gravitino/job/TestJobRuntimeTemplateMaskUtils.java
new file mode 100644
index 0000000000..9a4bd1f396
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/job/TestJobRuntimeTemplateMaskUtils.java
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.connector.HiddenPropertyMaskUtils;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.job.ShellJobTemplateDTO;
+import org.apache.gravitino.dto.job.SparkJobTemplateDTO;
+import org.apache.gravitino.job.JobTemplate.JobType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestJobRuntimeTemplateMaskUtils {
+
+ @Test
+ void testMaskSensitiveFieldsMasksUpdaterOptionsAndSparkConf() {
+ SparkJobTemplateDTO input =
+ SparkJobTemplateDTO.builder()
+ .withName("spark_job")
+ .withComment("c")
+ .withJobType(JobType.SPARK)
+ .withExecutable("local:///app.jar")
+ .withArguments(
+ ImmutableList.of(
+ "--catalog",
+ "ice",
+ "--updater-options",
+
"{\"gravitino_uri\":\"http://localhost\",\"password\":\"arg-pw\","
+ + "\"oauth_token\":\"tok\"}",
+ "--spark-conf",
+ "{\"spark.executor.memory\":\"1g\","
+ +
"\"spark.sql.catalog.ice.rest.auth.basic.password\":\"jdbc-pw\"}",
+ "--table",
+ "db.t"))
+ .withEnvironments(ImmutableMap.of("AUTH_PASSWORD", "env-secret",
"CATALOG", "ice"))
+ .withClassName("org.example.App")
+ .withConfigs(ImmutableMap.of("spark.hadoop.fs.s3a.secret.key",
"s3-secret"))
+ .build();
+
+ SparkJobTemplateDTO spark =
+ (SparkJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+ List<String> args = spark.arguments();
+ Assertions.assertEquals("--updater-options", args.get(2));
+ Assertions.assertFalse(args.get(3).contains("arg-pw"));
+ Assertions.assertFalse(args.get(3).contains("\"tok\""));
+ Assertions.assertTrue(args.get(3).contains("http://localhost"));
+
Assertions.assertTrue(args.get(3).contains(HiddenPropertyMaskUtils.MASKED_VALUE));
+ Assertions.assertEquals("--spark-conf", args.get(4));
+ Assertions.assertFalse(args.get(5).contains("jdbc-pw"));
+ Assertions.assertTrue(args.get(5).contains("1g"));
+ Assertions.assertEquals("--table", args.get(6));
+ Assertions.assertEquals("db.t", args.get(7));
+ // Map fields are outside this fix.
+ Assertions.assertEquals("env-secret",
spark.environments().get("AUTH_PASSWORD"));
+ Assertions.assertEquals("s3-secret",
spark.configs().get("spark.hadoop.fs.s3a.secret.key"));
+ }
+
+ @Test
+ void testMaskSensitiveArgumentsLeavesEmptyTokenUnchanged() {
+ SparkJobTemplateDTO input =
+ sparkWithArguments(ImmutableList.of("--spark-conf", "",
"--updater-options", " "));
+
+ SparkJobTemplateDTO spark =
+ (SparkJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+ Assertions.assertEquals(
+ ImmutableList.of("--spark-conf", "", "--updater-options", " "),
spark.arguments());
+ }
+
+ @Test
+ void testMaskSensitiveArgumentsSkipsMissingValue() {
+ SparkJobTemplateDTO input =
+ sparkWithArguments(ImmutableList.of("--spark-conf", "--catalog",
"ice"));
+
+ SparkJobTemplateDTO spark =
+ (SparkJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+ Assertions.assertEquals(
+ ImmutableList.of("--spark-conf", "--catalog", "ice"),
spark.arguments());
+ }
+
+ @Test
+ void testMaskSensitiveArgumentsMasksEqualsForm() {
+ SparkJobTemplateDTO input =
+ sparkWithArguments(
+ ImmutableList.of(
+
"--spark-conf={\"jdbc.password\":\"arg-jdbc\",\"spark.executor.memory\":\"1g\"}",
+ "--updater-options="));
+
+ SparkJobTemplateDTO spark =
+ (SparkJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+ List<String> args = spark.arguments();
+ Assertions.assertTrue(args.get(0).startsWith("--spark-conf="));
+ Assertions.assertFalse(args.get(0).contains("arg-jdbc"));
+ Assertions.assertTrue(args.get(0).contains("1g"));
+ Assertions.assertEquals("--updater-options=", args.get(1));
+ }
+
+ @Test
+ void testMaskSensitiveArgumentsLeavesOtherFlagsUnchanged() {
+ String where = "account_id = 5 and dt = '2024-01-01'";
+ String command = "export db_password=pd && run.sh";
+ ShellJobTemplateDTO input =
+ ShellJobTemplateDTO.builder()
+ .withName("shell_job")
+ .withJobType(JobType.SHELL)
+ .withExecutable("/bin/sh")
+ .withArguments(
+ ImmutableList.of(
+ "-c",
+ command,
+ "--password",
+ "my secret pass",
+ "--conf",
+ "spark.jdbc.password=my pass",
+ "--where",
+ where))
+ .build();
+
+ ShellJobTemplateDTO shell =
+ (ShellJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+ Assertions.assertEquals(
+ ImmutableList.of(
+ "-c",
+ command,
+ "--password",
+ "my secret pass",
+ "--conf",
+ "spark.jdbc.password=my pass",
+ "--where",
+ where),
+ shell.arguments());
+ }
+
+ @Test
+ void testMaskSensitiveFieldsNull() {
+
Assertions.assertNull(JobRuntimeTemplateMaskUtils.maskSensitiveFields(null));
+ }
+
+ @Test
+ void testMaskSensitiveFieldsCarriesAllSparkDtoFields() {
+ AuditDTO audit = AuditDTO.builder().withCreator("alice").build();
+ SparkJobTemplateDTO input =
+ SparkJobTemplateDTO.builder()
+ .withName("spark_job")
+ .withComment("keep-comment")
+ .withJobType(JobType.SPARK)
+ .withExecutable("local:///app.jar")
+ .withArguments(
+ ImmutableList.of("--updater-options", "{\"password\":\"pw\"}",
"--table", "db.t"))
+ .withEnvironments(ImmutableMap.of("K", "v"))
+ .withCustomFields(ImmutableMap.of("cf", "1"))
+ .withClassName("org.example.App")
+ .withJars(ImmutableList.of("a.jar"))
+ .withFiles(ImmutableList.of("f.txt"))
+ .withArchives(ImmutableList.of("a.zip"))
+ .withConfigs(ImmutableMap.of("spark.executor.memory", "1g"))
+ .withAudit(audit)
+ .build();
+
+ SparkJobTemplateDTO spark =
+ (SparkJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+
+ Assertions.assertEquals(input.name(), spark.name());
+ Assertions.assertEquals(input.comment(), spark.comment());
+ Assertions.assertEquals(input.jobType(), spark.jobType());
+ Assertions.assertEquals(input.executable(), spark.executable());
+ Assertions.assertEquals(input.environments(), spark.environments());
+ Assertions.assertEquals(input.customFields(), spark.customFields());
+ Assertions.assertEquals(input.className(), spark.className());
+ Assertions.assertEquals(input.jars(), spark.jars());
+ Assertions.assertEquals(input.files(), spark.files());
+ Assertions.assertEquals(input.archives(), spark.archives());
+ Assertions.assertEquals(input.configs(), spark.configs());
+ Assertions.assertEquals(input.audit(), spark.audit());
+ Assertions.assertNotEquals(input.arguments(), spark.arguments());
+ Assertions.assertFalse(spark.arguments().get(1).contains("pw"));
+ }
+
+ @Test
+ void testMaskSensitiveFieldsCarriesAllShellDtoFields() {
+ AuditDTO audit = AuditDTO.builder().withCreator("bob").build();
+ ShellJobTemplateDTO input =
+ ShellJobTemplateDTO.builder()
+ .withName("shell_job")
+ .withComment("shell-comment")
+ .withJobType(JobType.SHELL)
+ .withExecutable("/bin/sh")
+ .withArguments(ImmutableList.of("-c", "echo ok"))
+ .withEnvironments(ImmutableMap.of("HOME", "/tmp"))
+ .withCustomFields(ImmutableMap.of("owner", "tms"))
+ .withScripts(ImmutableList.of("a.sh"))
+ .withAudit(audit)
+ .build();
+
+ ShellJobTemplateDTO shell =
+ (ShellJobTemplateDTO)
JobRuntimeTemplateMaskUtils.maskSensitiveFields(input);
+
+ Assertions.assertEquals(input.name(), shell.name());
+ Assertions.assertEquals(input.comment(), shell.comment());
+ Assertions.assertEquals(input.jobType(), shell.jobType());
+ Assertions.assertEquals(input.executable(), shell.executable());
+ Assertions.assertEquals(input.arguments(), shell.arguments());
+ Assertions.assertEquals(input.environments(), shell.environments());
+ Assertions.assertEquals(input.customFields(), shell.customFields());
+ Assertions.assertEquals(input.scripts(), shell.scripts());
+ Assertions.assertEquals(input.audit(), shell.audit());
+ }
+
+ @Test
+ void testMaskSensitiveJobConfMasksUpdaterOptionsAndSparkConf() {
+ Map<String, String> jobConf = new LinkedHashMap<>();
+ jobConf.put("catalog_name", "ice");
+ jobConf.put(
+ "updater_options",
+
"{\"gravitino_uri\":\"http://localhost\",\"password\":\"arg-pw\",\"oauth_token\":\"tok\"}");
+ jobConf.put(
+ "spark_conf",
+ "{\"spark.executor.memory\":\"1g\","
+ +
"\"spark.sql.catalog.ice.rest.auth.basic.password\":\"jdbc-pw\"}");
+ jobConf.put("AUTH_PASSWORD", "env-secret");
+
+ Map<String, String> masked =
JobRuntimeTemplateMaskUtils.maskSensitiveJobConf(jobConf);
+
+ Assertions.assertEquals("ice", masked.get("catalog_name"));
+ Assertions.assertEquals("env-secret", masked.get("AUTH_PASSWORD"));
+ Assertions.assertFalse(masked.get("updater_options").contains("arg-pw"));
+ Assertions.assertFalse(masked.get("updater_options").contains("\"tok\""));
+
Assertions.assertTrue(masked.get("updater_options").contains("http://localhost"));
+ Assertions.assertFalse(masked.get("spark_conf").contains("jdbc-pw"));
+ Assertions.assertTrue(masked.get("spark_conf").contains("1g"));
+ Assertions.assertTrue(jobConf.get("updater_options").contains("arg-pw"));
+
Assertions.assertNull(JobRuntimeTemplateMaskUtils.maskSensitiveJobConf(null));
+ }
+
+ private static SparkJobTemplateDTO sparkWithArguments(List<String>
arguments) {
+ return SparkJobTemplateDTO.builder()
+ .withName("spark_job")
+ .withJobType(JobType.SPARK)
+ .withExecutable("local:///app.jar")
+ .withArguments(arguments)
+ .withClassName("org.example.App")
+ .build();
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
index e43b85dba6..11029484d7 100644
---
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
@@ -21,9 +21,12 @@ package org.apache.gravitino.listener.api.event;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.google.common.collect.ImmutableMap;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
@@ -466,6 +469,57 @@ public class TestJobEventDispatcher {
Assertions.assertEquals(jobConf, eventJobConf);
}
+ @Test
+ void testRunJobEventMasksSensitiveJobConf() throws
NoSuchJobTemplateException {
+ DummyEventListener listener = new DummyEventListener();
+ EventBus eventBus = new EventBus(Collections.singletonList(listener));
+ JobOperationDispatcher delegate = mock(JobOperationDispatcher.class);
+ when(delegate.runJob(any(String.class), any(String.class), any(Map.class)))
+ .thenReturn(jobEntity);
+ JobEventDispatcher maskingDispatcher = new JobEventDispatcher(eventBus,
delegate);
+ Map<String, String> conf =
+ ImmutableMap.of(
+ "catalog_name",
+ "ice",
+ "updater_options",
+ "{\"password\":\"arg-pw\",\"gravitino_uri\":\"http://localhost\"}",
+ "spark_conf",
+
"{\"spark.sql.catalog.ice.rest.auth.basic.password\":\"jdbc-pw\"}");
+
+ String templateName = jobTemplateEntity.name();
+ maskingDispatcher.runJob("metalake", templateName, conf);
+
+ verify(delegate).runJob(eq("metalake"), eq(templateName), eq(conf));
+ Map<String, String> preJobConf = ((RunJobPreEvent)
listener.popPreEvent()).jobConf();
+ Map<String, String> postJobConf = ((RunJobEvent)
listener.popPostEvent()).jobConf();
+ Assertions.assertEquals("ice", preJobConf.get("catalog_name"));
+
Assertions.assertFalse(preJobConf.get("updater_options").contains("arg-pw"));
+ Assertions.assertFalse(preJobConf.get("spark_conf").contains("jdbc-pw"));
+
Assertions.assertFalse(postJobConf.get("updater_options").contains("arg-pw"));
+ Assertions.assertTrue(conf.get("updater_options").contains("arg-pw"));
+ }
+
+ @Test
+ void testRunJobFailureEventMasksSensitiveJobConf() {
+ DummyEventListener listener = new DummyEventListener();
+ EventBus eventBus = new EventBus(Collections.singletonList(listener));
+ JobOperationDispatcher delegate = mock(JobOperationDispatcher.class);
+ when(delegate.runJob(any(String.class), any(String.class), any(Map.class)))
+ .thenThrow(new GravitinoRuntimeException("failed"));
+ JobEventDispatcher maskingDispatcher = new JobEventDispatcher(eventBus,
delegate);
+ Map<String, String> conf =
+ ImmutableMap.of("updater_options", "{\"password\":\"arg-pw\"}",
"catalog_name", "ice");
+
+ Assertions.assertThrowsExactly(
+ GravitinoRuntimeException.class,
+ () -> maskingDispatcher.runJob("metalake", jobTemplateEntity.name(),
conf));
+
+ Map<String, String> eventJobConf = ((RunJobFailureEvent)
listener.popPostEvent()).jobConf();
+ Assertions.assertEquals("ice", eventJobConf.get("catalog_name"));
+
Assertions.assertFalse(eventJobConf.get("updater_options").contains("arg-pw"));
+ Assertions.assertTrue(conf.get("updater_options").contains("arg-pw"));
+ }
+
@Test
void testCancelJobFailureEvent() {
Assertions.assertThrowsExactly(
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
b/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
index ef16fe4de9..18ab884695 100644
--- a/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
+++ b/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
@@ -21,10 +21,12 @@ package org.apache.gravitino.listener.api.info;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import java.time.Instant;
+import org.apache.gravitino.connector.HiddenPropertyMaskUtils;
import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.job.JobHandle;
import org.apache.gravitino.job.JobTemplate;
import org.apache.gravitino.job.ShellJobTemplate;
+import org.apache.gravitino.job.SparkJobTemplate;
import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.JobEntity;
@@ -140,6 +142,53 @@ public class TestJobInfo {
Assertions.assertEquals(resolvedTemplate, jobInfo.runtimeJobTemplate());
}
+ @Test
+ public void testFromJobEntityMasksSensitiveRuntimeJobTemplateFields() throws
Exception {
+ JobTemplate resolvedTemplate =
+ SparkJobTemplate.builder()
+ .withName("test-job-template")
+ .withComment("resolved")
+ .withExecutable("local:///app.jar")
+ .withClassName("org.example.App")
+ .withArguments(
+ Lists.newArrayList(
+ "--updater-options",
+
"{\"gravitino_uri\":\"http://localhost\",\"password\":\"nested-pw\"}",
+ "--spark-conf",
+
"{\"spark.executor.memory\":\"1g\",\"jdbc.password\":\"jdbc-pw\"}"))
+ .withEnvironments(ImmutableMap.of("AUTH_PASSWORD", "env-secret",
"CATALOG", "iceberg"))
+ .build();
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ String runtimeJobTemplateJson =
+ JsonUtils.anyFieldMapper()
+ .writeValueAsString(
+ DTOConverters.toDTO(resolvedTemplate,
DTOConverters.toDTO(auditInfo)));
+
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(auditInfo)
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .withRuntimeJobTemplate(runtimeJobTemplateJson)
+ .build();
+
+ JobInfo jobInfo = JobInfo.fromJobEntity(jobEntity);
+ JobTemplate masked = jobInfo.runtimeJobTemplate();
+ Assertions.assertNotNull(masked);
+ Assertions.assertEquals("env-secret",
masked.environments().get("AUTH_PASSWORD"));
+ Assertions.assertFalse(masked.arguments().get(1).contains("nested-pw"));
+
Assertions.assertTrue(masked.arguments().get(1).contains(HiddenPropertyMaskUtils.MASKED_VALUE));
+ Assertions.assertFalse(masked.arguments().get(3).contains("jdbc-pw"));
+ Assertions.assertTrue(masked.arguments().get(3).contains("1g"));
+
Assertions.assertTrue(jobEntity.runtimeJobTemplate().contains("nested-pw"));
+ }
+
@Test
public void testFromJobEntityWithMalformedRuntimeJobTemplateDoesNotThrow() {
// fromJobEntity() is called inside JobEventDispatcher's try block for
getJob/runJob/
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
index 4a185ab4f6..d040cf97c5 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
@@ -68,6 +68,7 @@ import org.apache.gravitino.dto.responses.NameListResponse;
import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.job.JobHandle;
import org.apache.gravitino.job.JobOperationDispatcher;
+import org.apache.gravitino.job.JobRuntimeTemplateMaskUtils;
import org.apache.gravitino.job.JobTemplateChange;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.JobEntity;
@@ -560,11 +561,18 @@ public class JobOperations {
* make the job unreadable or uncancellable through the API - failures here
are logged and
* swallowed rather than propagated, so callers of {@link #toDTO(JobEntity)}
(get/run/cancel/list
* job) always get a usable response with just the runtime job template
omitted.
+ *
+ * <p>For a Spark runtime template, sensitive-named keys inside {@code
--updater-options} and
+ * {@code --spark-conf} JSON arguments are masked before the DTO is returned
via {@link
+ * JobRuntimeTemplateMaskUtils}. Other arguments and map fields stay as
stored. This DTO has no
+ * {@code jobConf}; event {@code jobConf} is masked separately in the job
event dispatcher. Stored
+ * plaintext is unchanged.
*/
private static JobTemplateDTO toRuntimeJobTemplateDTO(JobEntity jobEntity) {
try {
- return DTOConverters.fromRuntimeJobTemplateJson(
- jobEntity.runtimeJobTemplate(), jobEntity.name());
+ return JobRuntimeTemplateMaskUtils.maskSensitiveFields(
+ DTOConverters.fromRuntimeJobTemplateJson(
+ jobEntity.runtimeJobTemplate(), jobEntity.name()));
} catch (Exception e) {
LOG.warn(
"Failed to deserialize the runtime job template for job {}, omitting
it from the "
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
index da19b642fa..04f057ab5d 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
@@ -51,10 +51,12 @@ import javax.ws.rs.core.Response;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.connector.HiddenPropertyMaskUtils;
import org.apache.gravitino.dto.job.JobDTO;
import org.apache.gravitino.dto.job.JobTemplateDTO;
import org.apache.gravitino.dto.job.ShellJobTemplateDTO;
import org.apache.gravitino.dto.job.ShellTemplateUpdateDTO;
+import org.apache.gravitino.dto.job.SparkJobTemplateDTO;
import org.apache.gravitino.dto.requests.JobRunRequest;
import org.apache.gravitino.dto.requests.JobTemplateRegisterRequest;
import org.apache.gravitino.dto.requests.JobTemplateUpdateRequest;
@@ -1465,6 +1467,57 @@ public class TestJobOperations extends JerseyTest {
Assertions.assertEquals(Lists.newArrayList("resolved-arg"),
runtimeJobTemplateDTO.arguments());
}
+ @Test
+ public void testToDTORuntimeJobTemplateMasksSensitiveFields() {
+ String runtimeJobTemplateJson =
+ "{"
+ + "\"jobType\":\"spark\","
+ + "\"name\":\"spark_template_1\","
+ + "\"comment\":\"resolved\","
+ + "\"executable\":\"local:///app.jar\","
+ + "\"arguments\":["
+ + "\"--updater-options\","
+ +
"\"{\\\"gravitino_uri\\\":\\\"http://localhost\\\",\\\"password\\\":\\\"nested-pw\\\"}\","
+ + "\"--spark-conf\","
+ +
"\"{\\\"spark.executor.memory\\\":\\\"1g\\\",\\\"jdbc.password\\\":\\\"jdbc-pw\\\"}\""
+ + "],"
+ +
"\"environments\":{\"AUTH_PASSWORD\":\"env-secret\",\"CATALOG\":\"ice\"},"
+ + "\"className\":\"org.example.App\","
+ + "\"configs\":{\"spark.hadoop.fs.s3a.secret.key\":\"s3-secret\"}"
+ + "}";
+ JobEntity jobWithSecrets =
+ JobEntity.builder()
+ .withId(new Random().nextLong())
+ .withJobExecutionId("job-execution-with-secrets")
+ .withNamespace(NamespaceUtil.ofJob(metalake))
+ .withJobTemplateName("spark_template_1")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .withRuntimeJobTemplate(runtimeJobTemplateJson)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .build();
+
+ JobDTO jobDTO = JobOperations.toDTO(jobWithSecrets);
+ Assertions.assertInstanceOf(SparkJobTemplateDTO.class,
jobDTO.runtimeJobTemplate());
+ SparkJobTemplateDTO spark = (SparkJobTemplateDTO)
jobDTO.runtimeJobTemplate();
+
+ Assertions.assertEquals("env-secret",
spark.environments().get("AUTH_PASSWORD"));
+ Assertions.assertEquals("s3-secret",
spark.configs().get("spark.hadoop.fs.s3a.secret.key"));
+ Assertions.assertEquals("--updater-options", spark.arguments().get(0));
+ Assertions.assertFalse(spark.arguments().get(1).contains("nested-pw"));
+
Assertions.assertTrue(spark.arguments().get(1).contains("http://localhost"));
+
Assertions.assertTrue(spark.arguments().get(1).contains(HiddenPropertyMaskUtils.MASKED_VALUE));
+ Assertions.assertEquals("--spark-conf", spark.arguments().get(2));
+ Assertions.assertFalse(spark.arguments().get(3).contains("jdbc-pw"));
+ Assertions.assertTrue(spark.arguments().get(3).contains("1g"));
+
+ // Stored entity JSON must remain unmasked (masking is response-only).
+
Assertions.assertTrue(jobWithSecrets.runtimeJobTemplate().contains("nested-pw"));
+
Assertions.assertTrue(jobWithSecrets.runtimeJobTemplate().contains("jdbc-pw"));
+ }
+
@Test
public void
testListJobsWithMalformedRuntimeJobTemplateDoesNotFailWholeList() {
// A single job whose stored runtime job template fails to deserialize
(e.g. corrupted or