exwiw 0.9.20 → 0.9.21

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: 328fbb843d5c15da7ea9f764adda96a5c58b4d24de615b9cac38ba305550ed05
4
- data.tar.gz: 691448e490f27475d1ae17be240f89af5ea7c3ac54ddedfa544ca1b71517f5c1
3
+ metadata.gz: 61f6374ae972477087c63744e9a3702acc795872f7d08599cef69bc7898563db
4
+ data.tar.gz: 5a140799d94c5804fec5988c0643acbcc79c33ed442e7be36fe2b65bcce14a3b
5
5
  SHA512:
6
- metadata.gz: ee57c7925b2b912a3e9777947d251b8870f94572ec96b5d7fbd0a8d60dbb8aaefd33cde4e12e735b2ff2eb44b24e2f101b81e4bdfc78752f55788619a4ce5a6c
7
- data.tar.gz: 9f3a2b95adb72322032b7ac4967d69e3c32a0df6fa75c64adc7f59be1f9de6b7958a8250c0abb2fd9857a5c063d8123f71c2d9678376757d1161ef34f484f368
6
+ metadata.gz: 78154be467c8920c5011f527957635145089c07bba5268ce4ebe7ac3272622243acb36b05bd1b538740f76a9167dafe48498a022b458bdd11610544d2c10d446
7
+ data.tar.gz: 7aea16379a332986f78b18f07a1c6c258745c407a804510de1451c17324c1533f7f17fda6ed725bc4e982e5b5824395fd96f01ffa30fc46c90b0a2e10d25c033
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.21] - 2026-08-06
6
+
7
+ ### Changed
8
+
9
+ - **`schema:generate` now runs in safe mode by default, so a column a migration just added cannot start being exported before anyone has judged whether it holds personal data.** Every column the config does not have yet is emitted **masked** and flagged `needs_mask_decision: true`; existing entries keep their resolved state through the usual merge, so in practice this marks exactly the new columns. `EXWIW_NEW_COLUMNS=plain` opts out, which is what a first-time bootstrap wants — every column of every table is new there. `SchemaGenerator.new` / `.from_rails_application` default to safe too, so a caller driving the generator from its own task or script gets the same behavior. A column that has a default of its own is masked with that default — a value the column provably holds, and the one the application treats as neutral, so masking a `default: true` flag does not quietly turn the feature off for every row (a default the database computes, like `now()`, is not a constant and does not count, and neither does a JSON object — `{...}` in a mask is a column placeholder, so those fall back to `{}`). Otherwise the mask follows the column type: `masked-{primary key}` for text (with `@example.com` when the column name mentions mail), `0` for numbers, `false` for booleans, a fixed date/timestamp, `{}` for JSON; text always takes the template, since its mask has to vary per row. Three kinds of column are flagged but never masked: the primary key and the foreign keys/types the `belongs_tos` join on (masking them would break the joins and leave the dump referencing rows that were never exported); types no constant safely fits (`uuid`, `binary`, enums, array columns — which report their member type, so a scalar default would not fit — and text columns too short to hold the masked value), since a default the column cannot hold would fail the restore the dump feeds; and columns covered by a unique index unless the mask varies per row, since a constant would collapse every row onto one value and break the restore with a duplicate key. If the index list cannot be read at all, every column is treated as unique-indexed rather than risking the latter. ActiveRecord only for now — `schema:generate_mongoid` does not flag new fields yet.
10
+
11
+ ### Added
12
+
13
+ - **`rake exwiw:schema:check` reports how the committed config differs from what the application would generate now, plus the columns still waiting for a masking decision.** Added/removed/changed tables and columns and the flagged columns come out as sorted JSON with a non-zero exit, each entry prefixed with its database in a multi-database app; `EXWIW_SCHEMA_CHECK_OUTPUT=<path>` additionally writes the report to a file, so a caller need not assume stdout carries nothing else. It regenerates into a throwaway copy rather than over the config directory, so it runs on a working tree it must not modify — a CI check that keeps a schema change from being merged until the config is reconciled and every new column's masking is decided. ActiveRecord only, like safe mode.
14
+
15
+ - **A column/field can carry `needs_mask_decision: true`, marking a masking decision nobody has made yet.** Extraction ignores the key entirely — what the column exports is whatever `replace_with` / `ignore` say — so it is purely a decision-tracking bit that tooling can attach and report, letting a check refuse to merge a schema change while any column still carries one. The on-disk state wins over regeneration: removing the key is how the decision is recorded, so a regenerated config must not restore it.
16
+
17
+ - **`replace_with` accepts a non-String value, so a column that is not text can be masked without changing its type.** A String mask is rendered as a template and emitted as text, which is the wrong shape for an integer/boolean column (and would change a MongoDB field's BSON type). A JSON scalar — `"replace_with": 0`, `false`, `1.5` — is used verbatim instead: the SQL adapters emit it as a typed literal rather than concatenating it into text, and the MongoDB adapter assigns the value as-is. `{}` placeholders are only interpreted in the String form, and NULL preservation applies to both. Because `false` is a valid mask value and falsy in Ruby, the checks that decide whether a column is masked now test for nil, so a `replace_with: false` column is no longer emitted unmasked.
18
+
19
+ ### Fixed
20
+
21
+ - **A `{}` in a `replace_with` template is emitted literally instead of compiling to a reference to a column with no name.** The SQL adapters read `{...}` as a placeholder even when it was empty, so `"replace_with": "{}"` compiled to `CONCAT(t."")` (`t.``` on mysql, `(t."")` on sqlite) and the extraction failed on that table. The MongoDB adapter never had the bug — its placeholder pattern requires at least one character — so the same config meant two different things per adapter. A brace pair with nothing between it names no column and is now a literal everywhere, which is what makes `{}` usable as an empty-JSON mask (the default safe-mode mask for a `json`/`jsonb` column). Templates that name a real column are unaffected, byte for byte.
22
+
5
23
  ## [0.9.20] - 2026-08-05
6
24
 
7
25
  ### Changed
data/README.md CHANGED
@@ -351,6 +351,52 @@ EXWIW_SCHEMA_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
351
351
 
352
352
  As with the CLI, a relative `schema_dir` in the config file is resolved relative to the config file's own directory.
353
353
 
354
+ #### Safe mode (masking new columns by default)
355
+
356
+ A migration that adds a column would otherwise leave `schema:generate` emitting it unmasked, so
357
+ it starts being exported the moment the config is regenerated — before anyone has judged whether
358
+ it holds personal data. So `schema:generate` runs in **safe mode by default**: every column the
359
+ config does not have yet is emitted **masked** and flagged
360
+ [`needs_mask_decision: true`](#needs_mask_decision).
361
+
362
+ Columns already in the config keep whatever they say — the merge that preserves `replace_with` /
363
+ `comment` / `ignore` preserves a resolved decision too — so in practice this marks exactly the
364
+ columns a migration just added.
365
+
366
+ ```bash
367
+ bundle exec rake exwiw:schema:generate # safe mode
368
+ EXWIW_NEW_COLUMNS=plain bundle exec rake exwiw:schema:generate # opt out
369
+ ```
370
+
371
+ Opting out is for the **first-time bootstrap** of a config, where every column of every table is
372
+ new and safe mode would flag the whole thing at once. Use it nowhere else: a column committed
373
+ under `plain` carries no flag, so nothing afterwards can tell it apart from one whose masking was
374
+ decided.
375
+
376
+ A column that has a **default of its own** is masked with that default: it is a value the column
377
+ provably holds, and it is what the application treats as neutral, so masking a `default: true`
378
+ flag does not quietly turn the feature off for every row in the dump. A default the database
379
+ computes (`now()`) is not a constant and does not count, and neither does a JSON object — `{...}`
380
+ in a mask is a column placeholder, so those fall back to `{}`. Otherwise the mask depends on the column
381
+ type: `masked-{primary key}` for text (with `@example.com` appended when the column name mentions
382
+ mail, so it stays a valid address), `0` for numbers, `false` for booleans, a fixed date/timestamp,
383
+ and `{}` for JSON. Text always takes the template rather than its default, since the mask has to
384
+ vary per row. Three kinds of
385
+ column are flagged but deliberately **not** masked:
386
+
387
+ - **The primary key, and the foreign keys/types the `belongs_tos` join on.** Masking them
388
+ would break the joins and leave the dump referencing rows that were never exported.
389
+ - **Types no constant safely fits** — `uuid`, `binary`, enums, array columns (which report their
390
+ member type, so a scalar default would not fit), and text columns too short to hold the masked
391
+ value. An invalid default would fail the restore the dump feeds, which is worse than exporting
392
+ the column while the flag keeps the change from being merged.
393
+ - **Columns covered by a unique index**, unless the mask varies per row (the text masks do, via
394
+ the primary key). A constant would collapse every row onto one value and break the restore with
395
+ a duplicate key.
396
+
397
+ Safe mode is ActiveRecord-only for now: `schema:generate_mongoid` does not flag new fields yet,
398
+ though the `needs_mask_decision` key itself is understood on a MongoDB field.
399
+
354
400
  #### Tidying stale config (`schema:tidy`)
355
401
 
356
402
  `schema:generate` adds and updates config files for the tables it finds, but it never deletes the config file of a table that has been dropped from the application. To reconcile the existing config against the current schema, run:
@@ -368,6 +414,43 @@ Because it reads the database directly, a table that still exists in the databas
368
414
 
369
415
  It respects `EXWIW_SCHEMA_DIR_PATH` and the per-database subdirectory layout in the same way as `schema:generate`. Unlike `generate`, `tidy` never adds or regenerates entries — every surviving table/column (including hand-edited `comment` / `ignore` / `replace_with`) is left untouched, so it is safe to run on a customized config. The task prints which tables and columns it removed (or that the config was already tidy). Stale `belongs_tos` are not pruned by `tidy`; rerun `schema:generate` to refresh those.
370
416
 
417
+ #### Checking the config against the schema
418
+
419
+ `schema:check` reports how the committed config differs from what the application would
420
+ generate now — without writing anything, so it can run on a working tree it must not modify:
421
+
422
+ ```bash
423
+ bundle exec rake exwiw:schema:check
424
+ ```
425
+
426
+ It regenerates into a throwaway copy of the config directory (safe mode + `tidy`) and prints
427
+ the comparison as JSON, then exits non-zero when anything needs attention:
428
+
429
+ ```json
430
+ {
431
+ "added_tables": [],
432
+ "added_columns": ["users.contact_email"],
433
+ "removed_tables": [],
434
+ "removed_columns": [],
435
+ "changed_tables": ["users"],
436
+ "needs_mask_decision": ["orders.memo"]
437
+ }
438
+ ```
439
+
440
+ `added_*` / `removed_*` / `changed_tables` mean the config no longer matches the schema — run
441
+ `schema:generate` and `schema:tidy` to reconcile it. `needs_mask_decision` lists the columns
442
+ whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). The exit code
443
+ makes it usable as a CI check that keeps a schema change from being merged until both are
444
+ resolved; the JSON is stable and sorted, so it can be posted as-is. In a multi-database app each
445
+ entry is prefixed with its database (`primary/users.email`), so the same table name in two
446
+ databases stays distinct.
447
+
448
+ Set `EXWIW_SCHEMA_CHECK_OUTPUT=<path>` to have the same JSON written to a file, which spares a
449
+ caller from assuming stdout carries nothing else (application boot is free to print).
450
+
451
+ Like safe mode, this is ActiveRecord-only — it regenerates through `SchemaGenerator`, so a
452
+ Mongoid config directory is not supported yet.
453
+
371
454
  #### Multiple databases
372
455
 
373
456
  If the application uses Rails' multiple-database support (`connects_to`), `schema:generate` buckets models by the database they connect to and writes each database's config files into its own subdirectory of the output directory, named after the database config name (`primary`, `analytics`, ...):
@@ -535,6 +618,27 @@ Individual `columns` (SQL) / `fields` (MongoDB) and `belongs_tos` entries accept
535
618
 
536
619
  The ignored entries are removed only at runtime, right after the config is loaded from file; the JSON on disk keeps them. Both `comment` and `ignore` are **preserved across `exwiw:schema:generate` / `exwiw:mongoid:schema:generate` regenerations** (the hand-edited value wins over the auto-generated config), just like `replace_with`. This applies to the MongoDB `MongodbCollectionConfig` (`fields` / `belongs_tos`) as well.
537
620
 
621
+ ### `needs_mask_decision`
622
+
623
+ A column/field may also carry `needs_mask_decision: true`, marking a column whose masking
624
+ nobody has decided on yet:
625
+
626
+ ```json
627
+ { "name": "contact_email", "replace_with": "masked-{id}@example.com", "needs_mask_decision": true }
628
+ ```
629
+
630
+ Extraction ignores the key entirely — what the column exports is whatever `replace_with` /
631
+ `ignore` say. It exists so the decision can be tracked and required: `schema:generate`'s
632
+ [safe mode](#safe-mode-masking-new-columns-by-default) attaches it to every newly discovered
633
+ column together with a default mask, and [`schema:check`](#checking-the-config-against-the-schema)
634
+ reports the columns that still carry it, so CI can keep a pull request red until each one is
635
+ resolved. Resolving it means removing the key — after keeping the mask (ideally recording why
636
+ in `comment`), replacing it with a real masking rule, dropping `replace_with` to export the
637
+ raw value, or setting `ignore: true`.
638
+
639
+ Like `comment` / `ignore`, the on-disk state wins over regeneration: once removed,
640
+ `schema:generate` does not bring it back.
641
+
538
642
  ### Polymorphic `belongs_to`
539
643
 
540
644
  A Rails polymorphic association (`belongs_to :reviewable, polymorphic: true`) does not point at a single table — the target row is selected at runtime by a type column. exwiw models this as **one `belongs_to` entry per concrete target table**, each carrying two extra fields:
@@ -826,6 +930,23 @@ absent field) is left as-is instead of being replaced by the masked literal, so
826
930
  string is a real value and is still masked. Because of this you do not need to hand-write a
827
931
  `raw_sql` `CASE WHEN ... IS NOT NULL ...` to keep NULLs.
828
932
 
933
+ A **non-String** value (number or boolean) is used verbatim instead of being rendered as a
934
+ template, so a column that is not text keeps its type:
935
+
936
+ ```jsonc
937
+ { "name": "score", "replace_with": 0 } // integer column -> SELECT emits the literal 0
938
+ { "name": "active", "replace_with": false } // boolean column
939
+ { "name": "email", "replace_with": "masked-{id}@example.com" } // template, as above
940
+ ```
941
+
942
+ The SQL adapters emit it as a typed literal (not concatenated into text) and the MongoDB
943
+ adapter assigns it as-is, so the field keeps its BSON type. NULL preservation applies to both
944
+ forms.
945
+
946
+ In the String form, a `{...}` placeholder must name a column: an empty brace pair (`{}`) names
947
+ nothing, so it is emitted literally — which is what makes `"replace_with": "{}"` a usable
948
+ empty-JSON mask, on every adapter.
949
+
829
950
  #### `raw_sql`
830
951
 
831
952
  It will used instead of the original value.
@@ -733,9 +733,12 @@ module Exwiw
733
733
 
734
734
  private def build_mask_plan(config)
735
735
  masked_fields = config.fields.each_with_object([]) do |field, acc|
736
- next unless field.replace_with
736
+ next if field.replace_with.nil?
737
737
 
738
- acc << [field.name, compile_template(field.replace_with)]
738
+ # A non-String replace_with (see Exwiw::MaskValue) is stored verbatim
739
+ # so the field keeps its BSON type; only a template compiles to segments.
740
+ mask = Exwiw::MaskValue.scalar?(field.replace_with) ? field.replace_with : compile_template(field.replace_with)
741
+ acc << [field.name, mask]
739
742
  end
740
743
  faked_fields = build_faked_fields(config)
741
744
  embedded = embedded_children_of(config).map do |child|
@@ -779,13 +782,13 @@ module Exwiw
779
782
  # masked value — matching the SQL adapters, where replace_with runs in the
780
783
  # database before the Ruby-side fake transform sees the row.
781
784
  private def apply_mask_plan!(doc, plan)
782
- plan.masked_fields.each do |name, segments|
785
+ plan.masked_fields.each do |name, mask|
783
786
  # Preserve a NULL / absent source value instead of clobbering it into a
784
787
  # masked literal. `doc[name].nil?` is true for both an explicit nil and
785
788
  # an absent key, so an absent key is left absent (not created).
786
789
  next if doc[name].nil?
787
790
 
788
- doc[name] = render_template(segments, doc)
791
+ doc[name] = mask.is_a?(Array) ? render_template(mask, doc) : mask
789
792
  end
790
793
  plan.faked_fields.each do |name, deriver, seed_field|
791
794
  # NULL-preserving like replace_with (an absent key stays absent). The
@@ -486,15 +486,12 @@ module Exwiw
486
486
  when Exwiw::QueryAst::ColumnValue::RawSql
487
487
  column.value
488
488
  when Exwiw::QueryAst::ColumnValue::ReplaceWith
489
- parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
490
- if part.start_with?('{')
491
- name = part[1..-2]
492
- qualified_name(ast.from_table_name, name)
493
- else
494
- "'#{part}'"
495
- end
489
+ if Exwiw::MaskValue.scalar?(column.value)
490
+ return null_preserving(ast, column, scalar_literal(column.value))
496
491
  end
497
492
 
493
+ parts = mask_template_parts(ast, column)
494
+
498
495
  replaced = parts.join(", ")
499
496
  null_preserving(ast, column, "CONCAT(#{replaced})")
500
497
  else
@@ -588,15 +588,12 @@ module Exwiw
588
588
  when Exwiw::QueryAst::ColumnValue::RawSql
589
589
  column.value
590
590
  when Exwiw::QueryAst::ColumnValue::ReplaceWith
591
- parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
592
- if part.start_with?('{')
593
- name = part[1..-2]
594
- qualified_name(ast.from_table_name, name)
595
- else
596
- "'#{part}'"
597
- end
591
+ if Exwiw::MaskValue.scalar?(column.value)
592
+ return null_preserving(ast, column, scalar_literal(column.value))
598
593
  end
599
594
 
595
+ parts = mask_template_parts(ast, column)
596
+
600
597
  replaced = parts.join(", ")
601
598
  null_preserving(ast, column, "CONCAT(#{replaced})")
602
599
  else
@@ -329,15 +329,12 @@ module Exwiw
329
329
  when Exwiw::QueryAst::ColumnValue::RawSql
330
330
  column.value
331
331
  when Exwiw::QueryAst::ColumnValue::ReplaceWith
332
- parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
333
- if part.start_with?('{')
334
- name = part[1..-2]
335
- qualified_name(ast.from_table_name, name)
336
- else
337
- "'#{part}'"
338
- end
332
+ if Exwiw::MaskValue.scalar?(column.value)
333
+ return null_preserving(ast, column, scalar_literal(column.value))
339
334
  end
340
335
 
336
+ parts = mask_template_parts(ast, column)
337
+
341
338
  replaced = parts.join(" || ")
342
339
  null_preserving(ast, column, "(#{replaced})")
343
340
  else
data/lib/exwiw/adapter.rb CHANGED
@@ -263,6 +263,33 @@ module Exwiw
263
263
  "CASE WHEN #{qualified_name(ast.from_table_name, column.name)} IS NOT NULL THEN #{masked_expr} ELSE NULL END"
264
264
  end
265
265
 
266
+ # Split a `replace_with` template into the expressions it concatenates: a
267
+ # `{column}` placeholder becomes that column's qualified name, everything
268
+ # else a quoted literal. An empty brace pair names no column, so it stays
269
+ # literal — which is what makes `"replace_with": "{}"` an empty-JSON mask.
270
+ # A quote in a literal is doubled; a backslash is not, so a hand-written
271
+ # mask containing one means what MySQL makes of it.
272
+ private def mask_template_parts(ast, column)
273
+ column.value.scan(/#{Exwiw::MaskValue::PLACEHOLDER}|[^{}]+|[{}]/).map do |part|
274
+ if part.size > 1 && part.start_with?("{")
275
+ qualified_name(ast.from_table_name, part[1..-2])
276
+ else
277
+ "'#{part.gsub("'", "''")}'"
278
+ end
279
+ end
280
+ end
281
+
282
+ # A non-String `replace_with` (see Exwiw::MaskValue) is emitted as a typed
283
+ # literal rather than concatenated into text, so an integer column masked
284
+ # with `0` keeps its numeric type.
285
+ private def scalar_literal(value)
286
+ case value
287
+ when true then "TRUE"
288
+ when false then "FALSE"
289
+ else value.to_s
290
+ end
291
+ end
292
+
266
293
  # Split an outer query's WHERE clauses into the scope id-set clauses to
267
294
  # lift into a materialized derived-table JOIN (see each adapter's
268
295
  # #compile_scope_join) and the remaining plain clauses (kept in WHERE).
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Exwiw
6
+ # The `replace_with` value safe mode attaches to a newly discovered column.
7
+ #
8
+ # Only types a constant can safely stand in for are masked: a default the
9
+ # column cannot hold would fail the restore the dump feeds, which is worse
10
+ # than exporting the column while its `needs_mask_decision` flag keeps the
11
+ # change from being merged.
12
+ module DefaultMask
13
+ FIXED_DATE = "2000-01-01"
14
+ FIXED_TIME = "2000-01-01 00:00:00"
15
+ EMPTY_JSON = "{}"
16
+ JSON_TYPES = %i[json jsonb].freeze
17
+
18
+ # Skip the mask when the column is too short to hold `masked-<primary key>`
19
+ # for a realistic key; the rendered length is only known per row at dump time.
20
+ MIN_TEXT_LIMIT = 20
21
+ MIN_EMAIL_LIMIT = 40
22
+
23
+ module_function
24
+
25
+ # The default mask for a column, or nil when no safe constant fits it.
26
+ # `primary_key` is what keeps a text mask unique per row, so without one text
27
+ # is left unmasked too. Under a unique index (`unique`) only a mask that
28
+ # varies per row is allowed, or every row would collide on restore.
29
+ def for(name:, type:, limit:, primary_key:, array: false, unique: false, column_default: nil)
30
+ return nil if array
31
+
32
+ mask =
33
+ case type
34
+ when :string, :text then text_mask(name, limit, primary_key)
35
+ else constant_mask(type, column_default)
36
+ end
37
+
38
+ return nil if mask.nil?
39
+ return nil if unique && !varies_per_row?(mask, primary_key)
40
+
41
+ mask
42
+ end
43
+
44
+ # The column's own default wins over the per-type constant: it is a value the
45
+ # column provably holds and the one the application treats as neutral, so
46
+ # masking a `default: true` flag does not turn the feature off for every row.
47
+ # A default the database computes (`now()`) arrives as nil and falls through.
48
+ def constant_mask(type, column_default)
49
+ from_default = default_value(type, column_default)
50
+ return from_default unless from_default.nil?
51
+
52
+ case type
53
+ when :integer, :decimal, :float then 0
54
+ when :boolean then false
55
+ when :date then FIXED_DATE
56
+ when :datetime, :timestamp, :time then FIXED_TIME
57
+ when :json, :jsonb then EMPTY_JSON
58
+ end
59
+ end
60
+
61
+ # The default as a mask value, or nil when it cannot be one. A JSON column's
62
+ # default is serialized as JSON whatever Ruby class it arrives as, so a string
63
+ # default keeps its quoting. Anything whose JSON contains an object is
64
+ # rejected rather than mis-parsed, so an array of objects falls back to the
65
+ # empty-JSON constant too.
66
+ def default_value(type, value)
67
+ return nil if value.nil?
68
+
69
+ mask = JSON_TYPES.include?(type) ? value.to_json : scalar_default(value)
70
+ return nil if mask.is_a?(String) && mask.match?(MaskValue::PLACEHOLDER)
71
+
72
+ mask
73
+ end
74
+
75
+ def scalar_default(value)
76
+ case value
77
+ when nil then nil
78
+ when true, false, Integer, Float, String then value
79
+ when Numeric then value.to_f
80
+ when Time, DateTime then value.strftime("%Y-%m-%d %H:%M:%S")
81
+ when Date then value.strftime("%Y-%m-%d")
82
+ when Hash, Array then value.to_json
83
+ end
84
+ end
85
+
86
+ def text_mask(name, limit, primary_key)
87
+ return nil if primary_key.nil?
88
+
89
+ if name.to_s.include?("mail")
90
+ return nil if limit && limit < MIN_EMAIL_LIMIT
91
+
92
+ "masked-{#{primary_key}}@example.com"
93
+ else
94
+ return nil if limit && limit < MIN_TEXT_LIMIT
95
+
96
+ "masked-{#{primary_key}}"
97
+ end
98
+ end
99
+
100
+ def varies_per_row?(mask, primary_key)
101
+ mask.is_a?(String) && mask.include?("{#{primary_key}}")
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ # Serdes type for a `replace_with` value: a template String with `{column}`
5
+ # placeholders, rendered as text, or a non-String JSON scalar used verbatim so
6
+ # a column that is not text keeps its type (in MongoDB, its BSON type).
7
+ class MaskValue < Serdes::TypeBase
8
+ PERMITTED = [String, Integer, Float].freeze
9
+
10
+ # What counts as a `{column}` placeholder: an empty brace pair names no
11
+ # column, which is what lets `{}` be an empty-JSON mask. One definition,
12
+ # shared by the adapters that parse a template and the generator that must
13
+ # avoid emitting one — keep it capture-free, since the splitter's `scan`
14
+ # reads whole matches.
15
+ PLACEHOLDER = /\{[^{}]+\}/
16
+
17
+ def permit?(value)
18
+ value == true || value == false || PERMITTED.any? { |type| value.is_a?(type) }
19
+ end
20
+
21
+ # Whether `value` is used verbatim rather than rendered as a template.
22
+ def self.scalar?(value)
23
+ !value.is_a?(String) && !value.nil?
24
+ end
25
+
26
+ def to_s
27
+ "mask_value"
28
+ end
29
+ end
30
+ end
@@ -139,10 +139,13 @@ module Exwiw
139
139
  merged.fields = passed.fields.map do |pf|
140
140
  receiver = receiver_field_by_name[pf.name]
141
141
  if receiver
142
- pf.replace_with = receiver.replace_with if receiver.replace_with
142
+ pf.replace_with = receiver.replace_with unless receiver.replace_with.nil?
143
143
  pf.replace_with_fake_data = receiver.replace_with_fake_data if receiver.replace_with_fake_data
144
144
  pf.comment = receiver.comment if receiver.comment
145
145
  pf.ignore = receiver.ignore unless receiver.ignore.nil?
146
+ # Receiver wins even when unset: clearing the flag is how a human
147
+ # records the decision, and regeneration must not bring it back.
148
+ pf.needs_mask_decision = receiver.needs_mask_decision
146
149
  end
147
150
  pf
148
151
  end
@@ -162,7 +165,7 @@ module Exwiw
162
165
  fake_data = field.replace_with_fake_data
163
166
  next unless fake_data
164
167
 
165
- if field.replace_with
168
+ unless field.replace_with.nil?
166
169
  raise ArgumentError,
167
170
  "MongodbCollectionConfig '#{name}' field '#{field.name}': replace_with and " \
168
171
  "replace_with_fake_data cannot be combined; use only one."
@@ -5,7 +5,7 @@ module Exwiw
5
5
  include Serdes
6
6
 
7
7
  attribute :name, String
8
- attribute :replace_with, optional(String), skip_serializing_if_nil: true
8
+ attribute :replace_with, Serdes::OptionalType.new(MaskValue.new), skip_serializing_if_nil: true
9
9
  # Ruby-process-side masking: replace the value with a deterministic fake
10
10
  # value derived from a seed field (see FakeData / RowTransformer). Unlike the
11
11
  # SQL adapters — where replace_with runs in the database and fake data needs
@@ -23,6 +23,10 @@ module Exwiw
23
23
  # once the config is loaded (see MongodbCollectionConfig#reject_ignored_members!).
24
24
  attribute :comment, optional(String), skip_serializing_if_nil: true
25
25
  attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
26
+ # See TableColumn#needs_mask_decision.
27
+ attribute :needs_mask_decision,
28
+ Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)),
29
+ skip_serializing_if_nil: true
26
30
 
27
31
  def self.from_symbol_keys(hash)
28
32
  from(hash.transform_keys(&:to_s))
@@ -177,7 +177,7 @@ module Exwiw
177
177
  columns.map do |c|
178
178
  if c.raw_sql
179
179
  QueryAst::ColumnValue::RawSql.new(name: c.name, value: c.raw_sql)
180
- elsif c.replace_with
180
+ elsif !c.replace_with.nil?
181
181
  QueryAst::ColumnValue::ReplaceWith.new(name: c.name, value: c.replace_with)
182
182
  else
183
183
  QueryAst::ColumnValue::Plain.new(name: c.name, value: c.name)
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+ require "tmpdir"
7
+
8
+ module Exwiw
9
+ # Reports how the committed schema config differs from what the application
10
+ # would generate now, plus the columns still waiting for a masking decision.
11
+ # Regenerating into a copy rather than over the config directory lets it run
12
+ # on a working tree it must not modify (CI). ActiveRecord only.
13
+ class SchemaCheck
14
+ CATEGORIES = %w[
15
+ added_tables added_columns removed_tables removed_columns changed_tables needs_mask_decision
16
+ ].freeze
17
+
18
+ def self.from_rails_application(schema_dir:)
19
+ Rails.application.eager_load!
20
+ new(models: ActiveRecord::Base.descendants, schema_dir: schema_dir)
21
+ end
22
+
23
+ def initialize(models:, schema_dir:)
24
+ @models = models
25
+ @schema_dir = schema_dir
26
+ end
27
+
28
+ # The report as a plain Hash. Every list is sorted, so a given state always
29
+ # produces the same output (it ends up in a CI comment).
30
+ def run
31
+ committed = read_configs(@schema_dir)
32
+ regenerated = Dir.mktmpdir do |tmp_dir|
33
+ regenerate_into(tmp_dir)
34
+ read_configs(tmp_dir)
35
+ end
36
+
37
+ report = diff(committed, regenerated)
38
+ report["needs_mask_decision"] = flagged_columns(committed)
39
+ report
40
+ end
41
+
42
+ # Whether the report requires someone to act: the config is out of date, or
43
+ # a column's masking has not been decided yet.
44
+ def self.clean?(report)
45
+ CATEGORIES.all? { |category| report.fetch(category, []).empty? }
46
+ end
47
+
48
+ private def regenerate_into(tmp_dir)
49
+ FileUtils.cp_r(File.join(@schema_dir, "."), tmp_dir) if Dir.exist?(@schema_dir)
50
+ # Explicit: safe mode is not optional here, whatever the library default is.
51
+ SchemaGenerator.new(models: @models, output_dir: tmp_dir, safe_new_columns: true).generate!
52
+ SchemaGenerator.new(models: @models, output_dir: tmp_dir).tidy!
53
+ end
54
+
55
+ # Every config file under `dir`, keyed by its path relative to `dir` so the
56
+ # per-database subdirectories stay distinct. Hand-editing these files is the
57
+ # workflow this drives, so a syntax error has to name the file it is in.
58
+ private def read_configs(dir)
59
+ return {} unless Dir.exist?(dir)
60
+
61
+ Dir[File.join(dir, "**", "*.json")].each_with_object({}) do |path, acc|
62
+ key = Pathname.new(path).relative_path_from(Pathname.new(dir)).to_s
63
+ begin
64
+ acc[key] = JSON.parse(File.read(path))
65
+ rescue JSON::ParserError => e
66
+ raise JSON::ParserError, "invalid JSON in schema config '#{path}': #{e.message}"
67
+ end
68
+ end
69
+ end
70
+
71
+ private def diff(committed, regenerated)
72
+ report = CATEGORIES.to_h { |category| [category, []] }
73
+
74
+ (committed.keys | regenerated.keys).sort.each do |key|
75
+ before = committed[key]
76
+ after = regenerated[key]
77
+
78
+ if before.nil?
79
+ report["added_tables"] << table_label(key, after)
80
+ next
81
+ end
82
+ if after.nil?
83
+ report["removed_tables"] << table_label(key, before)
84
+ next
85
+ end
86
+ next if before == after
87
+
88
+ label = table_label(key, before)
89
+ report["changed_tables"] << label
90
+ added, removed = column_diff(before, after)
91
+ report["added_columns"] += added.sort.map { |column| "#{label}.#{column}" }
92
+ report["removed_columns"] += removed.sort.map { |column| "#{label}.#{column}" }
93
+ end
94
+
95
+ report
96
+ end
97
+
98
+ private def column_diff(before, after)
99
+ before_names = column_names(before)
100
+ after_names = column_names(after)
101
+ [after_names - before_names, before_names - after_names]
102
+ end
103
+
104
+ # `fields` is the MongoDB config's spelling of `columns`.
105
+ private def column_names(config)
106
+ (config["columns"] || config["fields"] || []).map { |column| column["name"] }
107
+ end
108
+
109
+ # How a table is named in the report. The database is part of the label, or
110
+ # the same table name in two of them collides (each has `schema_migrations`).
111
+ private def table_label(key, config)
112
+ name = config&.fetch("name", nil) || File.basename(key, ".json")
113
+ db = File.dirname(key)
114
+ db == "." ? name : "#{db}/#{name}"
115
+ end
116
+
117
+ private def flagged_columns(committed)
118
+ committed.flat_map do |key, config|
119
+ (config["columns"] || config["fields"] || [])
120
+ .select { |column| column["needs_mask_decision"] }
121
+ .map { |column| "#{table_label(key, config)}.#{column['name']}" }
122
+ end.sort
123
+ end
124
+ end
125
+ end
@@ -43,14 +43,20 @@ module Exwiw
43
43
  # belongs_to to it.
44
44
  ACTIVE_STORAGE_VARIANT_RECORDS_TABLE = "active_storage_variant_records"
45
45
 
46
- def self.from_rails_application(output_dir:)
46
+ def self.from_rails_application(output_dir:, safe_new_columns: true)
47
47
  Rails.application.eager_load!
48
- new(models: ActiveRecord::Base.descendants, output_dir: output_dir)
48
+ new(models: ActiveRecord::Base.descendants, output_dir: output_dir, safe_new_columns: safe_new_columns)
49
49
  end
50
50
 
51
- def initialize(models:, output_dir:)
51
+ # `safe_new_columns` (the default) emits every column masked — as far as its
52
+ # type allows, see DefaultMask — and flagged `needs_mask_decision: true`.
53
+ # #merge lets an existing entry win, so in practice only columns a migration
54
+ # has just added keep that treatment. Pass false to bootstrap a config, where
55
+ # every column is new and flagging all of them at once is noise.
56
+ def initialize(models:, output_dir:, safe_new_columns: true)
52
57
  @models = models
53
58
  @output_dir = output_dir
59
+ @safe_new_columns = safe_new_columns
54
60
  end
55
61
 
56
62
  def generate!
@@ -239,11 +245,12 @@ module Exwiw
239
245
  columns: representative.column_names.map { |name| { name: name } },
240
246
  )
