athar 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6507cef01b889867badd523eec802ee9cf23fe868011de38e31dd82a48e056ab
4
- data.tar.gz: b0dae015b5ddd83cc7865e0a94419c26c881256dadca6a3797b4b90b7b028610
3
+ metadata.gz: '078258b806e8803d8c53908c23db272a425f1bfced177c80748cecb2afb96a0e'
4
+ data.tar.gz: 2e8435f0a14564b17085dddca4b1739c10b8ea06fdd4610592cc42a5a0390785
5
5
  SHA512:
6
- metadata.gz: 2c5cd3e8404cabdb04c852fe7ad134e905a2db5485dfe0c4e531ab90b3afaf03420ec82d826eaf9b0625fa6ac6b83bd881fb6a3295c6fdfee0727592cbd59b72
7
- data.tar.gz: 11d4162da0257d8abb1a397c9e70080a39ddf6aaa85251d9493cfb6d989ceb25b8bed05ee3b5d9689a8c78ed2686556c7f3b7e7e9aa2c8d20b57ee0a6c704027
6
+ metadata.gz: af56062074cc9a29881321e7b8bd97e9d3758652f32118322c95a0e7a907ea9b3e890a7e534599b598b3a748183fae386ac2e641bcf012174d0fb92eda7414dc
7
+ data.tar.gz: 9e98d6d11ce1dc73727da90ba060bcd29d7c4df399699c38dbe2253cf91d5b65d8fb68346dfadfcc937f4bd806fd5102f8e7c52b3a35759478ae61fea67bbec2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-05-05
10
+
11
+ ### Added
12
+
13
+ - Data masking for `athar_deletions.record_data`.
14
+ - Built-in masks: `:email`, `:partial:N:M`, `:hash`.
15
+ - New `athar:mask` generator for named regex masks.
16
+ - Custom mask functions via `athar_mask_<name>(jsonb) RETURNS jsonb`.
17
+ - New `--mask=col:mask_name[:arg...]` flag on `athar:model`.
18
+ - Bumped `athar_capture_delete` to v02 (adds optional 9th trigger argument for masks).
19
+
20
+ ### Upgrade
21
+
22
+ After bundling, run:
23
+
24
+ ```sh
25
+ bin/rails generate athar:install --update
26
+ bin/rails db:migrate
27
+ ```
28
+
29
+ This installs the new built-in mask functions and bumps `athar_capture_delete`. Existing model triggers continue to work without regeneration; regenerate them only if you want to add masks (`bin/rails generate athar:model X --update --mask=...`).
30
+
9
31
  ## [0.1.0] - 2026-05-03
10
32
 
11
33
  ### Added
