This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new d7773249bd Issue #8791 : Harden the Mask fields database mapping store
(#8793)
d7773249bd is described below
commit d7773249bdbd8d21711947b453006d2a5a65047b
Author: Bart Maertens <[email protected]>
AuthorDate: Fri Oct 9 19:42:22 2026 +0200
Issue #8791 : Harden the Mask fields database mapping store (#8793)
* Issue #8791 : Harden the Mask fields database mapping store
Store an HMAC of each source value when a pattern has a hash secret, and
move plain-text rows to their hashed key. Reserve sequence numbers with a row
lock and add a unique key on the masked value, so two processes never hand out
the same value. Share a mapping store by resolved URL and user. Clear the
failed and later fields of an error row. Add trim and ignore-case options and
key numbers by value. Stop when a hash secret does not resolve.
* Address review: resolve manual URLs in the mapping store key, reject
undecryptable hash secrets, remove leftover legacy rows (#8791)
* Initialize the Hop environment in MaskingRuntimeTest (#8791)
---
.../ROOT/pages/metadata-types/masking-pattern.adoc | 11 +-
.../ROOT/pages/pipeline/transforms/maskfields.adoc | 14 +-
.../database/0046-mask-fields-hashed-database.hpl | 186 ++++++++++++
.../0047-mask-fields-plain-text-migration.hpl | 173 ++++++++++++
.../main-0046-mask-fields-hashed-database.hwf | 312 +++++++++++++++++++++
.../main-0047-mask-fields-plain-text-migration.hwf | 234 ++++++++++++++++
.../metadata/masking-pattern/0046-first-name.json | 17 ++
.../metadata/masking-pattern/0047-first-name.json | 17 ++
.../scripts/script-mask-fields-hashed-database.sql | 27 ++
.../script-mask-fields-plain-text-migration.sql | 43 +++
.../transforms/0120-mask-fields-normalize.hpl | 193 +++++++++++++
.../datasets/golden-0120-mask-fields-normalize.csv | 5 +
.../transforms/main-0120-mask-fields-normalize.hwf | 79 ++++++
.../dataset/golden-0120-mask-fields-normalize.json | 40 +++
.../metadata/masking-pattern/0120-customer-nr.json | 17 ++
.../metadata/masking-pattern/0120-first-name.json | 17 ++
.../unit-test/0120-mask-fields-normalize UNIT.json | 43 +++
.../pipeline/transforms/maskfields/MaskFields.java | 5 +-
.../transforms/maskfields/MaskFieldsMeta.java | 36 +++
.../transforms/maskfields/MaskingEngine.java | 49 +++-
.../pipeline/transforms/maskfields/MaskingKey.java | 157 +++++++++++
.../transforms/maskfields/MaskingPattern.java | 43 +++
.../maskfields/MaskingPatternEditor.java | 11 +-
.../transforms/maskfields/MaskingRuntime.java | 51 +++-
.../maskfields/store/DatabaseMaskingStore.java | 191 +++++++++----
.../transforms/maskfields/store/IMaskingStore.java | 18 ++
.../maskfields/messages/messages_en_US.properties | 13 +-
.../transforms/maskfields/MaskFieldsMetaTest.java | 57 ++++
.../transforms/maskfields/MaskingEngineTest.java | 45 ++-
.../transforms/maskfields/MaskingKeyTest.java | 109 +++++++
.../transforms/maskfields/MaskingRuntimeTest.java | 28 ++
.../maskfields/store/DatabaseMaskingStoreTest.java | 231 ++++++++++++++-
32 files changed, 2397 insertions(+), 75 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/masking-pattern.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/masking-pattern.adoc
index 9e569d3f85..dca8d136ab 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/masking-pattern.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/masking-pattern.adoc
@@ -61,6 +61,12 @@ image::metadata-types/masking-pattern-first-name.jpg[Masking
pattern first-name,
|Remember values
|*NONE* assigns a new value every row. *MEMORY* keeps the mapping for one run.
*DATABASE* keeps it in a table across runs.
+|Ignore surrounding spaces
+|Values that only differ in leading or trailing spaces get the same
replacement. A value of only spaces is left unchanged. Used by *MEMORY* and
*DATABASE*.
+
+|Ignore case
+|Values that only differ in upper and lower case get the same replacement.
Used by *MEMORY* and *DATABASE*.
+
|Connection
|Database connection for *DATABASE*.
@@ -68,5 +74,8 @@ image::metadata-types/masking-pattern-first-name.jpg[Masking
pattern first-name,
|Schema of the mapping table. Empty uses the connection default.
|Table
-|Mapping table. A second table with the suffix `_seq` stores the next sequence
number. Both are created when they are missing. The map table stores the
original value and is itself personal data.
+|Mapping table. A second table with the suffix `_seq` stores the next sequence
number. Both are created when they are missing. A new mapping table holds
masked values of up to 255 characters.
+
+|Hash secret
+|The mapping table stores an HMAC-SHA256 of each value, made with this secret,
instead of the value itself. Use a variable such as `${MASKING_SECRET}`.
Changing the secret starts a new mapping. Without a secret the table stores the
original values and is itself personal data.
|===
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/maskfields.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/maskfields.adoc
index 4192848e1a..09994a8520 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/maskfields.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/maskfields.adoc
@@ -55,6 +55,8 @@ image::transforms/maskfields/maskfields-dialog.png[Mask
fields dialog,width="60%
|Name of a Masking pattern metadata item. Leave this empty to skip the field.
|===
+When a field cannot be masked and the transform has error handling, the error
row has that field and every masked field after it set to null.
+
== What a pattern writes
*SYNTHETIC* builds `prefix + token + suffix` for a string. The token is a
*SEQUENCE* (an integer that starts at *Sequence start* and steps by one) or a
*UUID*. A number field can take a sequence only, with an empty prefix and
suffix. A date cannot take a generated token.
@@ -67,11 +69,17 @@ image::transforms/maskfields/maskfields-dialog.png[Mask
fields dialog,width="60%
*MEMORY* keeps the mapping for this pipeline execution. The next run starts
again, so tokens from the previous run can be issued again.
-*DATABASE* keeps the mapping in the table named on the pattern. A second
table, that name plus `_seq`, stores the next sequence number. Both tables are
created when they are missing. The map table has `pattern_name`, `source_key`
and `masked_value`, with a primary key on the pattern name and the source key.
+*DATABASE* keeps the mapping in the table named on the pattern. A second
table, that name plus `_seq`, stores the next sequence number. Both tables are
created when they are missing. The map table has `pattern_name`, `source_key`
and `masked_value`, with a primary key on the pattern name and the source key.
A new map table also has a unique key on the pattern name and the masked value,
and holds masked values of up to 255 characters.
+
+Give a database pattern a *Hash secret*. The map table then stores an
HMAC-SHA256 of each value, made with that secret, instead of the value itself.
Without a secret, `source_key` holds the original value, so the table is itself
personal data, and the transform check warns about it. Keep the secret in a
variable, out of the project. Changing the secret starts a new mapping. A row
stored in plain text before the pattern had a secret is moved to its hashed key
the next time its value is ma [...]
+
+Several Hop processes can share one map table. Each new sequence number is
reserved with a row lock, so two processes never hand out the same value.
+
+*Ignore surrounding spaces* and *Ignore case* make values that only differ in
those ways share a replacement. A number is compared by its value, not by its
conversion mask. Turning these options on for a database pattern that already
holds mappings changes replacements: spellings such as `Matt` and `matt` that
had their own replacement now share the one stored for whichever spelling comes
by first, so data masked earlier no longer matches for the others.
-The map table stores the original value, so it is itself personal data. Put it
on a connection you protect, separate from the masked output.
+A hash secret that resolves to nothing, or still holds a variable that is not
set, stops the transform from starting. It never falls back to plain text or to
the literal text of the variable.
-Copies and partitions of one pipeline share one runtime, so a remembered value
stays the same and a sequence does not start over on each copy. Which row
receives which new sequence number follows whichever copy runs first. Beam and
native Spark each run a worker in its own JVM, so they stay unsupported. The
database store is what shares a mapping across pipelines.
+Copies and partitions of one pipeline share one runtime, so a remembered value
stays the same and a sequence does not start over on each copy. Which row
receives which new sequence number follows whichever copy runs first. Beam and
native Spark each run a worker in its own JVM, so they stay unsupported. The
database store is what shares a mapping across pipelines. Copies share a
database store when its connection resolves to the same URL and user.
== Related
diff --git a/integration-tests/database/0046-mask-fields-hashed-database.hpl
b/integration-tests/database/0046-mask-fields-hashed-database.hpl
new file mode 100644
index 0000000000..0ac4b4050b
--- /dev/null
+++ b/integration-tests/database/0046-mask-fields-hashed-database.hpl
@@ -0,0 +1,186 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0046-mask-fields-hashed-database</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ <parameter>
+ <name>RESULT_TABLE</name>
+ <default_value>mask_0046_run1</default_value>
+ <description>Table the masked rows are written to</description>
+ </parameter>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/07 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/07 20:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>people</from>
+ <to>Mask fields</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Mask fields</from>
+ <to>result</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>people</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>id</name>
+ <type>Integer</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>firstName</name>
+ <type>String</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>1</item>
+ <item>Matt</item>
+ </line>
+ <line>
+ <item>2</item>
+ <item>Ann</item>
+ </line>
+ <line>
+ <item>3</item>
+ <item>Bart</item>
+ </line>
+ <line>
+ <item>4</item>
+ <item>Matt</item>
+ </line>
+ </data>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Mask fields</name>
+ <type>MaskFields</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>firstName</name>
+ <pattern>0046-first-name</pattern>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>272</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>result</name>
+ <type>TableOutput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <commit>1000</commit>
+ <connection>unit-test-db</connection>
+ <fields>
+ <field>
+ <column_name>id</column_name>
+ <stream_name>id</stream_name>
+ </field>
+ <field>
+ <column_name>first_name</column_name>
+ <stream_name>firstName</stream_name>
+ </field>
+ </fields>
+ <return_field/>
+ <ignore_errors>N</ignore_errors>
+ <partitioning_daily>N</partitioning_daily>
+ <partitioning_enabled>N</partitioning_enabled>
+ <partitioning_field/>
+ <partitioning_monthly>Y</partitioning_monthly>
+ <return_keys>N</return_keys>
+ <schema>public</schema>
+ <specify_fields>Y</specify_fields>
+ <table>${RESULT_TABLE}</table>
+ <tablename_field/>
+ <tablename_in_field>N</tablename_in_field>
+ <tablename_in_table>Y</tablename_in_table>
+ <truncate>Y</truncate>
+ <use_batch>Y</use_batch>
+ <attributes/>
+ <GUI>
+ <xloc>448</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/database/0047-mask-fields-plain-text-migration.hpl
b/integration-tests/database/0047-mask-fields-plain-text-migration.hpl
new file mode 100644
index 0000000000..639f7f2485
--- /dev/null
+++ b/integration-tests/database/0047-mask-fields-plain-text-migration.hpl
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0047-mask-fields-plain-text-migration</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/07 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/07 20:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>people</from>
+ <to>Mask fields</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Mask fields</from>
+ <to>result</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>people</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>id</name>
+ <type>Integer</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>firstName</name>
+ <type>String</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>1</item>
+ <item>Matt</item>
+ </line>
+ <line>
+ <item>2</item>
+ <item>Ann</item>
+ </line>
+ </data>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Mask fields</name>
+ <type>MaskFields</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>firstName</name>
+ <pattern>0047-first-name</pattern>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>272</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>result</name>
+ <type>TableOutput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <commit>1000</commit>
+ <connection>unit-test-db</connection>
+ <fields>
+ <field>
+ <column_name>id</column_name>
+ <stream_name>id</stream_name>
+ </field>
+ <field>
+ <column_name>first_name</column_name>
+ <stream_name>firstName</stream_name>
+ </field>
+ </fields>
+ <return_field/>
+ <ignore_errors>N</ignore_errors>
+ <partitioning_daily>N</partitioning_daily>
+ <partitioning_enabled>N</partitioning_enabled>
+ <partitioning_field/>
+ <partitioning_monthly>Y</partitioning_monthly>
+ <return_keys>N</return_keys>
+ <schema>public</schema>
+ <specify_fields>Y</specify_fields>
+ <table>mask_0047_result</table>
+ <tablename_field/>
+ <tablename_in_field>N</tablename_in_field>
+ <tablename_in_table>Y</tablename_in_table>
+ <truncate>Y</truncate>
+ <use_batch>Y</use_batch>
+ <attributes/>
+ <GUI>
+ <xloc>448</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/database/main-0046-mask-fields-hashed-database.hwf
b/integration-tests/database/main-0046-mask-fields-hashed-database.hwf
new file mode 100644
index 0000000000..4bdaec2416
--- /dev/null
+++ b/integration-tests/database/main-0046-mask-fields-hashed-database.hwf
@@ -0,0 +1,312 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0046-mask-fields-hashed-database</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/10/07 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/07 20:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>scripts/script-mask-fields-hashed-database.sql</name>
+ <description/>
+ <type>SQL</type>
+ <connection>unit-test-db</connection>
+ <sendOneStatement>N</sendOneStatement>
+ <sql/>
+
<sqlfilename>${PROJECT_HOME}/scripts/script-mask-fields-hashed-database.sql</sqlfilename>
+ <sqlfromfile>Y</sqlfromfile>
+ <useVariableSubstitution>N</useVariableSubstitution>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>224</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>first run</name>
+ <description/>
+ <type>PIPELINE</type>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0046-mask-fields-hashed-database.hpl</filename>
+ <logext/>
+ <logfile/>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <parameter>
+ <name>RESULT_TABLE</name>
+ <stream_name/>
+ <value>mask_0046_run1</value>
+ </parameter>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>384</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>second run</name>
+ <description/>
+ <type>PIPELINE</type>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0046-mask-fields-hashed-database.hpl</filename>
+ <logext/>
+ <logfile/>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <parameter>
+ <name>RESULT_TABLE</name>
+ <stream_name/>
+ <value>mask_0046_run2</value>
+ </parameter>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>544</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>3 hashed mappings</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0046
+WHERE source_key LIKE 'hmac-sha256:%'
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>3</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>704</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>no names in plain text</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0046
+WHERE source_key IN ('Matt', 'Ann', 'Bart')
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>0</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>864</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>no masked value used twice</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT masked_value
+FROM public.mask_0046
+GROUP BY masked_value
+HAVING COUNT(*) > 1
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>0</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>1024</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Matt keeps one value</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT DISTINCT first_name
+FROM public.mask_0046_run1
+WHERE id IN (1, 4)
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>1</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>1184</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>both runs give the same values</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0046_run1 r1
+JOIN public.mask_0046_run2 r2 ON r1.id = r2.id AND r1.first_name =
r2.first_name
+WHERE r1.first_name LIKE 'fn%'
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>4</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>1344</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>scripts/script-mask-fields-hashed-database.sql</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>scripts/script-mask-fields-hashed-database.sql</from>
+ <to>first run</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>first run</from>
+ <to>second run</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>second run</from>
+ <to>3 hashed mappings</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>3 hashed mappings</from>
+ <to>no names in plain text</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>no names in plain text</from>
+ <to>no masked value used twice</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>no masked value used twice</from>
+ <to>Matt keeps one value</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Matt keeps one value</from>
+ <to>both runs give the same values</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/database/main-0047-mask-fields-plain-text-migration.hwf
b/integration-tests/database/main-0047-mask-fields-plain-text-migration.hwf
new file mode 100644
index 0000000000..7d5cda22e1
--- /dev/null
+++ b/integration-tests/database/main-0047-mask-fields-plain-text-migration.hwf
@@ -0,0 +1,234 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0047-mask-fields-plain-text-migration</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/10/07 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/07 20:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>scripts/script-mask-fields-plain-text-migration.sql</name>
+ <description/>
+ <type>SQL</type>
+ <connection>unit-test-db</connection>
+ <sendOneStatement>N</sendOneStatement>
+ <sql/>
+
<sqlfilename>${PROJECT_HOME}/scripts/script-mask-fields-plain-text-migration.sql</sqlfilename>
+ <sqlfromfile>Y</sqlfromfile>
+ <useVariableSubstitution>N</useVariableSubstitution>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>224</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>mask</name>
+ <description/>
+ <type>PIPELINE</type>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+
<filename>${PROJECT_HOME}/0047-mask-fields-plain-text-migration.hpl</filename>
+ <logext/>
+ <logfile/>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>384</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Matt keeps the stored value</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0047_result
+WHERE id = 1 AND first_name = 'fn-legacy'
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>1</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>544</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Ann continues the stored sequence</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0047_result
+WHERE id = 2 AND first_name = 'fn100'
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>1</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>704</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>2 mappings</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0047
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>2</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>864</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>no keys left in plain text</name>
+ <description/>
+ <type>EVAL_TABLE_CONTENT</type>
+ <add_rows_result>N</add_rows_result>
+ <clear_result_rows>Y</clear_result_rows>
+ <connection>unit-test-db</connection>
+ <custom_sql>SELECT 1
+FROM public.mask_0047
+WHERE source_key NOT LIKE 'hmac-sha256:%'
+</custom_sql>
+ <is_custom_sql>Y</is_custom_sql>
+ <is_usevars>N</is_usevars>
+ <limit>0</limit>
+ <schemaname>public</schemaname>
+ <success_condition>rows_count_equal</success_condition>
+ <tablename/>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>1024</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>scripts/script-mask-fields-plain-text-migration.sql</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>scripts/script-mask-fields-plain-text-migration.sql</from>
+ <to>mask</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>mask</from>
+ <to>Matt keeps the stored value</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Matt keeps the stored value</from>
+ <to>Ann continues the stored sequence</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Ann continues the stored sequence</from>
+ <to>2 mappings</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>2 mappings</from>
+ <to>no keys left in plain text</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/database/metadata/masking-pattern/0046-first-name.json
b/integration-tests/database/metadata/masking-pattern/0046-first-name.json
new file mode 100644
index 0000000000..c43b71c7d9
--- /dev/null
+++ b/integration-tests/database/metadata/masking-pattern/0046-first-name.json
@@ -0,0 +1,17 @@
+{
+ "name": "0046-first-name",
+ "description": "Database mapping with a hash secret",
+ "piiClassification": "Direct identifier",
+ "valueSource": "SYNTHETIC",
+ "token": "SEQUENCE",
+ "prefix": "fn",
+ "suffix": "",
+ "sequenceStart": "1",
+ "storage": "DATABASE",
+ "trimKey": false,
+ "ignoreCase": false,
+ "connection": "unit-test-db",
+ "schemaName": "public",
+ "tableName": "mask_0046",
+ "hashSecret": "it-0046-secret"
+}
diff --git
a/integration-tests/database/metadata/masking-pattern/0047-first-name.json
b/integration-tests/database/metadata/masking-pattern/0047-first-name.json
new file mode 100644
index 0000000000..fde266860a
--- /dev/null
+++ b/integration-tests/database/metadata/masking-pattern/0047-first-name.json
@@ -0,0 +1,17 @@
+{
+ "name": "0047-first-name",
+ "description": "Database mapping with a hash secret on a table that holds
plain-text keys",
+ "piiClassification": "Direct identifier",
+ "valueSource": "SYNTHETIC",
+ "token": "SEQUENCE",
+ "prefix": "fn",
+ "suffix": "",
+ "sequenceStart": "1",
+ "storage": "DATABASE",
+ "trimKey": false,
+ "ignoreCase": false,
+ "connection": "unit-test-db",
+ "schemaName": "public",
+ "tableName": "mask_0047",
+ "hashSecret": "it-0047-secret"
+}
diff --git
a/integration-tests/database/scripts/script-mask-fields-hashed-database.sql
b/integration-tests/database/scripts/script-mask-fields-hashed-database.sql
new file mode 100644
index 0000000000..3ea48faa49
--- /dev/null
+++ b/integration-tests/database/scripts/script-mask-fields-hashed-database.sql
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ *
+ */
+
+/* Issue #8791: Mask fields stores an HMAC of each value, not the value
itself. */
+
+DROP TABLE IF EXISTS public.mask_0046;
+DROP TABLE IF EXISTS public.mask_0046_seq;
+DROP TABLE IF EXISTS public.mask_0046_run1;
+DROP TABLE IF EXISTS public.mask_0046_run2;
+
+CREATE TABLE public.mask_0046_run1 (id INTEGER, first_name VARCHAR(100));
+CREATE TABLE public.mask_0046_run2 (id INTEGER, first_name VARCHAR(100));
diff --git
a/integration-tests/database/scripts/script-mask-fields-plain-text-migration.sql
b/integration-tests/database/scripts/script-mask-fields-plain-text-migration.sql
new file mode 100644
index 0000000000..406d297984
--- /dev/null
+++
b/integration-tests/database/scripts/script-mask-fields-plain-text-migration.sql
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ *
+ */
+
+/*
+ * Issue #8791: a mapping table written before patterns had a hash secret. It
has the old layout and
+ * stores the source value in plain text. Masking with a secret keeps the
replacement and moves the
+ * row to its hashed key.
+ */
+
+DROP TABLE IF EXISTS public.mask_0047;
+DROP TABLE IF EXISTS public.mask_0047_seq;
+DROP TABLE IF EXISTS public.mask_0047_result;
+
+CREATE TABLE public.mask_0047 (
+ pattern_name VARCHAR(128) NOT NULL,
+ source_key VARCHAR(255) NOT NULL,
+ masked_value VARCHAR(2000) NOT NULL,
+ PRIMARY KEY (pattern_name, source_key)
+);
+CREATE TABLE public.mask_0047_seq (
+ pattern_name VARCHAR(128) NOT NULL,
+ next_value BIGINT NOT NULL,
+ PRIMARY KEY (pattern_name)
+);
+CREATE TABLE public.mask_0047_result (id INTEGER, first_name VARCHAR(100));
+
+INSERT INTO public.mask_0047 VALUES ('0047-first-name', 'Matt', 'fn-legacy');
+INSERT INTO public.mask_0047_seq VALUES ('0047-first-name', 100);
diff --git a/integration-tests/transforms/0120-mask-fields-normalize.hpl
b/integration-tests/transforms/0120-mask-fields-normalize.hpl
new file mode 100644
index 0000000000..46c4afc85e
--- /dev/null
+++ b/integration-tests/transforms/0120-mask-fields-normalize.hpl
@@ -0,0 +1,193 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0120-mask-fields-normalize</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/07 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/07 20:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>people</from>
+ <to>Mask fields</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Mask fields</from>
+ <to>Result</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>people</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>id</name>
+ <type>Integer</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>firstName</name>
+ <type>String</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>customerNr</name>
+ <type>Integer</type>
+ <format>000000</format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <currency/>
+ <decimal/>
+ <group/>
+ <name>customerNrPlain</name>
+ <type>Integer</type>
+ <format></format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>1</item>
+ <item>Matt</item>
+ <item>000042</item>
+ <item>42</item>
+ </line>
+ <line>
+ <item>2</item>
+ <item> MATT </item>
+ <item>000007</item>
+ <item>7</item>
+ </line>
+ <line>
+ <item>3</item>
+ <item>matt</item>
+ <item>000042</item>
+ <item>42</item>
+ </line>
+ <line>
+ <item>4</item>
+ <item>Ann</item>
+ <item>001001</item>
+ <item>1001</item>
+ </line>
+ </data>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Mask fields</name>
+ <type>MaskFields</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>firstName</name>
+ <pattern>0120-first-name</pattern>
+ </field>
+ <field>
+ <name>customerNr</name>
+ <pattern>0120-customer-nr</pattern>
+ </field>
+ <field>
+ <name>customerNrPlain</name>
+ <pattern>0120-customer-nr</pattern>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>272</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Result</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+
+ <attributes/>
+ <GUI>
+ <xloc>448</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/transforms/datasets/golden-0120-mask-fields-normalize.csv
b/integration-tests/transforms/datasets/golden-0120-mask-fields-normalize.csv
new file mode 100644
index 0000000000..85f4ba722b
--- /dev/null
+++
b/integration-tests/transforms/datasets/golden-0120-mask-fields-normalize.csv
@@ -0,0 +1,5 @@
+id,firstName,customerNr,customerNrPlain
+1,fn1,500,500
+2,fn1,501,501
+3,fn1,500,500
+4,fn2,502,502
diff --git a/integration-tests/transforms/main-0120-mask-fields-normalize.hwf
b/integration-tests/transforms/main-0120-mask-fields-normalize.hwf
new file mode 100644
index 0000000000..49d3714a50
--- /dev/null
+++ b/integration-tests/transforms/main-0120-mask-fields-normalize.hwf
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0120-mask-fields-normalize</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/10/07 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/07 20:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Run Mask Fields normalize test</name>
+ <description/>
+ <type>RunPipelineTests</type>
+ <test_names>
+ <test_name>
+ <name>0120-mask-fields-normalize UNIT</name>
+ </test_name>
+ </test_names>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>224</xloc>
+ <yloc>96</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Run Mask Fields normalize test</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/transforms/metadata/dataset/golden-0120-mask-fields-normalize.json
b/integration-tests/transforms/metadata/dataset/golden-0120-mask-fields-normalize.json
new file mode 100644
index 0000000000..982f2ee2e0
--- /dev/null
+++
b/integration-tests/transforms/metadata/dataset/golden-0120-mask-fields-normalize.json
@@ -0,0 +1,40 @@
+{
+ "base_filename": "golden-0120-mask-fields-normalize.csv",
+ "name": "golden-0120-mask-fields-normalize",
+ "description": "Masked values from 0120-mask-fields-normalize",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 5,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "id"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "firstName"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 5,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "customerNr"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 5,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "customerNrPlain"
+ }
+ ],
+ "folder_name": ""
+}
diff --git
a/integration-tests/transforms/metadata/masking-pattern/0120-customer-nr.json
b/integration-tests/transforms/metadata/masking-pattern/0120-customer-nr.json
new file mode 100644
index 0000000000..45f688f9db
--- /dev/null
+++
b/integration-tests/transforms/metadata/masking-pattern/0120-customer-nr.json
@@ -0,0 +1,17 @@
+{
+ "name": "0120-customer-nr",
+ "description": "Shared by two integer fields with different conversion
masks",
+ "piiClassification": "Quasi-identifier",
+ "valueSource": "SYNTHETIC",
+ "token": "SEQUENCE",
+ "prefix": "",
+ "suffix": "",
+ "sequenceStart": "500",
+ "storage": "MEMORY",
+ "trimKey": false,
+ "ignoreCase": false,
+ "connection": "",
+ "schemaName": "",
+ "tableName": "",
+ "hashSecret": ""
+}
diff --git
a/integration-tests/transforms/metadata/masking-pattern/0120-first-name.json
b/integration-tests/transforms/metadata/masking-pattern/0120-first-name.json
new file mode 100644
index 0000000000..6f525c8ef2
--- /dev/null
+++ b/integration-tests/transforms/metadata/masking-pattern/0120-first-name.json
@@ -0,0 +1,17 @@
+{
+ "name": "0120-first-name",
+ "description": "Ignores surrounding spaces and case",
+ "piiClassification": "Direct identifier",
+ "valueSource": "SYNTHETIC",
+ "token": "SEQUENCE",
+ "prefix": "fn",
+ "suffix": "",
+ "sequenceStart": "1",
+ "storage": "MEMORY",
+ "trimKey": true,
+ "ignoreCase": true,
+ "connection": "",
+ "schemaName": "",
+ "tableName": "",
+ "hashSecret": ""
+}
diff --git
a/integration-tests/transforms/metadata/unit-test/0120-mask-fields-normalize
UNIT.json
b/integration-tests/transforms/metadata/unit-test/0120-mask-fields-normalize
UNIT.json
new file mode 100644
index 0000000000..ea1c2cad49
--- /dev/null
+++
b/integration-tests/transforms/metadata/unit-test/0120-mask-fields-normalize
UNIT.json
@@ -0,0 +1,43 @@
+{
+ "database_replacements": [],
+ "autoOpening": false,
+ "description": "Spaces, case and conversion masks do not change a remembered
replacement",
+ "persist_filename": "",
+ "test_type": "UNIT_TEST",
+ "variableValues": [],
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "id",
+ "data_set_field": "id"
+ },
+ {
+ "transform_field": "firstName",
+ "data_set_field": "firstName"
+ },
+ {
+ "transform_field": "customerNr",
+ "data_set_field": "customerNr"
+ },
+ {
+ "transform_field": "customerNrPlain",
+ "data_set_field": "customerNrPlain"
+ }
+ ],
+ "field_order": [
+ "id",
+ "firstName",
+ "customerNr",
+ "customerNrPlain"
+ ],
+ "data_set_name": "golden-0120-mask-fields-normalize",
+ "transform_name": "Result"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0120-mask-fields-normalize UNIT",
+ "trans_test_tweaks": [],
+ "pipeline_filename": "./0120-mask-fields-normalize.hpl"
+}
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFields.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFields.java
index 33fba597cc..860a78a16f 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFields.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFields.java
@@ -76,6 +76,8 @@ public class MaskFields extends BaseTransform<MaskFieldsMeta,
MaskFieldsData> {
patterns.put(field.getPatternName(), pattern);
}
long start = parseStart(pattern);
+ // Check the hash secret before the mapping table is opened.
+ MaskingKey key = MaskingKey.forPattern(pattern, this);
IMaskingStore store = storeFor(pattern, lease);
bindings.add(
new Binding(
@@ -85,7 +87,8 @@ public class MaskFields extends BaseTransform<MaskFieldsMeta,
MaskFieldsData> {
resolve(pattern.getSuffix()),
start,
store,
- sharedSequence(lease, transformName, field, pattern,
start)));
+ sharedSequence(lease, transformName, field, pattern, start),
+ key));
}
}
data.engine = new MaskingEngine(bindings);
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMeta.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMeta.java
index 9f182e3e13..3432f4e1de 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMeta.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMeta.java
@@ -45,6 +45,7 @@ import org.apache.hop.metadata.api.IHopMetadataSerializer;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.transform.BaseTransformMeta;
import org.apache.hop.pipeline.transform.TransformMeta;
+import
org.apache.hop.pipeline.transforms.maskfields.store.DatabaseMaskingStore;
@Getter
@Setter
@@ -69,6 +70,11 @@ public class MaskFieldsMeta extends
BaseTransformMeta<MaskFields, MaskFieldsData
/** Column index of the masking rule in the fields table. 0 is the row
number. */
public static final int RULE_COLUMN = 2;
+ /** Longest token a pattern can write: a UUID, or the digits of the largest
sequence value. */
+ private static final int UUID_LENGTH = 36;
+
+ private static final int LONG_DIGITS =
Long.toString(Long.MAX_VALUE).length();
+
@GuiWidgetElement(
id = WIDGET_EDIT_RULE,
order = "0200",
@@ -207,6 +213,22 @@ public class MaskFieldsMeta extends
BaseTransformMeta<MaskFields, MaskFieldsData
if (StringUtils.isEmpty(pattern.getTableName())) {
error(remarks, transformMeta, "MaskFields.Check.NoTable",
pattern.getName());
}
+ if (StringUtils.isEmpty(pattern.getHashSecret())) {
+ warning(remarks, transformMeta, "MaskFields.Check.PlainTextKeys",
pattern.getName());
+ }
+ int longest =
+ resolved(variables, pattern.getPrefix()).length()
+ + (pattern.getToken() == MaskingToken.UUID ? UUID_LENGTH :
LONG_DIGITS)
+ + resolved(variables, pattern.getSuffix()).length();
+ if (longest > DatabaseMaskingStore.MASKED_LENGTH) {
+ warning(
+ remarks,
+ transformMeta,
+ "MaskFields.Check.MaskedTooLong",
+ pattern.getName(),
+ Integer.toString(longest),
+ Integer.toString(DatabaseMaskingStore.MASKED_LENGTH));
+ }
if (metadataProvider == null) {
return;
}
@@ -236,6 +258,20 @@ public class MaskFieldsMeta extends
BaseTransformMeta<MaskFields, MaskFieldsData
}
}
+ private static String resolved(IVariables variables, String value) {
+ String text = variables == null ? value : variables.resolve(value);
+ return text == null ? "" : text;
+ }
+
+ private void warning(
+ List<ICheckResult> remarks, TransformMeta transformMeta, String key,
String... args) {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_WARNING,
+ BaseMessages.getString(PKG, key, (Object[]) args),
+ transformMeta));
+ }
+
private void error(
List<ICheckResult> remarks, TransformMeta transformMeta, String key,
String... args) {
remarks.add(
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngine.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngine.java
index e92020defc..a54c9b4973 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngine.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngine.java
@@ -45,10 +45,17 @@ public class MaskingEngine {
return bindings;
}
+ /**
+ * Masks the bound fields of the row in place. When a field fails, that
field and every bound
+ * field after it are set to null before the error is thrown, so an error
row never carries an
+ * original value.
+ */
public void apply(IRowMeta rowMeta, Object[] row) throws HopException {
- for (Binding binding : bindings) {
+ for (int i = 0; i < bindings.size(); i++) {
+ Binding binding = bindings.get(i);
int index = rowMeta.indexOfValue(binding.fieldName);
if (index < 0) {
+ clearFrom(rowMeta, row, i + 1);
throw new HopException(
BaseMessages.getString(PKG, "MaskFields.Error.FieldMissing",
binding.fieldName));
}
@@ -56,10 +63,23 @@ public class MaskingEngine {
try {
row[index] = mask(binding, valueMeta, row[index]);
} catch (HopValueException e) {
+ clearFrom(rowMeta, row, i);
throw new HopException(
BaseMessages.getString(
PKG, "MaskFields.Error.Convert", binding.fieldName,
binding.pattern.getName()),
e);
+ } catch (HopException | RuntimeException e) {
+ clearFrom(rowMeta, row, i);
+ throw e;
+ }
+ }
+ }
+
+ private void clearFrom(IRowMeta rowMeta, Object[] row, int first) {
+ for (int i = first; i < bindings.size(); i++) {
+ int index = rowMeta.indexOfValue(bindings.get(i).fieldName);
+ if (index >= 0 && index < row.length) {
+ row[index] = null;
}
}
}
@@ -78,21 +98,22 @@ public class MaskingEngine {
if (source == MaskingValueSource.SET_EMPTY) {
return "";
}
- String key = valueMeta.getString(value);
- if (StringUtils.isEmpty(key)) {
+ String normalized = binding.key.normalize(valueMeta, value);
+ if (StringUtils.isEmpty(normalized)) {
return value;
}
- return toField(valueMeta, syntheticValue(binding, valueMeta, key));
+ return toField(valueMeta, syntheticValue(binding, valueMeta, value,
normalized));
}
- private String syntheticValue(Binding binding, IValueMeta valueMeta, String
key)
- throws HopException {
+ private String syntheticValue(
+ Binding binding, IValueMeta valueMeta, Object value, String normalized)
throws HopException {
if (!binding.pattern.remembers()) {
return formatSynthetic(binding, valueMeta, nextToken(binding));
}
return binding.store.findOrCreate(
binding.pattern.getName(),
- key,
+ binding.key.storeKey(normalized),
+ () -> MaskingKey.legacyKey(valueMeta, value),
store -> formatSynthetic(binding, valueMeta, nextToken(binding,
store)));
}
@@ -136,6 +157,7 @@ public class MaskingEngine {
final IMaskingStore store;
final AtomicLong localSequence;
final AtomicLong sharedSequence;
+ final MaskingKey key;
public Binding(
String fieldName,
@@ -155,6 +177,18 @@ public class MaskingEngine {
long sequenceStart,
IMaskingStore store,
AtomicLong sharedSequence) {
+ this(fieldName, pattern, prefix, suffix, sequenceStart, store,
sharedSequence, null);
+ }
+
+ public Binding(
+ String fieldName,
+ MaskingPattern pattern,
+ String prefix,
+ String suffix,
+ long sequenceStart,
+ IMaskingStore store,
+ AtomicLong sharedSequence,
+ MaskingKey key) {
this.fieldName = fieldName;
this.pattern = pattern;
this.prefix = prefix == null ? "" : prefix;
@@ -163,6 +197,7 @@ public class MaskingEngine {
this.store = store;
this.sharedSequence = sharedSequence;
this.localSequence = sharedSequence == null ? new
AtomicLong(sequenceStart) : null;
+ this.key = key == null ? MaskingKey.plain() : key;
}
}
}
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingKey.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingKey.java
new file mode 100644
index 0000000000..7883cfa08f
--- /dev/null
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingKey.java
@@ -0,0 +1,157 @@
+/*
+ * 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.pipeline.transforms.maskfields;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.util.HexFormat;
+import java.util.Locale;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.util.StringUtil;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+
+/**
+ * Builds the key a remembering pattern finds a source value by. The key
ignores the field's
+ * conversion mask, can ignore spaces around the value and its case, and is an
HMAC of the value
+ * when the pattern has a hash secret.
+ */
+public final class MaskingKey {
+
+ private static final Class<?> PKG = MaskingKey.class;
+
+ static final String HMAC_PREFIX = "hmac-sha256:";
+ private static final String ALGORITHM = "HmacSHA256";
+
+ private final boolean trim;
+ private final boolean ignoreCase;
+ private final Mac mac;
+
+ public MaskingKey(boolean trim, boolean ignoreCase, String secret) throws
HopException {
+ this.trim = trim;
+ this.ignoreCase = ignoreCase;
+ if (StringUtils.isEmpty(secret)) {
+ this.mac = null;
+ } else {
+ try {
+ Mac hmac = Mac.getInstance(ALGORITHM);
+ hmac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),
ALGORITHM));
+ this.mac = hmac;
+ } catch (GeneralSecurityException e) {
+ throw new HopException("Unable to initialize " + ALGORITHM, e);
+ }
+ }
+ }
+
+ /**
+ * The key for a pattern. Only a database pattern hashes its keys. A hash
secret that resolves to
+ * nothing, still holds a variable or does not decrypt is an error: hashing
with the literal text
+ * or falling back to plain text would both go unnoticed.
+ */
+ public static MaskingKey forPattern(MaskingPattern pattern, IVariables
variables)
+ throws HopException {
+ String secret = null;
+ if (pattern.getStorage() == MaskingStorage.DATABASE
+ && StringUtils.isNotEmpty(pattern.getHashSecret())) {
+ String resolved = variables.resolve(pattern.getHashSecret());
+ if (!StringUtils.isEmpty(resolved) &&
!StringUtil.containsVariableToken(resolved)) {
+ // An encrypted value that is not a valid ciphertext decrypts to an
empty string.
+ secret = Encr.decryptPasswordOptionallyEncrypted(resolved);
+ }
+ if (StringUtils.isEmpty(secret)) {
+ throw new HopException(
+ BaseMessages.getString(
+ PKG, "MaskFields.Error.HashSecretUnresolved",
pattern.getName()));
+ }
+ }
+ return new MaskingKey(pattern.isTrimKey(), pattern.isIgnoreCase(), secret);
+ }
+
+ /** Plain key: no trimming, case-sensitive and not hashed. */
+ public static MaskingKey plain() {
+ try {
+ return new MaskingKey(false, false, null);
+ } catch (HopException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * @return the value as the pattern compares it, or null or empty when there
is nothing to mask
+ */
+ public String normalize(IValueMeta valueMeta, Object value) throws
HopValueException {
+ if (value == null) {
+ return null;
+ }
+ String text = canonical(valueMeta, value);
+ if (text == null) {
+ return null;
+ }
+ if (trim) {
+ text = text.trim();
+ }
+ if (ignoreCase) {
+ text = text.toLowerCase(Locale.ROOT);
+ }
+ return text;
+ }
+
+ /** The key stored in a mapping, built from {@link #normalize(IValueMeta,
Object)}. */
+ public String storeKey(String normalized) {
+ if (mac == null) {
+ return normalized;
+ }
+ byte[] digest;
+ synchronized (mac) {
+ digest = mac.doFinal(normalized.getBytes(StandardCharsets.UTF_8));
+ }
+ return HMAC_PREFIX + HexFormat.of().formatHex(digest);
+ }
+
+ /**
+ * The key earlier versions stored: the value as the field formats it, in
plain text. A mapping
+ * table can still hold rows with that key.
+ */
+ public static String legacyKey(IValueMeta valueMeta, Object value) throws
HopValueException {
+ return valueMeta.getString(value);
+ }
+
+ private static String canonical(IValueMeta valueMeta, Object value) throws
HopValueException {
+ if (!valueMeta.isNumeric()) {
+ return valueMeta.getString(value);
+ }
+ Object number = valueMeta.getNativeDataType(value);
+ if (number instanceof Long longValue) {
+ return Long.toString(longValue);
+ }
+ if (number instanceof Double doubleValue && Double.isFinite(doubleValue)) {
+ return
BigDecimal.valueOf(doubleValue).stripTrailingZeros().toPlainString();
+ }
+ if (number instanceof BigDecimal bigDecimal) {
+ return bigDecimal.stripTrailingZeros().toPlainString();
+ }
+ return valueMeta.getString(value);
+ }
+}
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPattern.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPattern.java
index cc6c539be2..d5320cd59f 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPattern.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPattern.java
@@ -57,6 +57,9 @@ public class MaskingPattern extends HopMetadataBase
implements IHopMetadata {
public static final String WIDGET_SUFFIX = "suffix";
public static final String WIDGET_SEQUENCE_START = "sequenceStart";
public static final String WIDGET_STORAGE = "storage";
+ public static final String WIDGET_TRIM_KEY = "trimKey";
+ public static final String WIDGET_IGNORE_CASE = "ignoreCase";
+ public static final String WIDGET_HASH_SECRET = "hashSecret";
public static final String WIDGET_CONNECTION = "connection";
public static final String WIDGET_SCHEMA = "schemaName";
public static final String WIDGET_TABLE = "tableName";
@@ -175,6 +178,32 @@ public class MaskingPattern extends HopMetadataBase
implements IHopMetadata {
toolTip = "i18n::MaskingPattern.Storage.Tooltip")
private MaskingStorage storage = MaskingStorage.NONE;
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_TRIM_KEY,
+ order = "0910",
+ type = GuiElementType.CHECKBOX,
+ parentId = GUI_WIDGETS_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_STORAGE,
+ groupOrder = "30",
+ label = "i18n::MaskingPattern.TrimKey.Label",
+ toolTip = "i18n::MaskingPattern.TrimKey.Tooltip")
+ private boolean trimKey;
+
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_IGNORE_CASE,
+ order = "0920",
+ type = GuiElementType.CHECKBOX,
+ parentId = GUI_WIDGETS_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_STORAGE,
+ groupOrder = "30",
+ label = "i18n::MaskingPattern.IgnoreCase.Label",
+ toolTip = "i18n::MaskingPattern.IgnoreCase.Tooltip")
+ private boolean ignoreCase;
+
@HopMetadataProperty(hopMetadataPropertyType =
HopMetadataPropertyType.RDBMS_CONNECTION)
@GuiWidgetElement(
id = WIDGET_CONNECTION,
@@ -215,6 +244,20 @@ public class MaskingPattern extends HopMetadataBase
implements IHopMetadata {
toolTip = "i18n::MaskingPattern.Table.Tooltip")
private String tableName = "";
+ @HopMetadataProperty(password = true)
+ @GuiWidgetElement(
+ id = WIDGET_HASH_SECRET,
+ order = "1300",
+ type = GuiElementType.TEXT,
+ password = true,
+ parentId = GUI_WIDGETS_PARENT_ID,
+ groupType = GuiWidgetGroupType.BOXES,
+ group = GROUP_STORAGE,
+ groupOrder = "30",
+ label = "i18n::MaskingPattern.HashSecret.Label",
+ toolTip = "i18n::MaskingPattern.HashSecret.Tooltip")
+ private String hashSecret = "";
+
public MaskingPattern() {}
/** Suggestions for the classification combo. A project can type its own
term. */
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPatternEditor.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPatternEditor.java
index bbe29209a4..19d477614c 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPatternEditor.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingPatternEditor.java
@@ -152,13 +152,16 @@ public class MaskingPatternEditor extends
MetadataEditor<MaskingPattern> {
}
if (removal) {
hidden.add(MaskingPattern.WIDGET_STORAGE);
+ }
+ if (removal || storage == MaskingStorage.NONE) {
+ hidden.add(MaskingPattern.WIDGET_TRIM_KEY);
+ hidden.add(MaskingPattern.WIDGET_IGNORE_CASE);
+ }
+ if (removal || storage != MaskingStorage.DATABASE) {
hidden.add(MaskingPattern.WIDGET_CONNECTION);
hidden.add(MaskingPattern.WIDGET_SCHEMA);
hidden.add(MaskingPattern.WIDGET_TABLE);
- } else if (storage != MaskingStorage.DATABASE) {
- hidden.add(MaskingPattern.WIDGET_CONNECTION);
- hidden.add(MaskingPattern.WIDGET_SCHEMA);
- hidden.add(MaskingPattern.WIDGET_TABLE);
+ hidden.add(MaskingPattern.WIDGET_HASH_SECRET);
}
widgets.setWidgetsHidden(getMetadata(), hidden);
relayoutScrolledContent();
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntime.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntime.java
index 925b9f8841..9ca3838578 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntime.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntime.java
@@ -17,12 +17,16 @@
package org.apache.hop.pipeline.transforms.maskfields;
+import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
+import org.apache.hop.core.Const;
import org.apache.hop.core.database.DatabaseMeta;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.util.StringUtil;
import org.apache.hop.core.variables.IVariables;
import
org.apache.hop.pipeline.transforms.maskfields.store.DatabaseMaskingStore;
import org.apache.hop.pipeline.transforms.maskfields.store.MemoryMaskingStore;
@@ -59,6 +63,7 @@ public final class MaskingRuntime {
private final String executionId;
private final ExecutionState state;
private final Set<String> databaseKeys = ConcurrentHashMap.newKeySet();
+ private final List<DatabaseMaskingStore> ownStores = new
CopyOnWriteArrayList<>();
private boolean released;
private Lease(String executionId, ExecutionState state) {
@@ -79,7 +84,12 @@ public final class MaskingRuntime {
return state.sequences.computeIfAbsent(key, name -> new
AtomicLong(start));
}
- /** One open connection for this database target, shared by every copy in
the JVM. */
+ /**
+ * One open connection for this database target, shared by every copy in
the JVM. The target is
+ * the resolved URL and user, so two projects with a connection of the
same name that point at
+ * different databases do not share a store. A URL that still holds a
variable does not say
+ * which database it opens, so this copy gets a store of its own.
+ */
public DatabaseMaskingStore database(
ILoggingObject parent,
IVariables variables,
@@ -87,7 +97,19 @@ public final class MaskingRuntime {
String schemaName,
String tableName)
throws HopException {
- String key = databaseKey(databaseMeta, schemaName, tableName);
+ String key = databaseKey(variables, databaseMeta, schemaName, tableName);
+ if (key == null) {
+ DatabaseMaskingStore store =
+ new DatabaseMaskingStore(parent, variables, databaseMeta,
schemaName, tableName);
+ try {
+ store.open();
+ } catch (HopException e) {
+ store.close();
+ throw e;
+ }
+ ownStores.add(store);
+ return store;
+ }
while (true) {
DatabaseEntry entry = databases.computeIfAbsent(key, name -> new
DatabaseEntry());
synchronized (entry) {
@@ -131,6 +153,9 @@ public final class MaskingRuntime {
state.memory.close();
}
}
+ for (DatabaseMaskingStore store : ownStores) {
+ store.close();
+ }
for (String key : databaseKeys) {
DatabaseEntry entry = databases.get(key);
if (entry == null) {
@@ -150,12 +175,22 @@ public final class MaskingRuntime {
}
}
- private static String databaseKey(
- DatabaseMeta databaseMeta, String schemaName, String tableName) {
- String name = databaseMeta == null ? "" : databaseMeta.getName();
- String schema = schemaName == null ? "" : schemaName;
- String table = tableName == null ? "" : tableName;
- return name + "\0" + schema + "\0" + table;
+ /**
+ * @return the URL a connection opens, its user and the table, or null when
the URL still holds a
+ * variable
+ */
+ static String databaseKey(
+ IVariables variables, DatabaseMeta databaseMeta, String schemaName,
String tableName)
+ throws HopException {
+ // A manual URL comes back from getURL() as entered. Database.connect()
resolves it.
+ String url = Const.NVL(variables.resolve(databaseMeta.getURL(variables)),
"");
+ if (StringUtil.containsVariableToken(url)) {
+ return null;
+ }
+ String user = Const.NVL(variables.resolve(databaseMeta.getUsername()), "");
+ String schema = Const.NVL(schemaName, "");
+ String table = Const.NVL(tableName, "");
+ return url + "\0" + user + "\0" + schema + "\0" + table;
}
private static final class ExecutionState {
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStore.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStore.java
index ca6e3e45f5..ee4a6c00ac 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStore.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStore.java
@@ -20,7 +20,9 @@ package org.apache.hop.pipeline.transforms.maskfields.store;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import org.apache.hop.core.Result;
import org.apache.hop.core.RowMetaAndData;
import org.apache.hop.core.database.Database;
import org.apache.hop.core.database.DatabaseMeta;
@@ -45,6 +47,12 @@ public class DatabaseMaskingStore implements IMaskingStore {
static final String COLUMN_MASKED = "masked_value";
static final String COLUMN_NEXT = "next_value";
+ /** Length of the masked value column in a new mapping table. */
+ public static final int MASKED_LENGTH = 255;
+
+ /** Attempts of one lookup that loses a race with another process before it
gives up. */
+ static final int MAX_ATTEMPTS = 5;
+
private final ILoggingObject parent;
private final IVariables variables;
private final DatabaseMeta databaseMeta;
@@ -52,6 +60,9 @@ public class DatabaseMaskingStore implements IMaskingStore {
private final String tableName;
private final Map<String, Map<String, String>> cache = new
ConcurrentHashMap<>();
+ /** Legacy keys per pattern this store already looked up and moved or
removed. */
+ private final Map<String, Set<String>> legacyDone = new
ConcurrentHashMap<>();
+
private Database database;
private String mapTable;
private String sequenceTable;
@@ -90,64 +101,120 @@ public class DatabaseMaskingStore implements
IMaskingStore {
database.setAutoCommit(false);
}
+ @Override
+ public String findOrCreate(String patternName, String sourceKey,
MaskAllocator allocator)
+ throws HopException {
+ return findOrCreate(patternName, sourceKey, null, allocator);
+ }
+
+ /**
+ * Finds or stores the replacement in one transaction. When another process
stores the same source
+ * key, the next sequence row or the same replacement at the same moment,
the constraint violation
+ * rolls the attempt back and the next attempt sees what that process
committed.
+ *
+ * <p>A row under the legacy key moves to {@code sourceKey}. When {@code
sourceKey} already has a
+ * row, for example because a second spelling folds onto the same key, the
legacy row is removed
+ * so the original value does not stay in the table.
+ */
@Override
public synchronized String findOrCreate(
- String patternName, String sourceKey, MaskAllocator allocator) throws
HopException {
+ String patternName, String sourceKey, KeySupplier legacyKey,
MaskAllocator allocator)
+ throws HopException {
Map<String, String> patternCache =
cache.computeIfAbsent(patternName, k -> new ConcurrentHashMap<>());
+ Set<String> patternLegacyDone =
+ legacyDone.computeIfAbsent(patternName, k ->
ConcurrentHashMap.newKeySet());
+ String oldKey = legacyKey == null ? null : legacyKey.get();
+ if (oldKey != null && (oldKey.equals(sourceKey) ||
patternLegacyDone.contains(oldKey))) {
+ oldKey = null;
+ }
String cached = patternCache.get(sourceKey);
- if (cached != null) {
+ if (cached != null && oldKey == null) {
return cached;
}
ensureOpen();
- try {
- String existing = lookup(patternName, sourceKey);
- if (existing != null) {
+ for (int attempt = 1; ; attempt++) {
+ try {
+ String masked = cached != null ? cached : lookup(patternName,
sourceKey);
+ if (oldKey != null) {
+ String oldMasked = lookup(patternName, oldKey);
+ if (oldMasked != null) {
+ if (masked == null) {
+ rekey(patternName, oldKey, sourceKey);
+ masked = oldMasked;
+ } else {
+ delete(patternName, oldKey);
+ }
+ }
+ }
+ if (masked == null) {
+ masked = allocator.allocate(this);
+ insertMap(patternName, sourceKey, masked);
+ }
database.commit();
- patternCache.put(sourceKey, existing);
- return existing;
- }
- String created = allocator.allocate(this);
- insertMap(patternName, sourceKey, created);
- database.commit();
- patternCache.put(sourceKey, created);
- return created;
- } catch (HopException e) {
- rollbackQuietly();
- if (isConstraintViolation(e)) {
- String winner = lookup(patternName, sourceKey);
- if (winner != null) {
- database.commit();
- patternCache.put(sourceKey, winner);
- return winner;
+ patternCache.put(sourceKey, masked);
+ if (oldKey != null) {
+ patternLegacyDone.add(oldKey);
+ }
+ return masked;
+ } catch (HopException e) {
+ rollbackQuietly();
+ if (!isConstraintViolation(e)) {
+ throw e;
+ }
+ if (attempt >= MAX_ATTEMPTS) {
+ // The database's message names the conflicting key, which can be
the original value.
+ // Leave the cause out so that value does not reach the log.
+ throw new HopException(
+ "Unable to store a replacement for pattern '"
+ + patternName
+ + "' after "
+ + MAX_ATTEMPTS
+ + " attempts: other processes kept storing conflicting rows
in "
+ + mapTable);
}
}
- throw e;
}
}
+ /**
+ * Next sequence value. The update locks the pattern's row until the caller
commits, so a second
+ * process waits and then continues from the committed value.
+ */
@Override
public synchronized long allocateSequence(String patternName, long start)
throws HopException {
ensureOpen();
- Long current = queryLong(selectNextSql(), oneString(), new Object[]
{patternName});
- if (current == null) {
- exec(
- "INSERT INTO "
- + sequenceTable
- + " ("
- + quotedPattern
- + ", "
- + quotedNext
- + ") VALUES (?, ?)",
- stringAndLong(),
- new Object[] {patternName, start + 1});
- return start;
+ Result result =
+ database.execStatement(
+ "UPDATE "
+ + sequenceTable
+ + " SET "
+ + quotedNext
+ + " = "
+ + quotedNext
+ + " + 1 WHERE "
+ + quotedPattern
+ + " = ?",
+ oneString(),
+ new Object[] {patternName});
+ if (result.getNrLinesUpdated() > 0) {
+ Long next = queryLong(selectNextSql(), oneString(), new Object[]
{patternName});
+ if (next == null) {
+ throw new HopException("The sequence row for pattern '" + patternName
+ "' disappeared");
+ }
+ return next - 1;
}
exec(
- "UPDATE " + sequenceTable + " SET " + quotedNext + " = ? WHERE " +
quotedPattern + " = ?",
- longAndString(),
- new Object[] {current + 1, patternName});
- return current;
+ "INSERT INTO "
+ + sequenceTable
+ + " ("
+ + quotedPattern
+ + ", "
+ + quotedNext
+ + ") VALUES (?, ?)",
+ stringAndLong(),
+ new Object[] {patternName, start + 1});
+ return start;
}
@Override
@@ -164,6 +231,7 @@ public class DatabaseMaskingStore implements IMaskingStore {
@Override
public synchronized void close() {
cache.clear();
+ legacyDone.clear();
if (database != null) {
database.disconnect();
database = null;
@@ -175,7 +243,9 @@ public class DatabaseMaskingStore implements IMaskingStore {
return;
}
// Primary-key columns have to be NOT NULL. The generic CREATE TABLE
leaves them nullable, and
- // H2 rejects a primary key on a nullable column.
+ // H2 rejects a primary key on a nullable column. The unique key stops two
source values from
+ // sharing a replacement, which keeps the masked value short enough to
index on every database.
+ // Tables created by earlier versions keep their layout.
database.execStatement(
"CREATE TABLE "
+ mapTable
@@ -184,11 +254,15 @@ public class DatabaseMaskingStore implements
IMaskingStore {
+ ", "
+ columnDefinition(stringColumn(COLUMN_SOURCE, 255))
+ ", "
- + columnDefinition(stringColumn(COLUMN_MASKED, 2000))
+ + columnDefinition(stringColumn(COLUMN_MASKED, MASKED_LENGTH))
+ ", PRIMARY KEY ("
+ quotedPattern
+ ", "
+ quotedSource
+ + "), UNIQUE ("
+ + quotedPattern
+ + ", "
+ + quotedMasked
+ "))");
}
@@ -249,6 +323,28 @@ public class DatabaseMaskingStore implements IMaskingStore
{
new Object[] {patternName, sourceKey, masked});
}
+ private void rekey(String patternName, String oldKey, String newKey) throws
HopException {
+ exec(
+ "UPDATE "
+ + mapTable
+ + " SET "
+ + quotedSource
+ + " = ? WHERE "
+ + quotedPattern
+ + " = ? AND "
+ + quotedSource
+ + " = ?",
+ rekeyParameters(),
+ new Object[] {newKey, patternName, oldKey});
+ }
+
+ private void delete(String patternName, String sourceKey) throws
HopException {
+ exec(
+ "DELETE FROM " + mapTable + " WHERE " + quotedPattern + " = ? AND " +
quotedSource + " = ?",
+ twoStrings(),
+ new Object[] {patternName, sourceKey});
+ }
+
private String selectNextSql() {
return "SELECT " + quotedNext + " FROM " + sequenceTable + " WHERE " +
quotedPattern + " = ?";
}
@@ -342,16 +438,17 @@ public class DatabaseMaskingStore implements
IMaskingStore {
return meta;
}
- private static IRowMeta stringAndLong() {
- RowMeta meta = oneString();
- meta.addValueMeta(new ValueMetaInteger(COLUMN_NEXT));
+ private static RowMeta rekeyParameters() {
+ RowMeta meta = new RowMeta();
+ meta.addValueMeta(new ValueMetaString(COLUMN_SOURCE));
+ meta.addValueMeta(new ValueMetaString(COLUMN_PATTERN));
+ meta.addValueMeta(new ValueMetaString("old_" + COLUMN_SOURCE));
return meta;
}
- private static IRowMeta longAndString() {
- RowMeta meta = new RowMeta();
+ private static IRowMeta stringAndLong() {
+ RowMeta meta = oneString();
meta.addValueMeta(new ValueMetaInteger(COLUMN_NEXT));
- meta.addValueMeta(new ValueMetaString(COLUMN_PATTERN));
return meta;
}
}
diff --git
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/IMaskingStore.java
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/IMaskingStore.java
index cb0380c6ef..c2b90d4424 100644
---
a/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/IMaskingStore.java
+++
b/plugins/transforms/maskfields/src/main/java/org/apache/hop/pipeline/transforms/maskfields/store/IMaskingStore.java
@@ -31,6 +31,18 @@ public interface IMaskingStore extends AutoCloseable {
String findOrCreate(String patternName, String sourceKey, MaskAllocator
allocator)
throws HopException;
+ /**
+ * Like {@link #findOrCreate(String, String, MaskAllocator)}, but also finds
a mapping stored
+ * under the key an earlier version wrote, and moves it to {@code
sourceKey}, or removes it when
+ * {@code sourceKey} already has a mapping. A store that never kept mappings
across versions
+ * ignores it.
+ */
+ default String findOrCreate(
+ String patternName, String sourceKey, KeySupplier legacyKey,
MaskAllocator allocator)
+ throws HopException {
+ return findOrCreate(patternName, sourceKey, allocator);
+ }
+
/** Next sequence value for the pattern, starting at {@code start} the first
time. */
long allocateSequence(String patternName, long start) throws HopException;
@@ -40,6 +52,12 @@ public interface IMaskingStore extends AutoCloseable {
@Override
void close();
+ /** Supplies a key only when the store needs it. */
+ @FunctionalInterface
+ interface KeySupplier {
+ String get() throws HopException;
+ }
+
/** Builds the replacement string for a new source key. */
@FunctionalInterface
interface MaskAllocator {
diff --git
a/plugins/transforms/maskfields/src/main/resources/org/apache/hop/pipeline/transforms/maskfields/messages/messages_en_US.properties
b/plugins/transforms/maskfields/src/main/resources/org/apache/hop/pipeline/transforms/maskfields/messages/messages_en_US.properties
index c2292826e6..9872821700 100644
---
a/plugins/transforms/maskfields/src/main/resources/org/apache/hop/pipeline/transforms/maskfields/messages/messages_en_US.properties
+++
b/plugins/transforms/maskfields/src/main/resources/org/apache/hop/pipeline/transforms/maskfields/messages/messages_en_US.properties
@@ -46,10 +46,13 @@ MaskFields.Check.Incompatible=Field ''{0}'' cannot use
masking pattern ''{1}'':
MaskFields.Check.NoConnection=Masking pattern ''{0}'' stores mappings in a
database and has no connection.
MaskFields.Check.MissingConnection=Database connection ''{0}'' for masking
pattern ''{1}'' was not found.
MaskFields.Check.NoTable=Masking pattern ''{0}'' stores mappings in a database
and has no table name.
+MaskFields.Check.MaskedTooLong=Masking pattern ''{0}'' can write values of up
to {1} characters. A mapping table created by this version holds {2}.
+MaskFields.Check.PlainTextKeys=Masking pattern ''{0}'' has no hash secret, so
its mapping table stores the original values in plain text.
MaskFields.Error.FieldMissing=Field ''{0}'' is no longer in the input row.
MaskFields.Error.Convert=Unable to write the masked value into field ''{0}''
with pattern ''{1}''.
MaskFields.Error.BadSequence=Sequence start ''{0}'' on masking pattern ''{1}''
is not an integer.
+MaskFields.Error.HashSecretUnresolved=The hash secret of masking pattern
''{0}'' is empty, holds a variable that is not set, or does not decrypt.
MaskFields.Error.OpenStore=Unable to open the mapping table for masking
pattern ''{0}''.
MaskingRules.SetEmpty=Set to blank only applies to a string field.
@@ -89,10 +92,16 @@ MaskingPattern.Suffix.Tooltip=Text written after the token.
MaskingPattern.SequenceStart.Label=Sequence start
MaskingPattern.SequenceStart.Tooltip=First integer to use. The default is 1.
MaskingPattern.Storage.Label=Remember values
-MaskingPattern.Storage.Tooltip=NONE assigns a new value every row. MEMORY
keeps the mapping for this run. DATABASE keeps it in a table across runs. The
table stores the original value.
+MaskingPattern.Storage.Tooltip=NONE assigns a new value every row. MEMORY
keeps the mapping for this run. DATABASE keeps it in a table across runs.
+MaskingPattern.TrimKey.Label=Ignore surrounding spaces
+MaskingPattern.TrimKey.Tooltip=Values that only differ in leading or trailing
spaces get the same replacement. A value of only spaces is left unchanged.
+MaskingPattern.IgnoreCase.Label=Ignore case
+MaskingPattern.IgnoreCase.Tooltip=Values that only differ in upper and lower
case get the same replacement.
+MaskingPattern.HashSecret.Label=Hash secret
+MaskingPattern.HashSecret.Tooltip=The mapping table stores an HMAC-SHA256 of
each value made with this secret, instead of the value itself. Use a variable
such as '${MASKING_SECRET}' and keep the secret out of the project. Changing
the secret starts a new mapping. Without a secret the table stores the original
values.
MaskingPattern.Connection.Label=Connection
MaskingPattern.Connection.Tooltip=Database that holds the mapping table.
MaskingPattern.Schema.Label=Schema
MaskingPattern.Schema.Tooltip=Schema of the mapping table. Leave this empty to
use the connection default.
MaskingPattern.Table.Label=Table
-MaskingPattern.Table.Tooltip=Mapping table. A second table with the suffix
_seq stores the next sequence number. Both are created when they are missing.
+MaskingPattern.Table.Tooltip=Mapping table. A second table with the suffix
_seq stores the next sequence number. Both are created when they are missing. A
new mapping table holds replacements of up to 255 characters.
diff --git
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMetaTest.java
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMetaTest.java
index 8cd58245a9..130046b72e 100644
---
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMetaTest.java
+++
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskFieldsMetaTest.java
@@ -18,6 +18,7 @@
package org.apache.hop.pipeline.transforms.maskfields;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -86,6 +87,55 @@ class MaskFieldsMetaTest {
assertTrue(messages(remarks).stream().anyMatch(text ->
text.contains("Missing")));
}
+ @Test
+ void warnsAboutPlainTextKeysAndLongReplacements() throws Exception {
+ MemoryMetadataProvider provider = new MemoryMetadataProvider();
+ MaskingPattern plain = synthetic("Plain", MaskingToken.SEQUENCE);
+ plain.setStorage(MaskingStorage.DATABASE);
+ plain.setConnection("db");
+ plain.setTableName("mask_map");
+ save(provider, plain);
+ MaskingPattern hashed = synthetic("Hashed", MaskingToken.UUID);
+ hashed.setStorage(MaskingStorage.DATABASE);
+ hashed.setConnection("db");
+ hashed.setTableName("mask_map");
+ hashed.setHashSecret("${SECRET}");
+ hashed.setPrefix("${PREFIX}");
+ save(provider, hashed);
+
+ MaskFieldsMeta meta = new MaskFieldsMeta();
+ meta.getFields().add(new MaskField("name", "Plain"));
+ meta.getFields().add(new MaskField("city", "Hashed"));
+ RowMeta prev = new RowMeta();
+ prev.addValueMeta(new ValueMetaString("name"));
+ prev.addValueMeta(new ValueMetaString("city"));
+ Variables variables = new Variables();
+ variables.setVariable("PREFIX", "x".repeat(250));
+
+ List<ICheckResult> remarks = new ArrayList<>();
+ TransformMeta transform = new TransformMeta();
+ transform.setName("Mask");
+ meta.check(
+ remarks,
+ null,
+ transform,
+ prev,
+ new String[] {"in"},
+ new String[0],
+ null,
+ variables,
+ provider);
+
+ List<String> warnings =
+ remarks.stream()
+ .filter(remark -> remark.getType() ==
ICheckResult.TYPE_RESULT_WARNING)
+ .map(ICheckResult::getText)
+ .toList();
+ assertEquals(2, warnings.size(), warnings.toString());
+ assertTrue(warnings.get(0).contains("Plain") &&
warnings.get(0).contains("plain text"));
+ assertTrue(warnings.get(1).contains("Hashed") &&
warnings.get(1).contains("286"));
+ }
+
@Test
void roundTripsTheTransformAndThePattern() throws Exception {
MaskFieldsMeta meta = new MaskFieldsMeta();
@@ -101,6 +151,9 @@ class MaskFieldsMetaTest {
pattern.setPrefix("first-name-");
pattern.setPiiClassification("Direct identifier");
pattern.setStorage(MaskingStorage.MEMORY);
+ pattern.setTrimKey(true);
+ pattern.setIgnoreCase(true);
+ pattern.setHashSecret("s3cret");
MemoryMetadataProvider provider = new MemoryMetadataProvider();
JsonMetadataParser<MaskingPattern> parser =
new JsonMetadataParser<>(MaskingPattern.class, provider);
@@ -113,6 +166,10 @@ class MaskFieldsMetaTest {
assertEquals("Direct identifier", loaded.getPiiClassification());
assertEquals(MaskingStorage.MEMORY, loaded.getStorage());
assertEquals(MaskingValueSource.SYNTHETIC, loaded.getValueSource());
+ assertTrue(loaded.isTrimKey());
+ assertTrue(loaded.isIgnoreCase());
+ assertEquals("s3cret", loaded.getHashSecret());
+ assertFalse(json.toJSONString().contains("s3cret"));
assertNull(MaskingRules.incompatibility(new ValueMetaString("name"),
loaded));
}
}
diff --git
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngineTest.java
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngineTest.java
index bc4a51ac09..1a670ca33a 100644
---
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngineTest.java
+++
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingEngineTest.java
@@ -17,15 +17,19 @@
package org.apache.hop.pipeline.transforms.maskfields;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.UUID;
+import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.row.RowMeta;
import org.apache.hop.core.row.value.ValueMetaString;
import org.apache.hop.pipeline.transforms.maskfields.MaskingEngine.Binding;
+import org.apache.hop.pipeline.transforms.maskfields.store.IMaskingStore;
import org.apache.hop.pipeline.transforms.maskfields.store.MemoryMaskingStore;
import org.junit.jupiter.api.Test;
@@ -94,6 +98,45 @@ class MaskingEngineTest {
assertEquals("", masked[1]);
}
+ @Test
+ void aFailingFieldClearsItselfAndTheFieldsAfterIt() throws Exception {
+ MaskingPattern first = synthetic("First name", MaskingStorage.NONE, "fn-",
"", "1");
+ MaskingPattern broken = synthetic("Broken", MaskingStorage.MEMORY, "x-",
"", "1");
+ IMaskingStore failing =
+ new MemoryMaskingStore() {
+ @Override
+ public synchronized String findOrCreate(
+ String patternName, String sourceKey, MaskAllocator allocator)
throws HopException {
+ throw new HopException("mapping table is gone");
+ }
+ };
+ MaskingEngine engine =
+ engine(
+ binding("first", first, null),
+ new Binding("last", broken, "x-", "", 1, failing),
+ binding("city", first, null));
+ RowMeta rowMeta = stringRow("first", "last", "city", "untouched");
+ Object[] row = {"Matt", "Casters", "Gent", "kept"};
+
+ assertThrows(HopException.class, () -> engine.apply(rowMeta, row));
+ assertArrayEquals(new Object[] {"fn-1", null, null, "kept"}, row);
+ }
+
+ @Test
+ void aRememberedValueIgnoresCaseAndSpacesWhenAsked() throws Exception {
+ MaskingPattern pattern = synthetic("First name", MaskingStorage.MEMORY,
"fn-", "", "1");
+ MemoryMaskingStore store = new MemoryMaskingStore();
+ long start = 1L;
+ MaskingEngine engine =
+ engine(
+ new Binding(
+ "name", pattern, "fn-", "", start, store, null, new
MaskingKey(true, true, null)));
+ RowMeta rowMeta = stringRow("name");
+ assertEquals("fn-1", apply(engine, rowMeta, "Matt")[0]);
+ assertEquals("fn-1", apply(engine, rowMeta, " MATT ")[0]);
+ assertEquals(" ", apply(engine, rowMeta, " ")[0]);
+ }
+
private static MaskingPattern synthetic(
String name, MaskingStorage storage, String prefix, String suffix,
String start) {
MaskingPattern pattern = new MaskingPattern();
@@ -107,7 +150,7 @@ class MaskingEngineTest {
return pattern;
}
- private static Binding binding(String field, MaskingPattern pattern,
MemoryMaskingStore store) {
+ private static Binding binding(String field, MaskingPattern pattern,
IMaskingStore store) {
long start = 1L;
if (pattern.getSequenceStart() != null &&
!pattern.getSequenceStart().isBlank()) {
start = Long.parseLong(pattern.getSequenceStart().trim());
diff --git
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingKeyTest.java
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingKeyTest.java
new file mode 100644
index 0000000000..d57ddea229
--- /dev/null
+++
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingKeyTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.pipeline.transforms.maskfields;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.value.ValueMetaBigNumber;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.Test;
+
+class MaskingKeyTest {
+
+ @Test
+ void aNumberKeyDoesNotDependOnTheConversionMask() throws Exception {
+ MaskingKey key = MaskingKey.plain();
+ ValueMetaNumber plain = new ValueMetaNumber("amount");
+ ValueMetaNumber formatted = new ValueMetaNumber("amount");
+ formatted.setConversionMask("#,##0.00");
+ assertEquals("1234.5", key.normalize(plain, 1234.5));
+ assertEquals("1234.5", key.normalize(formatted, 1234.5));
+
+ ValueMetaInteger integer = new ValueMetaInteger("id");
+ integer.setConversionMask("000000");
+ assertEquals("42", key.normalize(integer, 42L));
+ assertEquals("000042", MaskingKey.legacyKey(integer, 42L));
+
+ assertEquals("10", key.normalize(new ValueMetaBigNumber("big"), new
BigDecimal("10.000")));
+ }
+
+ @Test
+ void trimAndIgnoreCaseAreOptional() throws Exception {
+ ValueMetaString name = new ValueMetaString("name");
+ assertEquals(" Matt ", MaskingKey.plain().normalize(name, " Matt "));
+ assertEquals("matt", new MaskingKey(true, true, null).normalize(name, "
Matt "));
+ assertEquals("", new MaskingKey(true, false, null).normalize(name, " "));
+ }
+
+ @Test
+ void aHashSecretThatDoesNotResolveIsAnError() {
+ Variables variables = new Variables();
+ variables.setVariable("EMPTY_SECRET", "");
+ variables.setVariable("BROKEN_SECRET", "Encrypted not-a-ciphertext");
+ for (String secret :
+ new String[] {
+ "${NOT_SET}", "${EMPTY_SECRET}", "%%NOT_SET%%", "${BROKEN_SECRET}",
"Encrypted "
+ }) {
+ MaskingPattern pattern = databasePattern(secret);
+ HopException e =
+ assertThrows(HopException.class, () ->
MaskingKey.forPattern(pattern, variables));
+ assertTrue(e.getMessage().contains("Names"), e.getMessage());
+ }
+ }
+
+ @Test
+ void aResolvedHashSecretHashesDatabaseKeysOnly() throws Exception {
+ Variables variables = new Variables();
+ variables.setVariable("SECRET", "s3cret");
+ String expected = new MaskingKey(false, false, "s3cret").storeKey("Matt");
+ assertEquals(
+ expected, MaskingKey.forPattern(databasePattern("${SECRET}"),
variables).storeKey("Matt"));
+
+ MaskingPattern memory = databasePattern("${NOT_SET}");
+ memory.setStorage(MaskingStorage.MEMORY);
+ assertEquals("Matt", MaskingKey.forPattern(memory,
variables).storeKey("Matt"));
+ assertEquals("Matt", MaskingKey.forPattern(databasePattern(""),
variables).storeKey("Matt"));
+ }
+
+ private static MaskingPattern databasePattern(String secret) {
+ MaskingPattern pattern = new MaskingPattern();
+ pattern.setName("Names");
+ pattern.setStorage(MaskingStorage.DATABASE);
+ pattern.setHashSecret(secret);
+ return pattern;
+ }
+
+ @Test
+ void aSecretHashesTheStoredKey() throws Exception {
+ MaskingKey key = new MaskingKey(false, false, "s3cret");
+ String hashed = key.storeKey("Matt");
+ assertTrue(hashed.startsWith(MaskingKey.HMAC_PREFIX));
+ assertEquals(MaskingKey.HMAC_PREFIX.length() + 64, hashed.length());
+ assertEquals(hashed, new MaskingKey(false, false,
"s3cret").storeKey("Matt"));
+ assertNotEquals(hashed, new MaskingKey(false, false,
"other").storeKey("Matt"));
+ assertEquals("Matt", MaskingKey.plain().storeKey("Matt"));
+ }
+}
diff --git
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntimeTest.java
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntimeTest.java
index e5627d7784..deb5a1fe36 100644
---
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntimeTest.java
+++
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/MaskingRuntimeTest.java
@@ -18,16 +18,27 @@
package org.apache.hop.pipeline.transforms.maskfields;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import java.util.List;
import java.util.UUID;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.database.DatabaseMeta;
import org.apache.hop.core.row.RowMeta;
import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
import org.apache.hop.pipeline.transforms.maskfields.MaskingEngine.Binding;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class MaskingRuntimeTest {
+ @BeforeAll
+ static void initHop() throws Exception {
+ HopEnvironment.init();
+ }
+
@Test
void memoryIsSharedInsideOneExecutionAndResetForTheNext() throws Exception {
String executionId = uniqueId();
@@ -125,6 +136,23 @@ class MaskingRuntimeTest {
return rowMeta;
}
+ @Test
+ void aManualUrlIsKeyedOnTheUrlAConnectionOpens() throws Exception {
+ DatabaseMeta databaseMeta =
+ new DatabaseMeta("mask", "H2", "Native", "", "unused", "", "sa", "");
+ databaseMeta.setManualUrl("jdbc:h2:mem:${MASK_DB}");
+ Variables one = new Variables();
+ one.setVariable("MASK_DB", "one");
+ Variables two = new Variables();
+ two.setVariable("MASK_DB", "two");
+
+ String key = MaskingRuntime.databaseKey(one, databaseMeta, null,
"mask_map");
+ assertEquals("jdbc:h2:mem:one", key.substring(0, key.indexOf('\0')));
+ assertNotEquals(key, MaskingRuntime.databaseKey(two, databaseMeta, null,
"mask_map"));
+ // Unresolved, the URL does not say which database it opens.
+ assertNull(MaskingRuntime.databaseKey(new Variables(), databaseMeta, null,
"mask_map"));
+ }
+
private static String uniqueId() {
return "runtime-" + UUID.randomUUID();
}
diff --git
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStoreTest.java
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStoreTest.java
index 5695dd17e1..84ae5abcbe 100644
---
a/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStoreTest.java
+++
b/plugins/transforms/maskfields/src/test/java/org/apache/hop/pipeline/transforms/maskfields/store/DatabaseMaskingStoreTest.java
@@ -18,13 +18,29 @@
package org.apache.hop.pipeline.transforms.maskfields.store;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.database.Database;
import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.logging.ILoggingObject;
import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.transforms.maskfields.MaskingKey;
import org.apache.hop.pipeline.transforms.maskfields.MaskingRuntime;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -124,8 +140,221 @@ class DatabaseMaskingStoreTest {
}
}
+ @Test
+ void twoProcessesNeverHandOutTheSameValue() throws Exception {
+ DatabaseMeta databaseMeta = h2();
+ Variables variables = new Variables();
+ // Two stores on one table stand in for two Hop processes: no shared lock,
two connections.
+ DatabaseMaskingStore first =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ DatabaseMaskingStore second =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ first.open();
+ second.open();
+ int perStore = 200;
+ // The first value creates the sequence row. Both processes then race on
updating it.
+ Set<String> masked = new HashSet<>(List.of(allocate(first, "seed")));
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ Future<List<String>> a = executor.submit(() -> allocateAll(first, "a",
perStore));
+ Future<List<String>> b = executor.submit(() -> allocateAll(second, "b",
perStore));
+ masked.addAll(a.get(60, TimeUnit.SECONDS));
+ masked.addAll(b.get(60, TimeUnit.SECONDS));
+ assertEquals(2 * perStore + 1, masked.size());
+ } finally {
+ executor.shutdownNow();
+ first.close();
+ second.close();
+ }
+ }
+
+ @Test
+ void aHashSecretKeepsOriginalValuesOutOfTheTable() throws Exception {
+ DatabaseMeta databaseMeta = h2();
+ Variables variables = new Variables();
+ MaskingKey key = new MaskingKey(false, false, "s3cret");
+ DatabaseMaskingStore store =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ store.open();
+ try {
+ String hashed = key.storeKey("Matt");
+ assertTrue(hashed.startsWith("hmac-sha256:"));
+ assertEquals("first-name-1", allocate(store, hashed, "Matt"));
+ assertEquals("first-name-1", allocate(store, hashed, "Matt"));
+ assertEquals(List.of(hashed), sourceKeys(databaseMeta, variables));
+ } finally {
+ store.close();
+ }
+ }
+
+ @Test
+ void aPlainTextRowMovesToItsHashedKey() throws Exception {
+ DatabaseMeta databaseMeta = h2();
+ Variables variables = new Variables();
+ DatabaseMaskingStore store =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ store.open();
+ try {
+ // A row written before the pattern had a hash secret.
+ assertEquals("first-name-1", allocate(store, "Matt"));
+ } finally {
+ store.close();
+ }
+
+ String hashed = new MaskingKey(false, false, "s3cret").storeKey("Matt");
+ DatabaseMaskingStore later =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ later.open();
+ try {
+ assertEquals("first-name-1", allocate(later, hashed, "Matt"));
+ assertEquals("first-name-2", allocate(later, hashed + "x", "Ann"));
+ } finally {
+ later.close();
+ }
+ List<String> keys = sourceKeys(databaseMeta, variables);
+ assertTrue(keys.contains(hashed));
+ assertFalse(keys.contains("Matt"));
+ }
+
+ @Test
+ void twoStoredSpellingsOfOneKeyLeaveNoPlainTextRow() throws Exception {
+ DatabaseMeta databaseMeta = h2();
+ Variables variables = new Variables();
+ DatabaseMaskingStore store =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ store.open();
+ try {
+ // Rows written before the pattern ignored case.
+ assertEquals("first-name-1", allocate(store, "Matt"));
+ assertEquals("first-name-2", allocate(store, "MATT"));
+ } finally {
+ store.close();
+ }
+
+ DatabaseMaskingStore later =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ later.open();
+ try {
+ assertEquals("first-name-1", allocate(later, "matt", "Matt"));
+ // The new key is cached now. The second spelling still has to leave the
table.
+ assertEquals("first-name-1", allocate(later, "matt", "MATT"));
+ } finally {
+ later.close();
+ }
+ assertEquals(List.of("matt"), sourceKeys(databaseMeta, variables));
+
+ DatabaseMaskingStore again =
+ new DatabaseMaskingStore((ILoggingObject) null, variables,
databaseMeta, null, "mask_map");
+ again.open();
+ try {
+ assertEquals("first-name-1", allocate(again, "matt", "MATT"));
+ } finally {
+ again.close();
+ }
+ assertEquals(List.of("matt"), sourceKeys(databaseMeta, variables));
+ }
+
+ @Test
+ void aManualUrlIsKeyedOnTheDatabaseItOpens() throws Exception {
+ String one = "mem:mask" + UUID.randomUUID().toString().replace("-", "");
+ String two = "mem:mask" + UUID.randomUUID().toString().replace("-", "");
+ DatabaseMeta databaseMeta = h2();
+ databaseMeta.setManualUrl("jdbc:h2:${MASK_DB};DB_CLOSE_DELAY=-1");
+ Variables first = new Variables();
+ first.setVariable("MASK_DB", one);
+ Variables second = new Variables();
+ second.setVariable("MASK_DB", two);
+
+ MaskingRuntime.Lease lease = MaskingRuntime.getInstance().acquire("db-" +
UUID.randomUUID());
+ try {
+ DatabaseMaskingStore firstStore = lease.database(null, first,
databaseMeta, null, "mask_map");
+ DatabaseMaskingStore secondStore =
+ lease.database(null, second, databaseMeta, null, "mask_map");
+ assertNotSame(firstStore, secondStore);
+ assertEquals("first-name-1", allocate(firstStore, "Matt"));
+ assertEquals("first-name-1", allocate(secondStore, "Ann"));
+ } finally {
+ lease.release();
+ }
+ }
+
+ @Test
+ void connectionsWithTheSameNameOnDifferentDatabasesGetTheirOwnStore() throws
Exception {
+ Variables variables = new Variables();
+ DatabaseMeta one = h2();
+ DatabaseMeta two = h2();
+ two.setName(one.getName());
+ MaskingRuntime.Lease lease = MaskingRuntime.getInstance().acquire("db-" +
UUID.randomUUID());
+ try {
+ DatabaseMaskingStore first = lease.database(null, variables, one, null,
"mask_map");
+ DatabaseMaskingStore second = lease.database(null, variables, two, null,
"mask_map");
+ assertNotSame(first, second);
+ assertEquals("first-name-1", allocate(first, "Matt"));
+ assertEquals("first-name-1", allocate(second, "Ann"));
+ } finally {
+ lease.release();
+ }
+ }
+
+ @Test
+ void anExhaustedRetryKeepsTheSourceKeyOutOfTheError() throws Exception {
+ DatabaseMaskingStore store =
+ new DatabaseMaskingStore((ILoggingObject) null, new Variables(), h2(),
null, "mask_map");
+ store.open();
+ try {
+ // Every new key gets the same replacement, so the unique key on it
rejects every attempt.
+ assertEquals("same", store.findOrCreate("First name", "Matt", current ->
"same"));
+ HopException e =
+ assertThrows(
+ HopException.class, () -> store.findOrCreate("First name",
"Ann", current -> "same"));
+ assertNull(e.getCause());
+ assertFalse(e.getMessage().contains("Ann"), e.getMessage());
+ assertTrue(e.getMessage().contains("First name"), e.getMessage());
+ } finally {
+ store.close();
+ }
+ }
+
+ private static List<String> sourceKeys(DatabaseMeta databaseMeta, Variables
variables)
+ throws Exception {
+ Database database = new Database((ILoggingObject) null, variables,
databaseMeta);
+ database.connect();
+ try {
+ List<String> keys = new ArrayList<>();
+ for (Object[] row : database.getRows("SELECT source_key FROM mask_map",
1000)) {
+ keys.add((String) row[0]);
+ }
+ return keys;
+ } finally {
+ database.disconnect();
+ }
+ }
+
+ private static List<String> allocateAll(DatabaseMaskingStore store, String
prefix, int count)
+ throws Exception {
+ List<String> masked = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ masked.add(allocate(store, prefix + i));
+ }
+ return masked;
+ }
+
+ private static DatabaseMeta h2() {
+ String id = UUID.randomUUID().toString().replace("-", "");
+ return new DatabaseMeta(
+ "mask-" + id, "H2", "Native", "", "mem:mask" + id +
";DB_CLOSE_DELAY=-1", "", "sa", "");
+ }
+
private static String allocate(DatabaseMaskingStore store, String source)
throws Exception {
+ return allocate(store, source, null);
+ }
+
+ private static String allocate(DatabaseMaskingStore store, String source,
String legacy)
+ throws Exception {
return store.findOrCreate(
- "First name", source, current -> "first-name-" +
current.allocateSequence("First name", 1));
+ "First name",
+ source,
+ legacy == null ? null : () -> legacy,
+ current -> "first-name-" + current.allocateSequence("First name", 1));
}
}