241
247
  else
248
+ belongs_tos = aggregate_belongs_tos(model_group)
242
249
  TableConfig.from_symbol_keys(
243
250
  name: table_name,
244
251
  primary_key: primary_key,
245
- belongs_tos: aggregate_belongs_tos(model_group),
246
- columns: representative.column_names.map { |name| { name: name } },
252
+ belongs_tos: belongs_tos,
253
+ columns: build_columns(representative, primary_key, belongs_tos, conn),
247
254
  )
248
255
  end
249
256
  end
@@ -251,6 +258,58 @@ module Exwiw
251
258
  tables_from_models + build_rails_managed_tables(conn)
252
259
  end
253
260
 
261
+ # The `columns` entries for a table: just the name, or — in safe mode — also
262
+ # a default mask and the `needs_mask_decision` flag. The primary key and the
263
+ # foreign keys/types the belongs_tos join on are flagged but never masked,
264
+ # since masking them would break the joins.
265
+ private def build_columns(representative, primary_key, belongs_tos, conn)
266
+ names = representative.column_names
267
+ return names.map { |name| { name: name } } unless @safe_new_columns
268
+
269
+ structural = belongs_tos.flat_map { |bt| [bt[:foreign_key], bt[:foreign_type]] }.compact.to_set
270
+ structural << primary_key if primary_key
271
+ columns_by_name = representative.columns.each_with_object({}) { |column, acc| acc[column.name] = column }
272
+ unique = unique_column_names(conn, representative.table_name)
273
+ defaults = representative.column_defaults
274
+
275
+ names.map do |name|
276
+ entry = { name: name, needs_mask_decision: true }
277
+ next entry if structural.include?(name)
278
+
279
+ column = columns_by_name[name]
280
+ mask = column && DefaultMask.for(
281
+ name: name,
282
+ type: column.type,
283
+ limit: column.limit,
284
+ primary_key: primary_key,
285
+ # `integer[]` reports `:integer`, so a scalar default would not fit.
286
+ array: column.respond_to?(:array?) && column.array?,
287
+ unique: unique.nil? || unique.include?(name),
288
+ column_default: defaults[name],
289
+ )
290
+ mask.nil? ? entry : entry.merge(replace_with: mask)
291
+ end
292
+ end
293
+
294
+ # Columns covered by a unique index, or nil when they could not be read —
295
+ # callers treat that as "assume every column is unique", since masking a
296
+ # unique column with a constant breaks the restore the dump feeds.
297
+ #
298
+ # `Array()` because an expression index reports a String (`lower((email)::text)`)
299
+ # rather than a list; wrapping keeps it from being walked character by
300
+ # character, and it simply never matches a column name.
301
+ private def unique_column_names(conn, table_name)
302
+ conn.indexes(table_name).select(&:unique).flat_map { |index| Array(index.columns) }.to_set
303
+ rescue StandardError => e
304
+ # Once per run: a systematic failure would otherwise repeat per table.
305
+ unless @warned_missing_indexes
306
+ @warned_missing_indexes = true
307
+ warn "exwiw: could not read the indexes of '#{table_name}' (#{e.class}); " \
308
+ "treating every column as unique-indexed so no constant mask is emitted."
309
+ end
310
+ nil
311
+ end
312
+
254
313
  private def concrete_models
