lethe 0.1.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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +326 -0
- data/lib/generators/lethe/install/install_generator.rb +24 -0
- data/lib/generators/lethe/install/templates/create_lethe_erasures.rb.tt +14 -0
- data/lib/generators/lethe/install/templates/lethe.rb.tt +7 -0
- data/lib/lethe/checker.rb +205 -0
- data/lib/lethe/configuration.rb +75 -0
- data/lib/lethe/definition.rb +118 -0
- data/lib/lethe/discovery.rb +117 -0
- data/lib/lethe/enums.rb +46 -0
- data/lib/lethe/eraser.rb +126 -0
- data/lib/lethe/exporter.rb +163 -0
- data/lib/lethe/formatters.rb +178 -0
- data/lib/lethe/ledger.rb +29 -0
- data/lib/lethe/leftovers.rb +98 -0
- data/lib/lethe/locales/en.yml +77 -0
- data/lib/lethe/locales/ja.yml +77 -0
- data/lib/lethe/locator.rb +59 -0
- data/lib/lethe/plan.rb +110 -0
- data/lib/lethe/policy.rb +31 -0
- data/lib/lethe/railtie.rb +10 -0
- data/lib/lethe/reference.rb +43 -0
- data/lib/lethe/report.rb +36 -0
- data/lib/lethe/result.rb +22 -0
- data/lib/lethe/rows.rb +37 -0
- data/lib/lethe/rules.rb +136 -0
- data/lib/lethe/runner.rb +126 -0
- data/lib/lethe/sweeper.rb +50 -0
- data/lib/lethe/target.rb +42 -0
- data/lib/lethe/tasks/lethe.rake +26 -0
- data/lib/lethe/version.rb +5 -0
- data/lib/lethe.rb +193 -0
- metadata +119 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
class Configuration
|
|
5
|
+
# Tables Rails and lethe keep for themselves. Active Storage's are left
|
|
6
|
+
# out because attachments are found through has_one_attached and
|
|
7
|
+
# has_many_attached, and declared with destroy_attachments.
|
|
8
|
+
BUILTIN_SKIP_TABLES = %w[
|
|
9
|
+
ar_internal_metadata schema_migrations lethe_erasures
|
|
10
|
+
active_storage_attachments active_storage_blobs active_storage_variant_records
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# Where the definitions live, relative to the application root.
|
|
14
|
+
attr_accessor :definition_path
|
|
15
|
+
|
|
16
|
+
# Where lethe:export writes, without extension: .md and .json are added.
|
|
17
|
+
attr_accessor :export_path
|
|
18
|
+
|
|
19
|
+
# Tables to leave out of the search, besides Rails' own.
|
|
20
|
+
attr_accessor :skip_tables
|
|
21
|
+
|
|
22
|
+
# How erased attachments' files go, after the transaction commits:
|
|
23
|
+
# :later through Active Storage's purge job, or :now.
|
|
24
|
+
attr_reader :purge
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@definition_path = "config/lethe.rb"
|
|
28
|
+
@export_path = "docs/lethe"
|
|
29
|
+
@skip_tables = []
|
|
30
|
+
@purge = :later
|
|
31
|
+
@guess_columns = Hash.new { |hash, key| hash[key] = [] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# More column names to treat as pointing at a subject, per subject:
|
|
35
|
+
# config.guess_columns[User] = %w[follower_id following_id]
|
|
36
|
+
def guess_columns
|
|
37
|
+
GuessColumns.new(@guess_columns)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def purge=(value)
|
|
41
|
+
raise ArgumentError, "lethe: purge must be :later or :now" unless %i[later now].include?(value)
|
|
42
|
+
|
|
43
|
+
@purge = value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def guess_columns_for(subject_name)
|
|
47
|
+
@guess_columns.fetch(subject_name, []).map(&:to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def skipped_table?(table)
|
|
51
|
+
BUILTIN_SKIP_TABLES.include?(table) || skip_tables.map(&:to_s).include?(table)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Lets guess_columns be keyed by the class or its name.
|
|
55
|
+
class GuessColumns
|
|
56
|
+
def initialize(store)
|
|
57
|
+
@store = store
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def [](subject)
|
|
61
|
+
@store[key(subject)]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def []=(subject, columns)
|
|
65
|
+
@store[key(subject)] = Array(columns).map(&:to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def key(subject)
|
|
71
|
+
subject.is_a?(Module) ? subject.name : subject.to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# The policies declared for one subject, such as User.
|
|
5
|
+
class Definition
|
|
6
|
+
attr_reader :subject_name, :policies, :hooks
|
|
7
|
+
attr_accessor :subject_policy
|
|
8
|
+
|
|
9
|
+
def initialize(subject_name)
|
|
10
|
+
@subject_name = subject_name
|
|
11
|
+
@policies = []
|
|
12
|
+
@hooks = []
|
|
13
|
+
@subject_policy = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def hooks_for(moment)
|
|
17
|
+
hooks.select { |hook| hook.moment == moment }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The methods available inside Lethe.define.
|
|
21
|
+
class Builder
|
|
22
|
+
def initialize(definition)
|
|
23
|
+
@definition = definition
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Delete the rows. cascade: true deletes them through Active Record,
|
|
27
|
+
# so that dependent: associations, callbacks and attachments go too.
|
|
28
|
+
def destroy(*targets, reason: nil, where: nil, cascade: false)
|
|
29
|
+
add(:destroy, targets, location: caller_location, reason: reason, where: where, cascade: cascade)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Keep the rows but overwrite columns: scrub :posts, set: { body: "" }
|
|
33
|
+
# With for:, lethe:sweep deletes the rows once that time has passed:
|
|
34
|
+
# scrub :orders, set: { phone: nil }, for: 7.years. cascade: true makes
|
|
35
|
+
# that deletion go through Active Record, as with destroy.
|
|
36
|
+
def scrub(*targets, set:, reason: nil, where: nil, cascade: false, **options)
|
|
37
|
+
add(:scrub, targets, location: caller_location, reason: reason, where: where, set: set,
|
|
38
|
+
retention: retention(options), cascade: cascade)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Leave the rows as they are, for a reason: keep :orders, for: 7.years, reason: "..."
|
|
42
|
+
def keep(*targets, reason: nil, where: nil, cascade: false, **options)
|
|
43
|
+
add(:keep, targets, location: caller_location, reason: reason, where: where,
|
|
44
|
+
retention: retention(options), cascade: cascade)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The column does not point at the subject after all (a wrong guess).
|
|
48
|
+
def ignore(*targets, reason: nil)
|
|
49
|
+
add(:ignore, targets, location: caller_location, reason: reason)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Delete Active Storage attachments of the subject.
|
|
53
|
+
def destroy_attachments(*names, reason: nil)
|
|
54
|
+
raise ArgumentError, "lethe: destroy_attachments needs at least one name" if names.empty?
|
|
55
|
+
|
|
56
|
+
location = caller_location
|
|
57
|
+
names.each do |name|
|
|
58
|
+
@definition.policies << Policy.new(
|
|
59
|
+
action: :destroy_attachment, target: Target.attachment(name), reason: reason,
|
|
60
|
+
retention: nil, where: nil, set: nil, cascade: false, location: location
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# How the subject's own row goes: subject soft_delete: :deleted_at, scrub: { email: nil }
|
|
66
|
+
def subject(destroy: false, soft_delete: nil, scrub: {})
|
|
67
|
+
@definition.subject_policy = SubjectPolicy.new(
|
|
68
|
+
destroy: destroy, soft_delete: soft_delete, scrub: scrub, location: caller_location
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Runs inside the erasure's transaction, before anything is deleted.
|
|
73
|
+
def before_erase(&block)
|
|
74
|
+
hook(:before, block)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Runs inside the erasure's transaction, after everything is deleted.
|
|
78
|
+
# Anything that cannot be rolled back, such as a call to another
|
|
79
|
+
# service, belongs in a job enqueued from here.
|
|
80
|
+
def after_erase(&block)
|
|
81
|
+
hook(:after, block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# for: is a Ruby keyword, so it arrives in the options hash.
|
|
87
|
+
def retention(options)
|
|
88
|
+
unknown = options.keys - [:for]
|
|
89
|
+
raise ArgumentError, "lethe: unknown keyword#{"s" if unknown.size > 1}: #{unknown.join(", ")}" if unknown.any?
|
|
90
|
+
|
|
91
|
+
options[:for]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def add(action, targets, location:, reason: nil, where: nil, set: nil, retention: nil, cascade: false)
|
|
95
|
+
raise ArgumentError, "lethe: #{action} needs at least one target" if targets.empty?
|
|
96
|
+
|
|
97
|
+
targets.each do |value|
|
|
98
|
+
@definition.policies << Policy.new(
|
|
99
|
+
action: action, target: Target.parse(value), reason: reason,
|
|
100
|
+
retention: retention, where: where, set: set, cascade: cascade ? true : false, location: location
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def hook(moment, block)
|
|
106
|
+
raise ArgumentError, "lethe: #{moment}_erase needs a block" unless block
|
|
107
|
+
|
|
108
|
+
location = caller_locations(2, 1).first
|
|
109
|
+
@definition.hooks << Hook.new(moment: moment, block: block, location: "#{location.path}:#{location.lineno}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def caller_location
|
|
113
|
+
location = caller_locations(2, 1).first
|
|
114
|
+
"#{location.path}:#{location.lineno}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Finds everything that points at a subject model, from the schema and the
|
|
5
|
+
# models' associations. Reads table and column definitions only, never rows.
|
|
6
|
+
class Discovery
|
|
7
|
+
def initialize(subject, models:, connection:, configuration:)
|
|
8
|
+
@subject = subject
|
|
9
|
+
@models = models
|
|
10
|
+
@connection = connection
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns the references, one per table.column or attachment, sorted by key.
|
|
15
|
+
def call
|
|
16
|
+
found = {}
|
|
17
|
+
[foreign_keys, own_associations, other_associations, attachments, column_names].each do |references|
|
|
18
|
+
references.each do |reference|
|
|
19
|
+
found[reference.key] = found.key?(reference.key) ? found[reference.key].merge(reference) : reference
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
found.values.sort_by(&:key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def tables
|
|
26
|
+
@tables ||= @connection.tables.reject { |table| @configuration.skipped_table?(table) }.sort
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def subject_table
|
|
32
|
+
@subject.table_name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Foreign key constraints that point at the subject's table.
|
|
36
|
+
def foreign_keys
|
|
37
|
+
tables.flat_map do |table|
|
|
38
|
+
@connection.foreign_keys(table)
|
|
39
|
+
.select { |key| key.to_table == subject_table }
|
|
40
|
+
.map { |key| Reference.column(table, key.column, :foreign_key) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The subject's has_many, has_one and has_and_belongs_to_many.
|
|
45
|
+
def own_associations
|
|
46
|
+
@subject.reflect_on_all_associations.filter_map do |reflection|
|
|
47
|
+
# Through associations read other tables; has_one_attached's own
|
|
48
|
+
# associations are reported as attachments instead.
|
|
49
|
+
next if reflection.through_reflection? || reflection.class_name == "ActiveStorage::Attachment"
|
|
50
|
+
|
|
51
|
+
case reflection.macro
|
|
52
|
+
when :has_many, :has_one
|
|
53
|
+
table = table_of(reflection)
|
|
54
|
+
next unless table
|
|
55
|
+
|
|
56
|
+
via = reflection.options[:as] ? :polymorphic : :association
|
|
57
|
+
Reference.column(table, reflection.foreign_key, via)
|
|
58
|
+
when :has_and_belongs_to_many
|
|
59
|
+
Reference.column(reflection.join_table, reflection.foreign_key, :join_table)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# belongs_to in other models that points at the subject. A polymorphic
|
|
65
|
+
# belongs_to might point at anything, so it is only a guess.
|
|
66
|
+
def other_associations
|
|
67
|
+
@models.flat_map do |model|
|
|
68
|
+
next [] if model.abstract_class? || !table_exists?(model)
|
|
69
|
+
|
|
70
|
+
model.reflect_on_all_associations(:belongs_to).filter_map do |reflection|
|
|
71
|
+
if reflection.polymorphic?
|
|
72
|
+
Reference.column(model.table_name, reflection.foreign_key, :polymorphic_guess)
|
|
73
|
+
elsif table_of(reflection) == subject_table
|
|
74
|
+
Reference.column(model.table_name, reflection.foreign_key, :association)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def attachments
|
|
81
|
+
return [] unless @subject.respond_to?(:reflect_on_all_attachments)
|
|
82
|
+
|
|
83
|
+
@subject.reflect_on_all_attachments.map { |reflection| Reference.attachment(reflection.name) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Columns named like user_id or *_user_id, and the ones configured.
|
|
87
|
+
def column_names
|
|
88
|
+
# An STI subclass is stored in its base class's table, and other tables
|
|
89
|
+
# point at it as user_id, not admin_id.
|
|
90
|
+
singular = @subject.base_class.model_name.singular
|
|
91
|
+
extra = (@configuration.guess_columns_for(@subject.name) + @configuration.guess_columns_for(@subject.base_class.name)).uniq
|
|
92
|
+
tables.flat_map do |table|
|
|
93
|
+
@connection.columns(table).filter_map do |column|
|
|
94
|
+
name = column.name
|
|
95
|
+
next if table == subject_table && name == @subject.primary_key
|
|
96
|
+
|
|
97
|
+
if name == "#{singular}_id" || name.end_with?("_#{singular}_id") || extra.include?(name)
|
|
98
|
+
Reference.column(table, name, :column_name)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The table an association reads, or nil when its class cannot be loaded.
|
|
105
|
+
def table_of(reflection)
|
|
106
|
+
reflection.klass.table_name
|
|
107
|
+
rescue NameError, ArgumentError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def table_exists?(model)
|
|
112
|
+
tables.include?(model.table_name)
|
|
113
|
+
rescue ActiveRecord::ActiveRecordError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/lethe/enums.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# lethe reaches rows through plain table classes, which know nothing of the
|
|
5
|
+
# application's enums: { status: "answered" } would become status = NULL
|
|
6
|
+
# and match nothing. These turn enum names into the values stored, using
|
|
7
|
+
# the application's model for the table.
|
|
8
|
+
module Enums
|
|
9
|
+
# The hash with enum names replaced by their stored values. Values that
|
|
10
|
+
# are not enum names, such as nil or a stored value, stay as they are.
|
|
11
|
+
def self.cast(model, hash)
|
|
12
|
+
return hash unless model && hash.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
enums = model.defined_enums
|
|
15
|
+
hash.to_h do |column, value|
|
|
16
|
+
mapping = enums[column.to_s]
|
|
17
|
+
[column, mapping ? cast_value(mapping, value) : value]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The names in hash that are neither a name nor a stored value of the
|
|
22
|
+
# column's enum, as [column, value] pairs.
|
|
23
|
+
def self.unknown(model, hash)
|
|
24
|
+
return [] unless model && hash.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
hash.flat_map do |column, value|
|
|
27
|
+
mapping = model.defined_enums[column.to_s]
|
|
28
|
+
next [] unless mapping
|
|
29
|
+
|
|
30
|
+
Array(value).reject { |item| item.nil? || item.is_a?(Proc) || known?(mapping, item) }.map { |item| [column.to_s, item] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.cast_value(mapping, value)
|
|
35
|
+
return value.map { |item| cast_value(mapping, item) } if value.is_a?(Array)
|
|
36
|
+
return mapping[value.to_s] if (value.is_a?(String) || value.is_a?(Symbol)) && mapping.key?(value.to_s)
|
|
37
|
+
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.known?(mapping, item)
|
|
42
|
+
mapping.key?(item.to_s) || mapping.value?(item)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :cast_value, :known?
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/lethe/eraser.rb
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Carries out a Plan in one transaction. Attachments are detached inside
|
|
5
|
+
# it; their files are purged only after it commits, since files cannot be
|
|
6
|
+
# rolled back.
|
|
7
|
+
class Eraser
|
|
8
|
+
Erasure = Data.define(:subject, :id, :counts)
|
|
9
|
+
|
|
10
|
+
def initialize(plan, configuration:, ledger:)
|
|
11
|
+
@plan = plan
|
|
12
|
+
@record = plan.record
|
|
13
|
+
@definition = plan.definition
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@ledger = ledger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
blobs = []
|
|
20
|
+
counts = {}
|
|
21
|
+
ActiveRecord::Base.transaction do
|
|
22
|
+
@definition.hooks_for(:before).each { |hook| hook.block.call(@record) }
|
|
23
|
+
# { "posts.user_id" => { "destroy" => 1, "scrub" => 4 } }: a table can have a policy per condition.
|
|
24
|
+
@plan.each do |step|
|
|
25
|
+
rows = perform(step, blobs)
|
|
26
|
+
(counts[step.key] ||= Hash.new(0))[step.kind.to_s] += rows
|
|
27
|
+
end
|
|
28
|
+
@definition.hooks_for(:after).each { |hook| hook.block.call(@record) }
|
|
29
|
+
Ledger.record(subject_type, @record.id, "erase", counts) if @ledger
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
erasure = Erasure.new(subject: subject_type, id: @record.id, counts: counts)
|
|
33
|
+
ActiveRecord.after_all_transactions_commit do
|
|
34
|
+
failed = purge(blobs)
|
|
35
|
+
ActiveSupport::Notifications.instrument(
|
|
36
|
+
"erase.lethe", subject: erasure.subject, id: erasure.id, counts: counts, purge_failures: failed
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
erasure
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# The definition's subject, so that an STI subclass with a definition of
|
|
45
|
+
# its own is swept by its own retention.
|
|
46
|
+
def subject_type
|
|
47
|
+
@definition.subject_name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def perform(step, blobs)
|
|
51
|
+
case step.kind
|
|
52
|
+
when :attachment then detach(step.policy.target.name, blobs)
|
|
53
|
+
when :destroy then destroy(step)
|
|
54
|
+
when :scrub then scrub(step)
|
|
55
|
+
when :keep then 0
|
|
56
|
+
when :subject then erase_subject(step)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def destroy(step)
|
|
61
|
+
if step.policy.cascade
|
|
62
|
+
step.location.model.unscoped.where(step.conditions).destroy_all.size
|
|
63
|
+
else
|
|
64
|
+
rows(step).where(step.conditions).delete_all
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def scrub(step)
|
|
69
|
+
set = step.policy.set
|
|
70
|
+
unless step.policy.computed_set?
|
|
71
|
+
return rows(step).where(step.conditions).update_all(Enums.cast(step.location.model, set))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
count = 0
|
|
75
|
+
rows(step).where(step.conditions).find_each do |row|
|
|
76
|
+
row.update_columns(Enums.cast(step.location.model, evaluate(set, row)))
|
|
77
|
+
count += 1
|
|
78
|
+
end
|
|
79
|
+
count
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def erase_subject(step)
|
|
83
|
+
policy = step.policy
|
|
84
|
+
relation = rows(step).where(step.conditions)
|
|
85
|
+
return relation.delete_all if policy.destroy
|
|
86
|
+
|
|
87
|
+
# The first erasure's time stays, so that retention counts from it.
|
|
88
|
+
relation.where(policy.soft_delete => nil).update_all(policy.soft_delete => Time.current)
|
|
89
|
+
relation.update_all(Enums.cast(@record.class, evaluate(policy.scrub, @record))) unless policy.scrub.empty?
|
|
90
|
+
relation.count
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def detach(name, blobs)
|
|
94
|
+
attached = @record.public_send(name)
|
|
95
|
+
attachments = attached.respond_to?(:attachments) ? attached.attachments.to_a : [attached.attachment].compact
|
|
96
|
+
blobs.concat(attachments.map(&:blob))
|
|
97
|
+
attached.detach if attachments.any?
|
|
98
|
+
attachments.size
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The erasure has committed by now, so a file that cannot be purged must
|
|
102
|
+
# not make it look as if it failed. Returns the blob ids that could not be
|
|
103
|
+
# purged, for the notification; their blobs are already detached.
|
|
104
|
+
def purge(blobs)
|
|
105
|
+
blobs.filter_map do |blob|
|
|
106
|
+
(@configuration.purge == :now) ? blob.purge : blob.purge_later
|
|
107
|
+
nil
|
|
108
|
+
rescue => error
|
|
109
|
+
ActiveRecord::Base.logger&.warn("lethe: could not purge blob #{blob.id}: #{error.class}: #{error.message}")
|
|
110
|
+
blob.id
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def evaluate(set, row)
|
|
115
|
+
set.to_h do |column, value|
|
|
116
|
+
next [column, value] unless value.is_a?(Proc)
|
|
117
|
+
|
|
118
|
+
[column, value.arity.zero? ? value.call : value.call(row)]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def rows(step)
|
|
123
|
+
Rows.for(step.location.table)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Lethe
|
|
7
|
+
# Writes what happens to each kind of data when a subject leaves, as a
|
|
8
|
+
# Markdown table for people and as JSON for tools. Only covered references
|
|
9
|
+
# appear; ignored ones do not point at the subject.
|
|
10
|
+
class Exporter
|
|
11
|
+
def initialize(report, definitions:, models:, root:, definition_path:, locale: I18n.default_locale)
|
|
12
|
+
@report = report
|
|
13
|
+
@definitions = definitions
|
|
14
|
+
@models = models
|
|
15
|
+
@root = Pathname(root)
|
|
16
|
+
@definition_path = definition_path
|
|
17
|
+
@locale = locale
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def markdown
|
|
21
|
+
sections = subjects.map { |subject| markdown_section(subject) }
|
|
22
|
+
"<!-- #{t("lethe.export.generated", path: relative(@definition_path))} -->\n\n#{sections.join("\n")}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def json
|
|
26
|
+
::JSON.pretty_generate(
|
|
27
|
+
"version" => 1,
|
|
28
|
+
"subjects" => subjects.map { |subject| json_subject(subject) }
|
|
29
|
+
) + "\n"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def subjects
|
|
35
|
+
@definitions.map(&:subject_name).select { |name| @report.subjects.include?(name) }.sort
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rows(subject)
|
|
39
|
+
@report.results
|
|
40
|
+
.select { |result| result.subject == subject && result.status == :covered && result.reference }
|
|
41
|
+
.sort_by(&:key)
|
|
42
|
+
.flat_map { |result| result.policies.reject(&:ignore?).map { |policy| [result.reference, policy] } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def markdown_section(subject)
|
|
46
|
+
lines = [
|
|
47
|
+
"# #{t("lethe.export.title", subject: subject_label(subject))}",
|
|
48
|
+
"",
|
|
49
|
+
"| #{t("lethe.export.data")} | #{t("lethe.export.action")} | #{t("lethe.export.retention")} | #{t("lethe.export.reason")} |",
|
|
50
|
+
"| --- | --- | --- | --- |"
|
|
51
|
+
]
|
|
52
|
+
definition = definition_for(subject)
|
|
53
|
+
if definition.subject_policy
|
|
54
|
+
lines << row([t("lethe.export.subject_row", subject: subject_label(subject)), subject_action(definition.subject_policy), "", ""])
|
|
55
|
+
end
|
|
56
|
+
rows(subject).each do |reference, policy|
|
|
57
|
+
lines << row([label(subject, reference), action(policy), retention(policy.retention), policy.reason.to_s])
|
|
58
|
+
end
|
|
59
|
+
lines.join("\n") + "\n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def json_subject(subject)
|
|
63
|
+
definition = definition_for(subject)
|
|
64
|
+
subject_policy = definition.subject_policy
|
|
65
|
+
{
|
|
66
|
+
"subject" => subject,
|
|
67
|
+
"subject_row" => subject_policy && {
|
|
68
|
+
"destroy" => subject_policy.destroy ? true : false,
|
|
69
|
+
"soft_delete" => subject_policy.soft_delete&.to_s,
|
|
70
|
+
"scrub" => subject_policy.scrub.keys.map(&:to_s)
|
|
71
|
+
}.compact,
|
|
72
|
+
"data" => rows(subject).map do |reference, policy|
|
|
73
|
+
{
|
|
74
|
+
"key" => reference.key,
|
|
75
|
+
"table" => reference.table,
|
|
76
|
+
"column" => reference.column,
|
|
77
|
+
"attachment" => reference.attachment,
|
|
78
|
+
"label" => label(subject, reference),
|
|
79
|
+
"action" => policy.action.to_s,
|
|
80
|
+
"where" => policy.where&.transform_keys(&:to_s),
|
|
81
|
+
"set" => policy.set&.keys&.map(&:to_s),
|
|
82
|
+
"retention_seconds" => policy.retention&.to_i,
|
|
83
|
+
"retention" => policy.retention && retention(policy.retention),
|
|
84
|
+
"reason" => policy.reason
|
|
85
|
+
}.compact
|
|
86
|
+
end
|
|
87
|
+
}.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def definition_for(subject)
|
|
91
|
+
@definitions.find { |definition| definition.subject_name == subject }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def subject_model(subject)
|
|
95
|
+
subject.safe_constantize
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def subject_label(subject)
|
|
99
|
+
model = subject_model(subject)
|
|
100
|
+
model ? model.model_name.human(locale: @locale) : subject
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def label(subject, reference)
|
|
104
|
+
if reference.attachment
|
|
105
|
+
model = subject_model(subject)
|
|
106
|
+
name = model ? model.human_attribute_name(reference.attachment, locale: @locale) : reference.attachment
|
|
107
|
+
return t("lethe.export.attachment", name: name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
model = @models.find { |candidate| candidate.table_name == reference.table && candidate.base_class == candidate }
|
|
111
|
+
base = model ? model.model_name.human(locale: @locale) : reference.table
|
|
112
|
+
conventional = subject_model(subject)&.model_name&.singular&.then { |singular| "#{singular}_id" }
|
|
113
|
+
(reference.column == conventional) ? base : t("lethe.export.column_suffix", label: base, column: reference.column)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def action(policy)
|
|
117
|
+
text =
|
|
118
|
+
case policy.action
|
|
119
|
+
when :scrub then t("lethe.export.scrub", columns: list(policy.set.keys))
|
|
120
|
+
else t("lethe.export.#{policy.action}")
|
|
121
|
+
end
|
|
122
|
+
return text unless policy.where
|
|
123
|
+
|
|
124
|
+
conditions = policy.where.map { |column, value| "#{column} = #{Array(value).join(" / ")}" }
|
|
125
|
+
t("lethe.export.where", action: text, conditions: conditions.join(t("lethe.export.list_separator")))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def subject_action(policy)
|
|
129
|
+
return t("lethe.export.destroy") if policy.destroy
|
|
130
|
+
|
|
131
|
+
scrubbed = policy.scrub.keys
|
|
132
|
+
if scrubbed.empty?
|
|
133
|
+
t("lethe.export.soft_delete", column: policy.soft_delete)
|
|
134
|
+
else
|
|
135
|
+
t("lethe.export.soft_delete_scrub", column: policy.soft_delete, columns: list(scrubbed))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def retention(duration)
|
|
140
|
+
return "" unless duration
|
|
141
|
+
|
|
142
|
+
duration.parts.map { |unit, count| t("lethe.export.parts.#{unit}", count: count) }.join(" ")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def list(names)
|
|
146
|
+
names.join(t("lethe.export.list_separator"))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def row(cells)
|
|
150
|
+
"| #{cells.map { |cell| cell.to_s.gsub("|", "\\|").gsub(/\s*\n\s*/, " ") }.join(" | ")} |"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def relative(path)
|
|
154
|
+
Pathname(path).relative_path_from(@root).to_s
|
|
155
|
+
rescue ArgumentError
|
|
156
|
+
path.to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def t(key, **values)
|
|
160
|
+
I18n.t(key, **values, locale: @locale)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|