diff --git a/core/src/main/java/org/apache/hop/core/security/HopSecurity.java b/core/src/main/java/org/apache/hop/core/security/HopSecurity.java
index bdf4eadcddb..50e58855e25 100644
--- a/core/src/main/java/org/apache/hop/core/security/HopSecurity.java
+++ b/core/src/main/java/org/apache/hop/core/security/HopSecurity.java
@@ -17,6 +17,8 @@
package org.apache.hop.core.security;
+import java.util.Optional;
+
/**
* Global entry point for authorization checks. Defaults to an unrestricted context (desktop / no
* auth). Hop Web installs a session-aware {@link ISecurityContextProvider} so concurrent users each
@@ -70,6 +72,27 @@ public static HopSecurityContext getContext() {
return context != null ? context : HopSecurityContext.unrestricted();
}
+ /**
+ * The name to record in the audit fields of a file, such as the user who created or last modified
+ * a pipeline or workflow.
+ *
+ *
Only an authenticated context yields a name. The desktop runs unrestricted under the {@link
+ * HopSecurityContext#ANONYMOUS_USERNAME} placeholder, and neither that placeholder nor the
+ * operating system account name says anything about who edited the file, so those cases return an
+ * empty result and leave whatever the file already holds untouched. The same goes for the {@link
+ * HopSecurityPrivilegeMode#STAND_IN_USERNAME} stand-in used when the desktop simulates a role.
+ *
+ * @return the authenticated user name, or empty when no real user is known
+ */
+ public static Optional getAuditUsername() {
+ HopSecurityContext context = getContext();
+ if (!context.isAuthenticated()
+ || HopSecurityPrivilegeMode.STAND_IN_USERNAME.equals(context.getUsername())) {
+ return Optional.empty();
+ }
+ return Optional.of(context.getUsername());
+ }
+
/**
* @param permission permission to check
* @return true if the current context allows it
diff --git a/core/src/main/java/org/apache/hop/core/security/HopSecurityPrivilegeMode.java b/core/src/main/java/org/apache/hop/core/security/HopSecurityPrivilegeMode.java
index 83f50d1aa7b..5fb36744015 100644
--- a/core/src/main/java/org/apache/hop/core/security/HopSecurityPrivilegeMode.java
+++ b/core/src/main/java/org/apache/hop/core/security/HopSecurityPrivilegeMode.java
@@ -32,6 +32,12 @@ public final class HopSecurityPrivilegeMode {
/** Sentinel for “use full base privileges” (not a HopRole id). */
public static final String MODE_FULL = "full";
+ /**
+ * User name given to an effective context when the base context has no real user, such as the
+ * unrestricted desktop simulating a role.
+ */
+ public static final String STAND_IN_USERNAME = "session";
+
private HopSecurityPrivilegeMode() {}
/**
@@ -86,7 +92,7 @@ public static HopSecurityContext createEffective(HopSecurityContext base, HopRol
if (username == null
|| username.isBlank()
|| HopSecurityContext.ANONYMOUS_USERNAME.equals(username)) {
- username = "session";
+ username = STAND_IN_USERNAME;
}
return HopSecurityContext.forUser(username, EnumSet.of(target), base.getSourceRoles());
}
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
index b52c5bdf3e2..fec5d14cf97 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
@@ -177,12 +177,14 @@ The Pipeline tab allows you to specify general properties about the pipeline inc
|Pipeline filename| The filename of the pipeline
|Description|Short description of the pipeline
|Extended description| Long extended description of the pipeline
-|Status| Draft or production status
+|Status| Draft or production status. Deprecated: nothing in Hop acts on this value and it will be removed in a future release.
|Version|Description of the version
-|Created by| Displays the original creator of the pipeline
+|Created by| The user that created the pipeline. Only recorded when Hop knows who that is, such as an authenticated Hop web session, and shows `-` otherwise.
|Created at|Displays the date and time when the pipeline was created.
-|Last modified by| Displays the last user that modified the pipeline
-|Last modified at|Displays the date and time when the pipeline was last modified.
+|Last modified by| The user that last modified the pipeline, recorded under the same conditions as `Created by`.
+|Last modified at|Displays the date and time of the last save that changed the pipeline. A save that does not change anything leaves this untouched.
+|Created with|The version of Hop that created the pipeline. Empty for pipelines created before Hop started recording this.
+|Last modified with|The version of Hop that last saved a change to the pipeline.
|===
The parameters tab allows you to specify parameters specific for the pipeline.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/getsystemdata.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/getsystemdata.adoc
index 7adf9ba6b91..e4a6a885483 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/getsystemdata.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/getsystemdata.adoc
@@ -93,9 +93,13 @@ See also Launching several copies of a transform. | `copy of transform`
|Pipeline file name|File name of the pipeline (XML only). | This code has leading white space : `{sp}pipeline file name`
-|User that modified the pipeline last| | `User modified`
+|User that created the pipeline|Only filled in when Hop knew who created the pipeline, such as an authenticated Hop web session. `-` otherwise. | `User created`
-|Date when the pipeline was modified last| | `Date modified`
+|Date when the pipeline was created|The date the pipeline was first created. | `Date created`
+
+|User that modified the pipeline last|Only filled in when Hop knew who saved the pipeline last, such as an authenticated Hop web session. `-` otherwise. | `User modified`
+
+|Date when the pipeline was modified last|The date of the last save that changed the pipeline. | `Date modified`
|Hostname (Network setup)|Returns the hostname of the server. | `Hostname`
diff --git a/docs/hop-user-manual/modules/ROOT/pages/workflow/create-workflow.adoc b/docs/hop-user-manual/modules/ROOT/pages/workflow/create-workflow.adoc
index 3b9aef41fd5..51ff58bd21a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/workflow/create-workflow.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/workflow/create-workflow.adoc
@@ -144,12 +144,13 @@ The Workflow tab allows you to specify general properties about the workflow inc
|Workflow filename| The filename of the workflow
|Description|Short description of the workflow
|Extended description| Long extended description of the workflow
-|Status| Draft or production status
|Version|Description of the version
-|Created by| Displays the original creator of the workflow
+|Created by| The user that created the workflow. Only recorded when Hop knows who that is, such as an authenticated Hop web session, and shows `-` otherwise.
|Created at|Displays the date and time when the workflow was created.
-|Last modified by| Displays the last user that modified the workflow
-|Last modified at|Displays the date and time when the workflow was last modified.
+|Last modified by| The user that last modified the workflow, recorded under the same conditions as `Created by`.
+|Last modified at|Displays the date and time of the last save that changed the workflow. A save that does not change anything leaves this untouched.
+|Created with|The version of Hop that created the workflow. Empty for workflows created before Hop started recording this.
+|Last modified with|The version of Hop that last saved a change to the workflow.
|===
The parameters tab allows you to specify parameters specific for the workflow.
diff --git a/engine/src/main/java/org/apache/hop/base/AbstractMeta.java b/engine/src/main/java/org/apache/hop/base/AbstractMeta.java
index ef59cc1030b..0bf7d5f1855 100644
--- a/engine/src/main/java/org/apache/hop/base/AbstractMeta.java
+++ b/engine/src/main/java/org/apache/hop/base/AbstractMeta.java
@@ -20,6 +20,7 @@
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -49,6 +50,7 @@
import org.apache.hop.core.parameters.INamedParameterDefinitions;
import org.apache.hop.core.parameters.INamedParameters;
import org.apache.hop.core.parameters.UnknownParamException;
+import org.apache.hop.core.security.HopSecurity;
import org.apache.hop.core.undo.ChangeAction;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
@@ -989,6 +991,51 @@ public final void setChanged(boolean ch) {
}
}
+ /**
+ * Whether a save that is about to be written has to refresh the elements recording who last
+ * changed this file and when.
+ *
+ * An edited file gets a fresh stamp, and so does a file that is being written for the first
+ * time. A save that writes the same content over a file that is already there does not: the
+ * elements would then change every time an untouched file is saved and show up as a difference in
+ * version control.
+ *
+ * @param fileExists whether the file about to be written is already there
+ * @return true if the file has to be stamped before it is written
+ */
+ public boolean needsModificationStamp(boolean fileExists) {
+ return hasChanged() || !fileExists;
+ }
+
+ /**
+ * Record the current time and user as the last modification of this file. Called by the save path
+ * right before the file is written, when {@link #needsModificationStamp(boolean)} says so.
+ *
+ *
The user is only recorded when a real one is known, see {@link
+ * HopSecurity#getAuditUsername()}. The desktop has no authenticated user, so there the modified
+ * date is maintained and the user is left at whatever the file already holds.
+ */
+ public void stampModified() {
+ setModifiedDate(new Date());
+ HopSecurity.getAuditUsername().ifPresent(this::setModifiedUser);
+ }
+
+ /**
+ * Record the current user as the creator of this file. Called when a new file is created, never
+ * on save: the creator of an existing file is not whoever happens to save it next.
+ *
+ *
The creation date is set by the constructor. Only a known user is recorded, see {@link
+ * HopSecurity#getAuditUsername()}.
+ */
+ public void stampCreated() {
+ HopSecurity.getAuditUsername()
+ .ifPresent(
+ username -> {
+ setCreatedUser(username);
+ setModifiedUser(username);
+ });
+ }
+
public void addObserver(IHopObserver o) {
changedFlag.addObserver(o);
}
diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaInfo.java b/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaInfo.java
index 58db96e4f4a..5c86cff219d 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaInfo.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaInfo.java
@@ -42,7 +42,15 @@ public class PipelineMetaInfo extends AbstractMetaInfo {
@HopMetadataProperty(key = "transform_performance_capturing_size_limit")
protected String transformPerformanceCapturingSizeLimit;
- /** The status of the pipeline. */
+ /**
+ * The status of the pipeline: {@code 1} for draft, {@code 2} for production, {@code -1} when
+ * unset.
+ *
+ * @deprecated since 2.20, for removal. Nothing in Hop acts on the value, it has to be maintained
+ * by hand, and it is left at {@code -1} in practice. It is still read from and written to the
+ * file, so no existing value is lost before it is removed.
+ */
+ @Deprecated(since = "2.20", forRemoval = true)
@HopMetadataProperty(key = "pipeline_status")
protected int pipelineStatus;
diff --git a/engine/src/test/java/org/apache/hop/base/ModificationStampTest.java b/engine/src/test/java/org/apache/hop/base/ModificationStampTest.java
new file mode 100644
index 00000000000..572ef4189fb
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/base/ModificationStampTest.java
@@ -0,0 +1,273 @@
+/*
+ * 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.hop.base;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.Set;
+import org.apache.hop.core.security.HopRole;
+import org.apache.hop.core.security.HopSecurity;
+import org.apache.hop.core.security.HopSecurityContext;
+import org.apache.hop.core.security.HopSecurityPrivilegeMode;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * The creation and last modification of a pipeline or workflow are recorded in the file so a
+ * project can be reviewed for stale or unattended work.
+ *
+ *
Both are only recorded when there is something to record: the date of the last change, and the
+ * user behind it whenever the session knows one. A desktop session has no authenticated user, and
+ * neither that fact nor the operating system account says who edited the file, so the user elements
+ * keep whatever the file already held rather than being filled with a placeholder.
+ */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class ModificationStampTest {
+
+ /** The placeholder written by every Hop release so far when no user is known. */
+ private static final String NO_USER = "-";
+
+ private static final String AUTHOR = "alice";
+ private static final String EDITOR = "bob";
+
+ /** A pipeline as written by an older release: dates filled in, users left at the placeholder. */
+ private static final String PIPELINE_WITHOUT_USERS =
+ """
+
+
+ no-users
+ -
+ 2023/09/16 22:31:19.820
+ -
+ 2023/09/16 22:31:19.820
+
+
+ """;
+
+ /** A workflow as written by an older release, keeping its info elements at the root. */
+ private static final String WORKFLOW_WITHOUT_USERS =
+ """
+
+ no-users
+ -
+ 2023/09/16 22:31:19.820
+ -
+ 2023/09/16 22:31:19.820
+
+ """;
+
+ private final IVariables variables = new Variables();
+ private final IHopMetadataProvider metadataProvider = new MemoryMetadataProvider();
+
+ @AfterEach
+ void restoreSecurityContext() {
+ HopSecurity.reset();
+ }
+
+ private void authenticateAs(String username) {
+ HopSecurity.setProvider(() -> HopSecurityContext.forUser(username, Set.of(HopRole.USER)));
+ }
+
+ private PipelineMeta loadPipeline(String xml) throws Exception {
+ return new PipelineMeta(
+ new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
+ metadataProvider,
+ variables);
+ }
+
+ private WorkflowMeta loadWorkflow(String xml) throws Exception {
+ return new WorkflowMeta(
+ new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
+ metadataProvider,
+ variables);
+ }
+
+ @Test
+ void pipelineModificationDateIsStampedAndSerialized() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_WITHOUT_USERS);
+ Date loadedDate = pipelineMeta.getModifiedDate();
+
+ pipelineMeta.stampModified();
+
+ assertTrue(
+ pipelineMeta.getModifiedDate().after(loadedDate),
+ "the modification date has to move forward when the file is saved after an edit");
+ String stamped = XmlHandler.date2string(pipelineMeta.getModifiedDate());
+ assertTrue(
+ pipelineMeta.getXml(variables).contains("" + stamped + ""),
+ "the stamped date has to end up in the file");
+ }
+
+ @Test
+ void workflowModificationDateIsStamped() throws Exception {
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_WITHOUT_USERS);
+ Date loadedDate = workflowMeta.getModifiedDate();
+
+ workflowMeta.stampModified();
+
+ assertTrue(
+ workflowMeta.getModifiedDate().after(loadedDate),
+ "the modification date has to move forward when the file is saved after an edit");
+ }
+
+ @Test
+ void authenticatedUserIsRecordedAsTheLastToModify() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_WITHOUT_USERS);
+ authenticateAs(EDITOR);
+
+ pipelineMeta.stampModified();
+
+ assertEquals(EDITOR, pipelineMeta.getModifiedUser());
+ assertTrue(
+ pipelineMeta.getXml(variables).contains("" + EDITOR + ""));
+ }
+
+ @Test
+ void aModificationNeverRewritesTheCreator() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_WITHOUT_USERS);
+ Date createdDate = pipelineMeta.getCreatedDate();
+ authenticateAs(EDITOR);
+
+ pipelineMeta.stampModified();
+
+ assertEquals(
+ NO_USER,
+ pipelineMeta.getCreatedUser(),
+ "whoever saves a file is not its creator: an unknown creator stays unknown");
+ assertEquals(createdDate, pipelineMeta.getCreatedDate());
+ }
+
+ @Test
+ void aKnownCreatorSurvivesAModificationBySomeoneElse() throws Exception {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ authenticateAs(AUTHOR);
+ pipelineMeta.stampCreated();
+
+ authenticateAs(EDITOR);
+ pipelineMeta.stampModified();
+
+ assertEquals(AUTHOR, pipelineMeta.getCreatedUser());
+ assertEquals(EDITOR, pipelineMeta.getModifiedUser());
+ }
+
+ @Test
+ void creationRecordsTheAuthenticatedUserAsCreatorAndModifier() {
+ authenticateAs(AUTHOR);
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ WorkflowMeta workflowMeta = new WorkflowMeta();
+
+ pipelineMeta.stampCreated();
+ workflowMeta.stampCreated();
+
+ assertEquals(AUTHOR, pipelineMeta.getCreatedUser());
+ assertEquals(AUTHOR, pipelineMeta.getModifiedUser());
+ assertEquals(AUTHOR, workflowMeta.getCreatedUser());
+ assertEquals(AUTHOR, workflowMeta.getModifiedUser());
+ }
+
+ @Test
+ void withoutAnAuthenticatedUserTheUserElementsAreLeftAlone() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_WITHOUT_USERS);
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_WITHOUT_USERS);
+
+ // The desktop default: an unrestricted context with an anonymous placeholder for a name.
+ pipelineMeta.stampCreated();
+ pipelineMeta.stampModified();
+ workflowMeta.stampCreated();
+ workflowMeta.stampModified();
+
+ assertEquals(NO_USER, pipelineMeta.getCreatedUser());
+ assertEquals(NO_USER, pipelineMeta.getModifiedUser());
+ assertEquals(NO_USER, workflowMeta.getCreatedUser());
+ assertEquals(NO_USER, workflowMeta.getModifiedUser());
+ assertTrue(
+ pipelineMeta.getXml(variables).contains("-"),
+ "a file saved on the desktop keeps the placeholder earlier releases wrote");
+ }
+
+ @Test
+ void anUntouchedFileThatIsSavedAgainIsNotStamped() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_WITHOUT_USERS);
+
+ assertFalse(
+ pipelineMeta.needsModificationStamp(true),
+ "saving an untouched file over the existing one has to leave the file as it is");
+
+ pipelineMeta.setChanged();
+
+ assertTrue(
+ pipelineMeta.needsModificationStamp(true),
+ "saving an edited file has to record the change");
+ }
+
+ @Test
+ void aFileWrittenForTheFirstTimeIsAlwaysStamped() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_WITHOUT_USERS);
+
+ assertTrue(
+ pipelineMeta.needsModificationStamp(false),
+ "a file that is not there yet is being written, however the changed flag stands");
+ }
+
+ @Test
+ void theAnonymousPlaceholderIsNeverWrittenAsAUser() {
+ HopSecurity.setProvider(HopSecurityContext::unrestricted);
+ PipelineMeta pipelineMeta = new PipelineMeta();
+
+ pipelineMeta.stampCreated();
+ pipelineMeta.stampModified();
+
+ assertEquals(NO_USER, pipelineMeta.getCreatedUser());
+ assertEquals(NO_USER, pipelineMeta.getModifiedUser());
+ }
+
+ @Test
+ void aDesktopRoleSimulationIsNeverWrittenAsAUser() {
+ HopSecurity.setProvider(
+ () ->
+ HopSecurityPrivilegeMode.createEffective(
+ HopSecurityContext.unrestricted(), HopRole.READ_ONLY));
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ WorkflowMeta workflowMeta = new WorkflowMeta();
+
+ pipelineMeta.stampCreated();
+ pipelineMeta.stampModified();
+ workflowMeta.stampCreated();
+ workflowMeta.stampModified();
+
+ assertEquals(NO_USER, pipelineMeta.getCreatedUser());
+ assertEquals(NO_USER, pipelineMeta.getModifiedUser());
+ assertEquals(NO_USER, workflowMeta.getCreatedUser());
+ assertEquals(NO_USER, workflowMeta.getModifiedUser());
+ }
+}
diff --git a/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemData.java b/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemData.java
index 7085699075b..23165f6e673 100644
--- a/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemData.java
+++ b/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemData.java
@@ -20,6 +20,8 @@
import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.AVAILABLE_PROCESSORS;
import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.COMMITTED_VIRTUAL_MEMORY_SIZE;
import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.COPYNR;
+import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.CREATED_DATE;
+import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.CREATED_USER;
import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.CURRENT_PID;
import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.FILENAME;
import static org.apache.hop.pipeline.transforms.systemdata.SystemDataType.FREE_PHYSICAL_MEMORY_SIZE;
@@ -311,6 +313,8 @@ private void initResolvers() {
resolvers.put(COPYNR, () -> (long) getCopy());
resolvers.put(PIPELINE_NAME, () -> getPipelineMeta().getName());
resolvers.put(FILENAME, () -> getPipelineMeta().getFilename());
+ resolvers.put(CREATED_USER, () -> getPipelineMeta().getCreatedUser());
+ resolvers.put(CREATED_DATE, () -> getPipelineMeta().getCreatedDate());
resolvers.put(MODIFIED_USER, () -> getPipelineMeta().getModifiedUser());
resolvers.put(MODIFIED_DATE, () -> getPipelineMeta().getModifiedDate());
resolvers.put(HOSTNAME_REAL, Const::getHostnameReal);
diff --git a/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataMeta.java b/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataMeta.java
index 3cd9bebba8f..e63a7c7e338 100644
--- a/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataMeta.java
+++ b/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataMeta.java
@@ -108,6 +108,7 @@ public void getFields(
THIS_MONTH_END,
NEXT_MONTH_START,
NEXT_MONTH_END,
+ CREATED_DATE,
MODIFIED_DATE,
PREV_WEEK_START,
PREV_WEEK_END,
@@ -140,6 +141,7 @@ public void getFields(
break;
case PIPELINE_NAME,
FILENAME,
+ CREATED_USER,
MODIFIED_USER,
HOSTNAME,
HOSTNAME_REAL,
diff --git a/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataType.java b/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataType.java
index 825f229d682..6aab842cefb 100644
--- a/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataType.java
+++ b/plugins/transforms/systemdata/src/main/java/org/apache/hop/pipeline/transforms/systemdata/SystemDataType.java
@@ -46,6 +46,8 @@ public enum SystemDataType implements IEnumHasCodeAndDescription {
COPYNR("copy of transform", "CopyOfTransform"),
PIPELINE_NAME(" pipeline name", "PipelineName"),
FILENAME(" pipeline file name", "PipelineFileName"),
+ CREATED_USER("User created", "UserCreated"),
+ CREATED_DATE("Date created", "DateCreated"),
MODIFIED_USER("User modified", "UserModified"),
MODIFIED_DATE("Date modified", "DateModified"),
HOSTNAME("Hostname", "HostnameNetworkSetup"),
diff --git a/plugins/transforms/systemdata/src/main/resources/org/apache/hop/pipeline/transforms/systemdata/messages/messages_en_US.properties b/plugins/transforms/systemdata/src/main/resources/org/apache/hop/pipeline/transforms/systemdata/messages/messages_en_US.properties
index 6c5955c186e..d24be0468bf 100644
--- a/plugins/transforms/systemdata/src/main/resources/org/apache/hop/pipeline/transforms/systemdata/messages/messages_en_US.properties
+++ b/plugins/transforms/systemdata/src/main/resources/org/apache/hop/pipeline/transforms/systemdata/messages/messages_en_US.properties
@@ -53,6 +53,7 @@ SystemDataMeta.TypeDesc.CommandLineArgument9=Command line argument 9
SystemDataMeta.TypeDesc.CommittedVirtualMemorySize=Committed virtual size (bytes)
SystemDataMeta.TypeDesc.CopyOfTransform=Copy of transform
SystemDataMeta.TypeDesc.CurrentPID=Current process identifier (PID)
+SystemDataMeta.TypeDesc.DateCreated=Date when the pipeline was created
SystemDataMeta.TypeDesc.DateModified=Date when the pipeline was modified last
SystemDataMeta.TypeDesc.EndDateRange=End date range (Pipeline)
SystemDataMeta.TypeDesc.FreePhysicalMemorySize=Free physical memory size (bytes)
@@ -131,6 +132,7 @@ SystemDataMeta.TypeDesc.TomorrowEnd=Tomorrow 23\:59\:59
SystemDataMeta.TypeDesc.TomorrowStart=Tomorrow 00\:00\:00
SystemDataMeta.TypeDesc.TotalPhysicalMemorySize=Total physical memory size (bytes)
SystemDataMeta.TypeDesc.TotalSwapSpaceSize=Total swap space size (bytes)
+SystemDataMeta.TypeDesc.UserCreated=User that created the pipeline
SystemDataMeta.TypeDesc.UserModified=User that modified the pipeline last
SystemDataMeta.TypeDesc.YesterdayEnd=Yesterday 23\:59\:59
SystemDataMeta.TypeDesc.YesterdayStart=Yesterday 00\:00\:00
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index bd4b10340f7..b99c0ce12b7 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -5727,9 +5727,12 @@ public synchronized void save() throws HopException {
boolean fileExist = HopVfs.fileExists(pipelineMeta.getFilename());
- // Record the version of Hop saving this pipeline
+ // Record who saved this pipeline, when, and with which version of Hop
//
- pipelineMeta.setModifiedHopVersion(Const.NVL(Const.getHopVersion(), ""));
+ if (pipelineMeta.needsModificationStamp(fileExist)) {
+ pipelineMeta.stampModified();
+ pipelineMeta.setModifiedHopVersion(Const.NVL(Const.getHopVersion(), ""));
+ }
String xml = pipelineMeta.getXml(variables);
OutputStream out = HopVfs.getOutputStream(pipelineMeta.getFilename(), false);
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
index 301c947c4e3..5c9e32059d8 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
@@ -169,8 +169,9 @@ public IHopFileTypeHandler newFile(HopGui hopGui, IVariables variables) throws H
PipelineMeta pipelineMeta = new PipelineMeta();
pipelineMeta.setName(BaseMessages.getString(PKG, "HopPipelineFileType.New.Text"));
- // Record the version of Hop creating this pipeline
+ // Record who created this pipeline and with which version of Hop
//
+ pipelineMeta.stampCreated();
pipelineMeta.setCreatedHopVersion(Const.NVL(Const.getHopVersion(), ""));
// Pass the MetadataProvider for reference lookups
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 9afaddbec58..640d78d2880 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -4857,9 +4857,12 @@ public synchronized void save() throws HopException {
boolean fileExist = HopVfs.fileExists(workflowMeta.getFilename());
- // Record the version of Hop saving this workflow
+ // Record who saved this workflow, when, and with which version of Hop
//
- workflowMeta.setModifiedHopVersion(Const.NVL(Const.getHopVersion(), ""));
+ if (workflowMeta.needsModificationStamp(fileExist)) {
+ workflowMeta.stampModified();
+ workflowMeta.setModifiedHopVersion(Const.NVL(Const.getHopVersion(), ""));
+ }
String xml = workflowMeta.getXml(variables);
OutputStream out = HopVfs.getOutputStream(workflowMeta.getFilename(), false);
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
index 16e29de2a88..8f957826b27 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
@@ -174,8 +174,9 @@ public IHopFileTypeHandler newFile(HopGui hopGui, IVariables parentVariableSpace
WorkflowMeta workflowMeta = new WorkflowMeta();
workflowMeta.setName(BaseMessages.getString(PKG, "HopWorkflowFileType.New.Text"));
- // Record the version of Hop creating this workflow
+ // Record who created this workflow and with which version of Hop
//
+ workflowMeta.stampCreated();
workflowMeta.setCreatedHopVersion(Const.NVL(Const.getHopVersion(), ""));
// Pass the MetaStore for reference lookups