255
314
  @models.reject(&:abstract_class?).select(&:table_exists?)
256
315
  end
@@ -5,7 +5,7 @@ module Exwiw
5
5
  include Serdes
6
6
 
7
7
  attribute :name, String
8
- attribute :replace_with, optional(String), skip_serializing_if_nil: true
8
+ attribute :replace_with, Serdes::OptionalType.new(MaskValue.new), skip_serializing_if_nil: true
9
9
  attribute :raw_sql, optional(String), skip_serializing_if_nil: true
10
10
  # Ruby-process-side masking modes, applied to the fetched rows by
11
11
  # RowTransformer (SQL adapters only) — unlike replace_with/raw_sql, which
@@ -17,6 +17,12 @@ module Exwiw
17
17
  # INSERT) once the config is loaded (see TableConfig#reject_ignored_members!).
18
18
  attribute :comment, optional(String), skip_serializing_if_nil: true
19
19
  attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
20
+ # Marks a column whose masking nobody has decided on yet. Extraction never
21
+ # reads it; `schema:generate` emits it and `schema:check` reports it, so CI
22
+ # can require the decision.
23
+ attribute :needs_mask_decision,
24
+ Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)),
25
+ skip_serializing_if_nil: true
20
26
 
21
27
  def self.from_symbol_keys(hash)