data/README.md CHANGED
@@ -31,6 +31,7 @@ It does not turn deleted rows into queryable models, and it does not provide ful
31
31
  - [The Solution](#the-solution)
32
32
  - [Requirements](#requirements)
33
33
  - [Installation](#installation)
34
+ - [Upgrading](#upgrading)
34
35
  - [Quick Start](#quick-start)
35
36
  - [Usage](#usage)
36
37
  - [Configuration](#configuration)
@@ -93,6 +94,22 @@ config.active_record.schema_format = :sql
93
94
 
94
95
  The generators raise a clear error if you pass `--no-fx` while the host app still uses `schema.rb`. They never edit `config/application.rb` for you.
95
96
 
97
+ ## Upgrading
98
+
99
+ After upgrading Athar, run the install generator in update mode and migrate:
100
+
101
+ ```sh
102
+ bin/rails generate athar:install --update
103
+ bin/rails db:migrate
104
+ ```
105
+
106
+ This updates Athar's shared PostgreSQL functions without recreating the audit tables. Existing model triggers continue to work; regenerate a model trigger only when you want to change its capture policy, for example to add masks:
107
+
108
+ ```sh
109
+ bin/rails generate athar:model User --update --snapshot --mask=email:email
110
+ bin/rails db:migrate
111
+ ```
112
+
96
113
  ## Quick Start
97
114
 
98
115
  Install capture for a model:
@@ -153,6 +170,83 @@ The model file does not change. Capture policy is owned by the trigger. To chang
153
170
 
154
171
  Identity-only is the default and is recommended for high-churn tables. Prefer `--only` for PII-sensitive records over `--snapshot`. `--except` is not supported because it is too easy to accidentally retain new sensitive columns.
155
172
 
173
+ ### Data Masking
174
+
175
+ Athar can mask values inside `record_data` before they are stored, so the audit log keeps signal without retaining the original sensitive value:
176
+
177
+ ```sh
178
+ bin/rails generate athar:model User --snapshot --mask=email:email,phone:partial:0:4
179
+ bin/rails db:migrate
180
+ ```
181
+
182
+ ```ruby
183
+ User.find(user_id).destroy!
184
+ Athar::Deletion.last.record_data
185
+ # => { "email" => "use***@example.com", "phone" => "********4567", ... }
186
+ ```
187
+
188
+ Three built-in masks are available:
189
+
190
+ | Spec | Behavior | Example |
191
+ |------|----------|---------|
192
+ | `email` | Keeps the first 3 chars of the local part, then `***@<domain>`. | `user.name@example.com` → `use***@example.com` |
193
+ | `partial:N:M` | Keeps the first `N` and last `M` characters; replaces the middle with `*` (length-preserving). When `N + M ≥ length`, returns all asterisks. | `4111111111111111` with `partial:0:4` → `************1111` |
194
+ | `hash` | Plain SHA-256 hex of the textual form. Deterministic across rows. | `user.name@example.com` → 64 hex chars |
195
+
196
+ Mask spec format: `column:mask_name[:arg1:arg2]`. Multiple specs are comma-separated. Built-ins each have a fixed arity (`partial` takes two integer args; `email` and `hash` take none). Custom mask functions take no DSL args.
197
+
198
+ The `--mask` flag requires `--only` or `--snapshot`; identity-only capture has nothing to mask.
199
+
200
+ #### Named Regex Masks
201
+
202
+ For per-app patterns where the built-ins do not fit, install a named regex mask:
203
+
204
+ ```sh
205
+ bin/rails generate athar:mask ssn_keep_last4 \
206
+ --regex='^([0-9]{3})-([0-9]{2})-([0-9]{4})$' \
207
+ --replacement='XXX-XX-\3'
208
+ bin/rails db:migrate
209
+ ```
210
+
211
+ Reference it from a model:
212
+
213
+ ```sh
214
+ bin/rails generate athar:model User --snapshot --mask=ssn:ssn_keep_last4
215
+ bin/rails db:migrate
216
+ ```
217
+
218
+ `bin/rails generate athar:mask <name> --update --regex=... --replacement=...` regenerates the function. `--remove` drops it (and refuses if any model trigger still references it).
219
+
220
+ #### Custom Mask Functions
221
+
222
+ Applications can install any PostgreSQL function with the signature `athar_mask_<name>(value jsonb) RETURNS jsonb` and reference it from `--mask`:
223
+
224
+ ```sql
225
+ CREATE OR REPLACE FUNCTION athar_mask_uae_phone(value jsonb) RETURNS jsonb AS $$
226
+ DECLARE text_value text;
227
+ BEGIN
228
+ IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN RETURN value; END IF;
229
+ text_value := value #>> '{}';
230
+ RETURN to_jsonb(regexp_replace(text_value, '^\+971([0-9]{2})[0-9]+([0-9]{4})$', '+971\1****\2'));
231
+ END;
232
+ $$ LANGUAGE plpgsql IMMUTABLE;
233
+ ```
234
+
235
+ ```sh
236
+ bin/rails generate athar:model User --snapshot --mask=phone:uae_phone
237
+ ```
238
+
239
+ The names `email`, `partial`, and `hash` are reserved and cannot be used by custom or named-regex masks.
240
+
241
+ #### Privacy and Operational Notes
242
+
243
+ > [!IMPORTANT]
244
+ > Masking only protects what is stored in `athar_deletions`. It does **not** redact values from the PostgreSQL WAL — logical replication, point-in-time recovery, and `pg_dump` see the original `DELETE` row before the trigger fires.
245
+
246
+ - `:hash` is unsalted SHA-256. Same input always yields the same digest, which lets analysts correlate deletions across tables but means the audit log is brute-forceable for small input universes (emails, phone numbers, national IDs).
247
+ - Identity columns (`record_id`, `record_type`, `actor_*`, `schema_name`, `table_name`, `deleted_at`) are never masked; they are indexed lookup keys.
248
+ - Mask spec is frozen into the trigger at migration time. There is no runtime override.
249
+
156
250
  ### Generator Options
157
251
 
158
252
  - `--primary-key=id`
@@ -387,6 +481,14 @@ You generated a trigger for a table whose primary key type does not match the sh
387
481
 
388
482
  Confirm the table has an Athar trigger installed, and confirm the delete was not wrapped in `Athar.without_capture`.
389
483
 
484
+ ### "Function `athar_mask_foo` does not exist"
485
+
486
+ The function was dropped after the trigger was installed (or `bin/rails generate athar:install --update` was never run after upgrading the gem). Either reinstall the function, run `--update`, or run `bin/rails generate athar:model X --update` to regenerate the trigger without the orphan reference.
487
+
488
+ ### "athar_mask_partial: head and tail must be non-negative"
489
+
490
+ A trigger was hand-edited or constructed with negative `partial` arguments. The generator validates this at scaffold time, so the runtime check only fires for triggers that bypassed the generator.
491
+
390
492
  ## Development
391
493
 
392
494
  The maintained local workflow is mise:
data/lib/athar/sql.rb CHANGED
@@ -12,6 +12,10 @@ module Athar
12
12
  STATIC_FUNCTIONS = %w[
13
13
  athar_filter_keys
14
14
  athar_capture_delete
15
+ athar_mask_email
16
+ athar_mask_partial
17
+ athar_mask_hash
18
+ athar_apply_masks
15
19
  ].freeze
16
20
 
17
21
  TEMPLATE_FUNCTIONS = %w[
@@ -38,8 +42,10 @@ module Athar
38
42
 
39
43
  def function_signature(name)
40
44
  case name
41
- when "athar_filter_keys" then "jsonb, text[]"
45
+ when "athar_filter_keys", "athar_apply_masks" then "jsonb, text[]"
42
46
  when "athar_capture_delete", "athar_capture_truncate" then ""
47
+ when "athar_mask_email", "athar_mask_hash" then "jsonb"
48
+ when "athar_mask_partial" then "jsonb, integer, integer"
43
49
  else
44
50
  raise ArgumentError, "unknown SQL function: #{name.inspect}"
45
51
  end
data/lib/athar/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Athar
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,68 @@
1
+ -- Athar mask dispatcher (v1).
2
+ -- Walks an array of "column:mask_name[:arg1:arg2]" specs and applies each
3
+ -- mask to the named column inside the supplied jsonb object. Built-ins
4
+ -- are dispatched via a hardcoded CASE; unknown names are routed to a
5
+ -- custom user-installed athar_mask_<name>(jsonb) RETURNS jsonb function
6
+ -- via EXECUTE (the format() call %I-quotes the identifier).
7
+ --
8
+ -- IMPORTANT: jsonb_set propagates SQL NULL through `new_value`, which
9
+ -- would wipe the entire `data` object if a mask returned SQL NULL. We
10
+ -- COALESCE to JSON null so NULL-returning masks (e.g. athar_mask_email
11
+ -- on a NULL input) preserve the column as JSON null without corrupting
12
+ -- the surrounding object.
13
+
14
+ CREATE OR REPLACE FUNCTION athar_apply_masks(
15
+ data jsonb,
16
+ masks text[]
17
+ ) RETURNS jsonb AS $$
18
+ DECLARE
19
+ spec text;
20
+ parts text[];
21
+ column_name text;
22
+ mask_name text;
23
+ value jsonb;
24
+ masked_value jsonb;
25
+ head_arg int;
26
+ tail_arg int;
27
+ BEGIN
28
+ IF data IS NULL OR masks IS NULL THEN
29
+ RETURN data;
30
+ END IF;
31
+
32
+ FOREACH spec IN ARRAY masks LOOP
33
+ parts := string_to_array(spec, ':');
34
+ column_name := parts[1];
35
+ mask_name := parts[2];
36
+
37
+ IF NOT (data ? column_name) THEN
38
+ CONTINUE;
39
+ END IF;
40
+
41
+ value := data -> column_name;
42
+ masked_value := NULL;
43
+
44
+ CASE mask_name
45
+ WHEN 'email' THEN
46
+ masked_value := athar_mask_email(value);
47
+ WHEN 'partial' THEN
48
+ head_arg := parts[3]::int;
49
+ tail_arg := parts[4]::int;
50
+ masked_value := athar_mask_partial(value, head_arg, tail_arg);
51
+ WHEN 'hash' THEN
52
+ masked_value := athar_mask_hash(value);
53
+ ELSE
54
+ EXECUTE format('SELECT %I($1)', 'athar_mask_' || mask_name)
55
+ INTO masked_value
56
+ USING value;
57
+ END CASE;
58
+
59
+ data := jsonb_set(
60
+ data,
61
+ ARRAY[column_name],
62
+ COALESCE(masked_value, 'null'::jsonb)
63
+ );
64
+ END LOOP;
65
+
66
+ RETURN data;
67
+ END;
68
+ $$ LANGUAGE plpgsql;
@@ -8,6 +8,7 @@
8
8
  -- TG_ARGV[5] record_type_column ('type' | 'null')
9
9
  -- TG_ARGV[6] capture_mode ('identity' | 'only' | 'snapshot')
10
10
  -- TG_ARGV[7] columns ('{email,name}' or 'null')
11
+ -- TG_ARGV[8] masks ('{"col:mask_name[:arg1:arg2]"}' or 'null')
11
12
 
12
13
  CREATE OR REPLACE FUNCTION athar_capture_delete()
13
14
  RETURNS trigger AS $$
@@ -20,6 +21,7 @@ DECLARE
20
21
  arg_record_type_column text;
21
22
  arg_capture_mode text;
22
23
  arg_columns text[];
24
+ arg_masks text[];
23
25
 
24
26
  full_row jsonb;
25
27
  filtered_data jsonb;
@@ -38,6 +40,7 @@ BEGIN
38
40
  arg_record_type_column := NULLIF(TG_ARGV[5], 'null');
39
41
  arg_capture_mode := NULLIF(TG_ARGV[6], 'null');
40
42
  arg_columns := NULLIF(TG_ARGV[7], 'null')::text[];
43
+ arg_masks := NULLIF(TG_ARGV[8], 'null')::text[];
41
44
 
42
45
  full_row := to_jsonb(OLD);
43
46
 
@@ -58,6 +61,10 @@ BEGIN
58
61
  RAISE EXCEPTION 'Unsupported Athar capture mode: %', arg_capture_mode;
59
62
  END IF;
60
63
 
64
+ IF arg_masks IS NOT NULL AND array_length(arg_masks, 1) > 0 THEN
65
+ filtered_data := athar_apply_masks(filtered_data, arg_masks);
66
+ END IF;
67
+
61
68
  meta := '{}'::jsonb;
62
69
  meta_text := current_setting('athar.meta', true);
63
70
  IF coalesce(meta_text, '') <> '' THEN
@@ -0,0 +1,28 @@
1
+ -- Athar email mask (v1).
2
+ -- Keeps the first 3 characters of the local part, appends 3 literal
3
+ -- asterisks, then the original domain. NULL and non-string inputs pass
4
+ -- through unchanged.
5
+
6
+ CREATE OR REPLACE FUNCTION athar_mask_email(value jsonb) RETURNS jsonb AS $$
7
+ DECLARE
8
+ text_value text;
9
+ at_position int;
10
+ local_part text;
11
+ domain text;
12
+ BEGIN
13
+ IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN
14
+ RETURN value;
15
+ END IF;
16
+
17
+ text_value := value #>> '{}';
18
+ at_position := position('@' in text_value);
19
+ IF at_position = 0 THEN
20
+ RETURN value;
21
+ END IF;
22
+
23
+ local_part := substring(text_value, 1, at_position - 1);
24
+ domain := substring(text_value, at_position + 1);
25
+
26
+ RETURN to_jsonb(left(local_part, 3) || '***@' || domain);
27
+ END;
28
+ $$ LANGUAGE plpgsql IMMUTABLE;
@@ -0,0 +1,22 @@
1
+ -- Athar SHA-256 hash mask (v1).
2
+ -- Hashes the textual representation of any JSON scalar. Deterministic
3
+ -- (no salt). NULL passes through. Uses Postgres built-in sha256() (PG 11+);
4
+ -- Athar requires PG 13+, so no extension is needed.
5
+
6
+ CREATE OR REPLACE FUNCTION athar_mask_hash(value jsonb) RETURNS jsonb AS $$
7
+ DECLARE
8
+ text_value text;
9
+ BEGIN
10
+ IF value IS NULL THEN
11
+ RETURN value;
12
+ END IF;
13
+
14
+ IF jsonb_typeof(value) = 'string' THEN
15
+ text_value := value #>> '{}';
16
+ ELSE
17
+ text_value := value::text;
18
+ END IF;
19
+
20
+ RETURN to_jsonb(encode(sha256(text_value::bytea), 'hex'));
21
+ END;
22
+ $$ LANGUAGE plpgsql IMMUTABLE;
@@ -0,0 +1,38 @@
1
+ -- Athar partial mask (v1).
2
+ -- Keeps the first `head` and last `tail` characters; replaces the middle
3
+ -- with `*` (length-preserving). When head+tail >= length, returns the
4
+ -- whole string as asterisks of the same length.
5
+
6
+ CREATE OR REPLACE FUNCTION athar_mask_partial(
7
+ value jsonb,
8
+ head int,
9
+ tail int
10
+ ) RETURNS jsonb AS $$
11
+ DECLARE
12
+ text_value text;
13
+ total_length int;
14
+ middle_length int;
15
+ BEGIN
16
+ IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN
17
+ RETURN value;
18
+ END IF;
19
+
20
+ IF head < 0 OR tail < 0 THEN
21
+ RAISE EXCEPTION 'athar_mask_partial: head and tail must be non-negative (got %, %)', head, tail;
22
+ END IF;
23
+
24
+ text_value := value #>> '{}';
25
+ total_length := char_length(text_value);
26
+
27
+ IF head + tail >= total_length THEN
28
+ RETURN to_jsonb(repeat('*', total_length));
29
+ END IF;
30
+
31
+ middle_length := total_length - head - tail;
32
+ RETURN to_jsonb(
33
+ substring(text_value, 1, head) ||
34
+ repeat('*', middle_length) ||
35
+ substring(text_value, total_length - tail + 1)
36
+ );
37
+ END;
38
+ $$ LANGUAGE plpgsql IMMUTABLE;
@@ -66,7 +66,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
66
66
  <% end -%>
67
67
  SQL
68
68
  end
69
-
69
+ <% unless update? -%>
70
70
  private
71
71
 
72
72
  def athar_primary_and_foreign_key_types
@@ -77,4 +77,5 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
77
77
  foreign_key_type = setting || :bigint
78
78
  [primary_key_type, foreign_key_type]
79
79
  end
80
+ <% end -%>
80
81
  end
@@ -59,7 +59,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
59
59
  <% end -%>
60
60
  <% end -%>
61
61
  end
62
-
62
+ <% unless update? -%>
63
63
  private
64
64
 
65
65
  def athar_primary_and_foreign_key_types
@@ -70,4 +70,5 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
70
70
  foreign_key_type = setting || :bigint
71
71
  [primary_key_type, foreign_key_type]
72
72
  end
73
+ <% end -%>
73
74
  end
@@ -0,0 +1,16 @@
1
+ -- Athar named regex mask: <%= name %>
2
+ -- Generated by `bin/rails g athar:mask <%= name %> --regex=... --replacement=...`.
3
+
4
+ CREATE OR REPLACE FUNCTION athar_mask_<%= name %>(value jsonb) RETURNS jsonb AS $$
5
+ DECLARE
6
+ text_value text;
7
+ BEGIN
8
+ IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN
9
+ RETURN value;
10
+ END IF;
11
+
12
+ text_value := value #>> '{}';
13
+
14
+ RETURN to_jsonb(regexp_replace(text_value, <%= pg_quote(options[:regex]) %>, <%= pg_quote(options[:replacement]) %>, <%= pg_quote(options[:flags]) %>));
15
+ END;
16
+ $$ LANGUAGE plpgsql IMMUTABLE;
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require_relative "../fx_helper"
6
+
7
+ module Athar
8
+ module Generators
9
+ class MaskGenerator < ::Rails::Generators::NamedBase # rubocop:disable Metrics/ClassLength
10
+ include ::Rails::Generators::Migration
11
+ include FxHelper
12
+
13
+ RESERVED_MASK_NAMES = %w[email partial hash].freeze
14
+ SAFE_IDENTIFIER_REGEX = /\A[A-Za-z_][A-Za-z0-9_]*\z/
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ argument :name, type: :string, banner: "MaskName"
19
+
20
+ class_option :regex, type: :string,
21
+ default: nil, desc: "Regex pattern passed to PostgreSQL regexp_replace"
22
+ class_option :replacement, type: :string, default: nil,
23
+ desc: "Replacement string (use \\1, \\2, etc. for capture groups)"
24
+ class_option :flags, type: :string, default: "g",
25
+ desc: "Flags for regexp_replace (default 'g')"
26
+ class_option :update, type: :boolean, default: false,
27
+ desc: "Generate an update migration that bumps the function version"
28
+ class_option :remove, type: :boolean, default: false,
29
+ desc: "Generate a removal migration that drops the function"
30
+
31
+ def validate_options!
32
+ validate_name!
33
+ validate_action!
34
+ validate_no_active_references! if remove?
35
+ ensure_raw_sql_supported! unless fx?
36
+ end
37
+
38
+ def write_function_file
39
+ return unless fx? && !remove?
40
+
41
+ FileUtils.mkdir_p(functions_destination)
42
+ version = next_version.to_s.rjust(2, "0")
43
+ path = File.join(functions_destination, "athar_mask_#{name}_v#{version}.sql")
44
+ File.write(path, function_body)
45
+ end
46
+
47
+ def generate_migration
48
+ template_name =
49
+ if remove?
50
+ fx? ? "remove_migration_fx.rb.erb" : "remove_migration.rb.erb"
51
+ elsif update?
52
+ fx? ? "update_migration_fx.rb.erb" : "update_migration.rb.erb"
53
+ else
54
+ fx? ? "install_migration_fx.rb.erb" : "install_migration.rb.erb"
55
+ end
56
+ migration_template template_name, "db/migrate/#{migration_filename}.rb"
57
+ end
58
+
59
+ no_tasks do # rubocop:disable Metrics/BlockLength
60
+ def update? = options.[](:update)
61
+ def remove? = options.[](:remove)
62
+ def function_name = "athar_mask_#{name}"
63
+
64
+ def function_body
65
+ template_path = File.join(__dir__, "functions/athar_mask_regex.sql.erb")
66
+ ERB.new(File.read(template_path), trim_mode: "-").result(binding)
67
+ end
68
+
69
+ def migration_filename
70
+ if remove?
71
+ "athar_remove_mask_#{name}"
72
+ elsif update?
73
+ "athar_update_mask_#{name}_v#{next_version.to_s.rjust(2, "0")}"
74
+ else
75
+ "athar_install_mask_#{name}"
76
+ end
77
+ end
78
+
79
+ def migration_class_name
80
+ migration_filename.camelize
81
+ end
82
+
83
+ def functions_destination
84
+ File.expand_path("db/functions", destination_root)
85
+ end
86
+
87
+ def pg_quote(value)
88
+ return "''" if value.nil?
89
+
90
+ "'#{value.to_s.gsub("'", "''")}'"
91
+ end
92
+
93
+ def next_version
94
+ @next_version ||= previous_version_for_mask + 1
95
+ end
96
+
97
+ def previous_version_for_mask # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
98
+ # In fx mode, determine version from .sql files in db/functions/
99
+ if fx? && File.directory?(functions_destination)
100
+ sql_version = Dir.entries(functions_destination)
101
+ .filter_map { |f| f[/\Aathar_mask_#{Regexp.escape(name)}_v(\d+)\.sql\z/, 1]&.to_i }
102
+ .max
103
+
104
+ return sql_version if sql_version
105
+ end
106
+
107
+ # Fallback: determine version from existing migration filenames.
108
+ # Install migration has no version suffix (counts as v1 when present).
109
+ # Update migrations have _vNN suffix.
110
+ migrations_dir = File.expand_path("db/migrate", destination_root)
111
+ return 0 unless File.directory?(migrations_dir)
112
+
113
+ install_exists = Dir.entries(migrations_dir).any? do |f|
114
+ f.match?(/\d+_athar_install_mask_#{Regexp.escape(name)}\.rb\z/)
115
+ end
116
+
117
+ update_versions = Dir.entries(migrations_dir).filter_map do |f|
118
+ f[/\d+_athar_update_mask_#{Regexp.escape(name)}_v(\d+)\.rb\z/,
119
+ 1]&.to_i
120
+ end
121
+
122
+ max_update = update_versions.max
123
+
124
+ if max_update
125
+ max_update
126
+ elsif install_exists
127
+ 1
128
+ else
129
+ 0
130
+ end
131
+ end
132
+ end
133
+
134
+ def self.next_migration_number(dir)
135
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
136
+ end
137
+
138
+ private
139
+
140
+ def validate_name!
141
+ raise_invalid("name #{name.inspect} is not a safe SQL identifier") unless name.match?(SAFE_IDENTIFIER_REGEX)
142
+ raise_invalid("name #{name.inspect} is reserved (built-in mask)") if RESERVED_MASK_NAMES.include?(name)
143
+ return unless name.start_with?("mask_")
144
+
145
+ raise_invalid("name should not start with 'mask_' (the function will already be prefixed athar_mask_)")
146
+ end
147
+
148
+ def validate_action!
149
+ raise_invalid("--update and --remove are mutually exclusive") if update? && remove?
150
+ return if remove?
151
+ return if options[:regex] && options[:replacement]
152
+
153
+ raise_invalid("--regex and --replacement are required (unless --remove)")
154
+ end
155
+
156
+ def validate_no_active_references!
157
+ references = scan_for_mask_references(name)
158
+ return if references.empty?
159
+
160
+ raise_invalid(
161
+ "cannot remove athar_mask_#{name}; still referenced by:\n - " + # rubocop:disable Style/StringConcatenation
162
+ references.join("\n - ") +
163
+ "\nRegenerate those model triggers without this mask first."
164
+ )
165
+ end
166
+
167
+ def scan_for_mask_references(mask_name) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
168
+ results = []
169
+
170
+ triggers_dir = File.join(destination_root, "db/triggers")
171
+ if Dir.exist?(triggers_dir)
172
+ Dir.glob(File.join(triggers_dir, "*.sql")).each do |path|
173
+ results << path if File.read(path).include?(":#{mask_name}\"")
174
+ end
175
+ end
176
+
177
+ migrate_dir = File.join(destination_root, "db/migrate")
178
+ if Dir.exist?(migrate_dir)
179
+ Dir.glob(File.join(migrate_dir, "*.rb")).each do |path|
180
+ results << path if File.read(path).include?(":#{mask_name}\"")
181
+ end
182
+ end
183
+
184
+ results
185
+ end
186
+
187
+ def raise_invalid(message)
188
+ raise ::Thor::Error, "Athar mask generator error: #{message}"
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,11 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def up
3
+ execute <<~SQL
4
+ <%= function_body.lines.map { |l| " #{l}" }.join %>
5
+ SQL
6
+ end
7
+
8
+ def down
9
+ execute "DROP FUNCTION IF EXISTS athar_mask_<%= name %>(jsonb)"
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_function :athar_mask_<%= name %>
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def up
3
+ execute "DROP FUNCTION IF EXISTS athar_mask_<%= name %>(jsonb)"
4
+ end
5
+
6
+ def down
7
+ raise ActiveRecord::IrreversibleMigration
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def up
3
+ drop_function :athar_mask_<%= name %>
4
+ end
5
+
6
+ def down
7
+ raise ActiveRecord::IrreversibleMigration
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def up
3
+ execute <<~SQL
4
+ <%= function_body.lines.map { |l| " #{l}" }.join %>
5
+ SQL
6
+ end
7
+
8
+ def down
9
+ raise ActiveRecord::IrreversibleMigration
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ update_function :athar_mask_<%= name %>, version: <%= next_version %>, revert_to_version: <%= next_version - 1 %>
4
+ end
5
+ end
@@ -12,6 +12,7 @@ module Athar
12
12
  include FxHelper
13
13
 
14
14
  ALLOWED_ID_TYPES = %w[bigint integer uuid].freeze
15
+ BUILTIN_MASKS = %w[email partial hash].freeze
15
16
  CAPTURE_MODES = %w[identity only snapshot].freeze
16
17
  UNSAFE_COLUMN_REGEX = /[\s,{}"\\']/
17
18
  # PostgreSQL unquoted identifier surface: starts with letter or _,
@@ -34,12 +35,14 @@ module Athar
34
35
  class_option :track_truncate, type: :boolean, default: false, desc: "Install AFTER TRUNCATE trigger."
35
36
  class_option :update, type: :boolean, default: false, desc: "Generate an update migration."
36
37
  class_option :remove, type: :boolean, default: false, desc: "Generate a removal migration."
38
+ class_option :mask, type: :array, default: nil, desc: "Mask spec: col:mask_name[:arg1:arg2],..."
37
39
 
38
40
  def validate_options!
39
41
  validate_capture_mode!
40
42
  validate_identifiers!
41
43
  validate_id_type!
42
44
  validate_columns!
45
+ validate_masks!
43
46
  ensure_raw_sql_supported! unless fx?
44
47
  end
45
48
 
@@ -113,7 +116,8 @@ module Athar
113
116
  id_type:,
114
117
  record_type_column_arg:,
115
118
  capture_mode:,
116
- columns_arg:
119
+ columns_arg:,
120
+ masks_arg:
117
121
  }
118
122
  Athar::SQL.render(template, locals)
119
123
  end
@@ -231,6 +235,29 @@ module Athar
231
235
  capture_mode == "only" ? "'{#{columns.join(",")}}'" : "'null'"
232
236
  end
233
237
 
238
+ def masks_arg
239
+ return "'null'" if mask_specs.empty?
240
+
241
+ literals = mask_specs.map do |spec|
242
+ pieces = [spec[:column], spec[:mask], *spec[:args]]
243
+ %("#{pieces.join(":")}")
244
+ end
245
+
246
+ "'{#{literals.join(",")}}'"
247
+ end
248
+
249
+ def mask_specs
250
+ @mask_specs ||= Array(options[:mask]).flat_map { |item| item.to_s.split(",") }
251
+ .map(&:strip).reject(&:empty?)
252
+ .map { |spec| parse_mask_spec(spec) }
253
+ end
254
+
255
+ def parse_mask_spec(spec)
256
+ parts = spec.split(":")
257
+ raise_invalid("Mask spec #{spec.inspect} is missing a mask name") if parts.length < 2
258
+ { column: parts[0], mask: parts[1], args: parts[2..] || [] }
259
+ end
260
+
234
261
  def migration_filename
235
262
  if remove?
236
263
  "athar_remove_#{table_name}_trigger"
@@ -251,7 +278,7 @@ module Athar
251
278
  # The `on:` argument passed to Fx's create_trigger / update_trigger /
252
279
  # drop_trigger. For the public schema we keep the bare symbol so the
253
280
  # generated migration matches the Rails convention. For non-public
254
- # schemas we pass a "schema.table" string so DROP TRIGGER ON hits
281
+ # schemas we pass a "schema.table" string so DROP TRIGGER ... ON ... hits
255
282
  # the correct relation regardless of search_path.
256
283
  def fx_on_argument
257
284
  if schema_name == "public"
@@ -336,6 +363,91 @@ module Athar
336
363
  end
337
364
  end
338
365
 
366
+ def validate_masks! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
367
+ return if options[:mask].nil? || options[:mask].empty?
368
+
369
+ raise_invalid("--mask requires --only or --snapshot") unless options[:only] || options[:snapshot]
370
+
371
+ seen_columns = {}
372
+ mask_specs.each do |spec|
373
+ column = spec[:column]
374
+ mask = spec[:mask]
375
+ args = spec[:args]
376
+
377
+ validate_safe_identifier!("column", column)
378
+ validate_safe_identifier!("mask", mask)
379
+
380
+ raise_invalid("Duplicate column #{column.inspect} in --mask") if seen_columns[column]
381
+ seen_columns[column] = true
382
+
383
+ if options[:only] && !columns.include?(column)
384
+ raise_invalid("Mask references uncaptured column #{column.inspect}; add it to --only or use --snapshot")
385
+ end
386
+
387
+ if options[:snapshot] && !model_class.columns_hash.key?(column)
388
+ raise_invalid("Mask references unknown column #{column.inspect} on #{table_name}")
389
+ end
390
+
391
+ validate_mask_arity!(mask, args)
392
+ validate_mask_resolves!(mask)
393
+ end
394
+ end
395
+
396
+ def validate_mask_arity!(mask, args) # rubocop:disable Metrics/CyclomaticComplexity
397
+ case mask
398
+ when "email", "hash"
399
+ raise_invalid("#{mask} takes no arguments (got #{args.length})") unless args.empty?
400
+ when "partial"
401
+ unless args.length == 2 && args.all? { |a| a.match?(/\A\d+\z/) }
402
+ raise_invalid("partial requires exactly 2 integer args (got #{args.inspect})")
403
+ end
404
+ else
405
+ raise_invalid("#{mask} is a custom mask and takes no arguments (got #{args.inspect})") unless args.empty?
406
+ end
407
+ end
408
+
409
+ def validate_mask_resolves!(mask)
410
+ return if BUILTIN_MASKS.include?(mask)
411
+ return if custom_mask_installed?(mask)
412
+
413
+ raise_invalid(
414
+ "Mask :#{mask} is not installed. Run 'bin/rails g athar:mask #{mask} --regex=...' " \
415
+ "or install a custom athar_mask_#{mask}(jsonb) function first."
416
+ )
417
+ end
418
+
419
+ def custom_mask_installed?(mask)
420
+ custom_mask_file_installed?(mask) || custom_mask_database_installed?(mask)
421
+ end
422
+
423
+ def custom_mask_file_installed?(mask)
424
+ return false unless fx?
425
+
426
+ Dir.glob(File.join(destination_root, "db/functions/athar_mask_#{mask}_v*.sql")).any?
427
+ end
428
+
429
+ def custom_mask_database_installed?(mask)
430
+ sql = ActiveRecord::Base.sanitize_sql_array([
431
+ <<~SQL.squish,
432
+ SELECT 1
433
+ FROM pg_proc p
434
+ WHERE p.proname = ?
435
+ AND p.pronargs = 1
436
+ AND p.proargtypes[0] = 'jsonb'::regtype
437
+ AND p.prorettype = 'jsonb'::regtype
438
+ AND pg_function_is_visible(p.oid)
439
+ LIMIT 1
440
+ SQL
441
+ "athar_mask_#{mask}"
442
+ ])
443
+
444
+ !ActiveRecord::Base.connection.select_value(sql).nil?
445
+ rescue StandardError
446
+ # If we can't reach the DB, fall back to false. The trigger install
447
+ # itself will fail loudly later if the function truly doesn't exist.
448
+ false
449
+ end
450
+
339
451
  def raise_invalid(message)
340
452
  raise ::Thor::Error, "Athar generator error: #{message}"
341
453
  end
@@ -10,5 +10,6 @@ EXECUTE PROCEDURE athar_capture_delete(
10
10
  '<%= id_type %>',
11
11
  <%= record_type_column_arg %>,
12
12
  '<%= capture_mode %>',
13
- <%= columns_arg %>
13
+ <%= columns_arg %>,
14
+ <%= masks_arg %>
14
15
  );
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: athar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ali Hamdi Ali Fadel
@@ -103,12 +103,24 @@ files:
103
103
  - lib/athar/table_event.rb
104
104
  - lib/athar/version.rb
105
105
  - lib/generators/athar/fx_helper.rb
106
+ - lib/generators/athar/install/functions/athar_apply_masks.sql
106
107
  - lib/generators/athar/install/functions/athar_capture_delete.sql
107
108
  - lib/generators/athar/install/functions/athar_capture_truncate.sql.erb
108
109
  - lib/generators/athar/install/functions/athar_filter_keys.sql
110
+ - lib/generators/athar/install/functions/athar_mask_email.sql
111
+ - lib/generators/athar/install/functions/athar_mask_hash.sql
112
+ - lib/generators/athar/install/functions/athar_mask_partial.sql
109
113
  - lib/generators/athar/install/install_generator.rb
110
114
  - lib/generators/athar/install/templates/install_migration.rb.erb
111
115
  - lib/generators/athar/install/templates/install_migration_fx.rb.erb
116
+ - lib/generators/athar/mask/functions/athar_mask_regex.sql.erb
117
+ - lib/generators/athar/mask/mask_generator.rb
118
+ - lib/generators/athar/mask/templates/install_migration.rb.erb
119
+ - lib/generators/athar/mask/templates/install_migration_fx.rb.erb
120
+ - lib/generators/athar/mask/templates/remove_migration.rb.erb
121
+ - lib/generators/athar/mask/templates/remove_migration_fx.rb.erb
122
+ - lib/generators/athar/mask/templates/update_migration.rb.erb
123
+ - lib/generators/athar/mask/templates/update_migration_fx.rb.erb
112
124
  - lib/generators/athar/model/model_generator.rb
113
125
  - lib/generators/athar/model/templates/migration.rb.erb
114
126
  - lib/generators/athar/model/templates/migration_fx.rb.erb