22
28
  from(hash.transform_keys(&:to_s))
@@ -259,7 +259,7 @@ module Exwiw
259
259
  ruby_side = [column.map && "map", column.replace_with_fake_data && "replace_with_fake_data"].compact
260
260
  return if ruby_side.empty?
261
261
 
262
- sql_side = [column.raw_sql && "raw_sql", column.replace_with && "replace_with"].compact
262
+ sql_side = [column.raw_sql && "raw_sql", !column.replace_with.nil? && "replace_with"].select { |v| v }
263
263
  if ruby_side.size > 1 || sql_side.any?
264
264
  raise ArgumentError,
265
265
  "Table '#{name}' column '#{column.name}': #{(ruby_side + sql_side).join('/')} cannot be combined; " \
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.9.20"
4
+ VERSION = "0.9.21"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -10,6 +10,8 @@ require_relative "exwiw/config_file"
10
10
  require_relative "exwiw/strict_keys"
11
11
  require_relative "exwiw/belongs_to"
12
12
  require_relative "exwiw/fake_data"
13
+ require_relative "exwiw/mask_value"
14
+ require_relative "exwiw/default_mask"
13
15
  require_relative "exwiw/table_column"
14
16
  require_relative "exwiw/reverse_scope"
15
17
  require_relative "exwiw/batch_scope"
@@ -38,6 +40,7 @@ require_relative "exwiw/after_insert_hook"
38
40
  require_relative "exwiw/runner"
39
41
  require_relative "exwiw/explain_runner"
40
42
  require_relative "exwiw/schema_generator"
43
+ require_relative "exwiw/schema_check"
41
44
  require_relative "exwiw/mongoid_schema_generator"
42
45
 
43
46
  begin
data/lib/tasks/exwiw.rake CHANGED
@@ -13,12 +13,18 @@ namespace :exwiw do
13
13
  ENV["EXWIW_SCHEMA_DIR_PATH"] || Exwiw::ConfigFile.schema_dir || "exwiw/schema"
14
14
  end
15
15
 
16
+ # Safe mode is on unless EXWIW_NEW_COLUMNS=plain, which a first-time
17
+ # bootstrap wants: there every column is new, so safe mode would flag the
18
+ # whole config at once. See SchemaGenerator#initialize.
19
+ safe_new_columns = lambda { ENV["EXWIW_NEW_COLUMNS"] != "plain" }
20
+
16
21
  desc "Generate schema from application"
17
22
  task generate: :environment do
18
23
  require "exwiw"
19
24
 
20
25
  groups = Exwiw::SchemaGenerator.from_rails_application(
21
26
  output_dir: resolve_schema_dir.call,
27
+ safe_new_columns: safe_new_columns.call,
22
28
  ).generate!
23
29
 
24
30
  # Surface cross-database belongs_tos the generator auto-ignored: these
@@ -38,6 +44,24 @@ namespace :exwiw do
38
44
  end
39
45
  end
40
46
 
47
+ desc "Report how the committed schema config differs from the application, without changing it"
48
+ task check: :environment do
49
+ require "exwiw"
50
+
51
+ report = Exwiw::SchemaCheck.from_rails_application(schema_dir: resolve_schema_dir.call).run
52
+ json = JSON.pretty_generate(report)
53
+ puts json
54
+ # A file too, so a caller need not assume stdout carries only the JSON.
55
+ File.write(ENV["EXWIW_SCHEMA_CHECK_OUTPUT"], json + "\n") if ENV["EXWIW_SCHEMA_CHECK_OUTPUT"]
56
+
57
+ unless Exwiw::SchemaCheck.clean?(report)
58
+ $stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
59
+ "run `rake exwiw:schema:generate exwiw:schema:tidy` " \
60
+ "and resolve every `needs_mask_decision` column."
61
+ exit 1
62
+ end
63
+ end
64
+
41
65
  desc "Remove tables/columns from the schema config that no longer exist in the application"
42
66
  task tidy: :environment do
43
67
  require "exwiw"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.20
4
+ version: 0.9.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -70,12 +70,14 @@ files:
70
70
  - lib/exwiw/cli.rb
71
71
  - lib/exwiw/config_file.rb
72
72
  - lib/exwiw/ddl_postprocessor.rb
73
+ - lib/exwiw/default_mask.rb
73
74
  - lib/exwiw/determine_table_processing_order.rb
74
75
  - lib/exwiw/embedded_in.rb
75
76
  - lib/exwiw/explain_runner.rb
76
77
  - lib/exwiw/ext_json.rb
77
78
  - lib/exwiw/fake_data.rb
78
79
  - lib/exwiw/japanese_names.rb
80
+ - lib/exwiw/mask_value.rb
79
81
  - lib/exwiw/mongo_query.rb
80
82
  - lib/exwiw/mongodb_collection_config.rb
81
83
  - lib/exwiw/mongodb_field.rb
@@ -88,6 +90,7 @@ files:
88
90
  - lib/exwiw/reverse_scope.rb
89
91
  - lib/exwiw/row_transformer.rb
90
92
  - lib/exwiw/runner.rb
93
+ - lib/exwiw/schema_check.rb
91
94
  - lib/exwiw/schema_generator.rb
92
95
  - lib/exwiw/strict_keys.rb
93
96
  - lib/exwiw/table_column.rb