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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1b7cb2467505c4efcd2901a83a887f829641721fc6efbd812810df4836f8e38c
|
|
4
|
+
data.tar.gz: 3d93eb10b84a500315d85a687528321e098a09eada43d264db7865cba14d44e7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c7a9e090623334708e50d611ab37d68d8c57bfe2a5372bd6bdca222f7b34b3cf3db050e6c381f731e650f2c06851cb43f9093a91cc161792de50795ce0ea419d
|
|
7
|
+
data.tar.gz: b5351ebc5369d48142aa7e0e8f343a70530e90ad4ba13481a3688881c61b9e99246bf6b083975145c96d264e4987acd666107b9784c5ddfe532dbc54e692a0d1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file. The format
|
|
4
|
+
is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
|
5
|
+
project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## 0.1.0 - 2026-10-27
|
|
8
|
+
|
|
9
|
+
The first release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Lethe.define` to declare, per subject such as `User`, what happens to the
|
|
14
|
+
data that points at it: `destroy`, `scrub`, `keep`, `ignore`,
|
|
15
|
+
`destroy_attachments`, and `subject` for its own row.
|
|
16
|
+
- Finding what points at a subject from foreign keys, associations, join
|
|
17
|
+
tables, polymorphic associations, Active Storage attachments and, as a
|
|
18
|
+
guess, column names.
|
|
19
|
+
- `bin/rails lethe:check`, with text, GitHub Actions and JSON output, and
|
|
20
|
+
`STRICT=1` to fail on guesses.
|
|
21
|
+
- `bin/rails lethe:export`, which writes the policies as Markdown and JSON,
|
|
22
|
+
and `CHECK=1` to fail when they are out of date.
|
|
23
|
+
- `Lethe.check!` for tests.
|
|
24
|
+
- `Lethe.erase!` to carry the policies out in one transaction, refusing
|
|
25
|
+
while the check has problems; `destroy ..., cascade: true`, Procs in
|
|
26
|
+
`set:`, and `before_erase` / `after_erase` hooks.
|
|
27
|
+
- `Lethe.plan` to see the steps without changing anything, and
|
|
28
|
+
`Lethe.leftovers` to prove in tests that nothing else was left behind.
|
|
29
|
+
- `bin/rails lethe:sweep` to delete kept rows once `keep ..., for:` or
|
|
30
|
+
`scrub ..., for:` has passed, through the model with `cascade: true`, and
|
|
31
|
+
`bin/rails generate lethe:install` for the table it needs.
|
|
32
|
+
- The check also fails on a polymorphic "table.column" without its type,
|
|
33
|
+
`cascade: true` without a model, and rows kept with a foreign key to a
|
|
34
|
+
subject row that is deleted.
|
|
35
|
+
- Enum names in `where:` and `set:`, read through the application's model;
|
|
36
|
+
a name the enum does not have fails the check.
|
|
37
|
+
- A definition for an STI subclass is used for its records, and its kept
|
|
38
|
+
rows are swept by its own retention.
|
|
39
|
+
- Inside a Rails app, `config/lethe.rb` and every model are loaded on first
|
|
40
|
+
use, so `Lethe.erase!` works from a controller and sees what CI sees.
|
|
41
|
+
- Messages and exports in English and Japanese.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KAIBYAKU K.K
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/logo/lethe-logo-vertical-dark.svg">
|
|
4
|
+
<img src="docs/logo/lethe-logo-vertical.svg" alt="lethe: a time to keep, and a time to cast away." width="300">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
# lethe [](https://github.com/kaibyaku-jp/lethe/actions/workflows/ci.yml)
|
|
9
|
+
|
|
10
|
+
A time to keep, and a time to cast away.
|
|
11
|
+
|
|
12
|
+
When someone deletes their account, every table that points at them needs a
|
|
13
|
+
decision: delete the rows, overwrite what is personal, or keep them for a
|
|
14
|
+
reason. lethe finds those tables in your Rails app, checks that each one has
|
|
15
|
+
a declared policy, fails CI when a new table has none, and carries the
|
|
16
|
+
policies out when the account goes.
|
|
17
|
+
|
|
18
|
+
> **Status:** early, before its first release. Until 1.0, minor versions may
|
|
19
|
+
> change how things work; [CHANGELOG.md](CHANGELOG.md) says what changed.
|
|
20
|
+
|
|
21
|
+
Lethe is the river of forgetting in Greek myth: in Plato's *Republic*, souls
|
|
22
|
+
drink from it before they are born again.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
### Finds What Points at a User
|
|
28
|
+
|
|
29
|
+
Foreign keys, `has_many` and `has_one`, `belongs_to` in other models,
|
|
30
|
+
join tables, polymorphic associations, Active Storage attachments and,
|
|
31
|
+
as a guess, columns named like `user_id` or `*_user_id`.
|
|
32
|
+
|
|
33
|
+
### Fails on What Nobody Decided
|
|
34
|
+
|
|
35
|
+
A table added last week with a `user_id` and no policy fails the check. So
|
|
36
|
+
does a policy for a table or column that no longer exists.
|
|
37
|
+
|
|
38
|
+
### Writes It Down for People
|
|
39
|
+
|
|
40
|
+
`lethe:export` writes what happens to each kind of data as a Markdown table,
|
|
41
|
+
in English or Japanese, for your privacy policy, app store review or support
|
|
42
|
+
team. CI can check that it is up to date, so every change shows up in review.
|
|
43
|
+
|
|
44
|
+
### Erases as Declared
|
|
45
|
+
|
|
46
|
+
`Lethe.erase!(user)` deletes, overwrites and keeps exactly what the
|
|
47
|
+
policies say, in one transaction, and refuses to run while the check has
|
|
48
|
+
problems. `Lethe.leftovers(user)` proves in a test that nothing else was
|
|
49
|
+
left behind.
|
|
50
|
+
|
|
51
|
+
### Reads the Schema to Check
|
|
52
|
+
|
|
53
|
+
`lethe:check` and `lethe:export` read the schema and your models'
|
|
54
|
+
associations only, never a row. Only `erase!`, `plan`, `leftovers` and
|
|
55
|
+
`lethe:sweep` touch data, and only when your code calls them.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Supported Versions
|
|
59
|
+
|
|
60
|
+
* Ruby 3.4, 4.0
|
|
61
|
+
|
|
62
|
+
* Rails 7.2, 8.0, 8.1
|
|
63
|
+
|
|
64
|
+
* SQLite, MySQL and PostgreSQL
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
Put this in your Gemfile:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
group :development, :test do
|
|
73
|
+
gem "lethe"
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
To erase accounts with it in production, put it outside the group instead.
|
|
78
|
+
It adds the rake tasks and the methods below, and nothing else: no
|
|
79
|
+
callbacks, middleware or patches to Rails.
|
|
80
|
+
|
|
81
|
+
To keep rows for a while (`keep ..., for:`), lethe records each erasure in a
|
|
82
|
+
table of its own. Create it, with a `config/lethe.rb` to start from:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
% bin/rails generate lethe:install
|
|
86
|
+
% bin/rails db:migrate
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Declaring Policies
|
|
91
|
+
|
|
92
|
+
Write the policies in `config/lethe.rb`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Lethe.define User do
|
|
96
|
+
# The users row itself: destroy: true, or soft_delete: with a column.
|
|
97
|
+
subject soft_delete: :deleted_at, scrub: {nickname: "Deleted user", email: nil}
|
|
98
|
+
|
|
99
|
+
# Associations of User, by name.
|
|
100
|
+
destroy :ratings, :favorites, :notifications
|
|
101
|
+
scrub :posts, set: {body: ""}, reason: "Quoted posts keep their row"
|
|
102
|
+
scrub :questions, where: {status: "answered"}, set: {body: ""}
|
|
103
|
+
destroy :questions, where: {status: %w[pending declined]}
|
|
104
|
+
scrub :orders, set: {phone: nil, address: nil}, for: 7.years, reason: "Bookkeeping"
|
|
105
|
+
|
|
106
|
+
# Anything without an association, as "table.column".
|
|
107
|
+
destroy "follows.follower_id", "follows.following_id"
|
|
108
|
+
keep "muted_users.muted_user_id", reason: "Other people's settings"
|
|
109
|
+
|
|
110
|
+
destroy_attachments :avatar
|
|
111
|
+
|
|
112
|
+
# A guess that does not point at User.
|
|
113
|
+
ignore "audit_logs.admin_user_id", reason: "Points at AdminUser"
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| policy | means |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `destroy` | The rows are deleted. |
|
|
120
|
+
| `scrub` | The rows stay, with the columns in `set:` overwritten; `for:` says how long. |
|
|
121
|
+
| `keep` | The rows stay as they are. Needs a `reason:`; `for:` says how long. |
|
|
122
|
+
| `ignore` | The column does not point at the subject. Needs a `reason:`. |
|
|
123
|
+
| `destroy_attachments` | The subject's Active Storage attachments are deleted. |
|
|
124
|
+
| `subject` | How the subject's own row goes. |
|
|
125
|
+
|
|
126
|
+
`where:` limits a policy to some rows, so the same table can have one policy
|
|
127
|
+
per condition. lethe does not check that the conditions cover every row.
|
|
128
|
+
Enum names work in `where:` and `set:` as they do with your model, and a
|
|
129
|
+
name the enum does not have fails the check.
|
|
130
|
+
|
|
131
|
+
An STI subclass, such as `Admin < User`, can have a definition of its own.
|
|
132
|
+
It is used instead of the base class's for records of that class, so it
|
|
133
|
+
must declare everything.
|
|
134
|
+
|
|
135
|
+
Declare more subjects, such as `Organization`, the same way. Declarations
|
|
136
|
+
for the same subject add up.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
## Checking
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
% bin/rails lethe:check
|
|
143
|
+
User: 41 references (association 36, attachment 3, column name 4, foreign key 31)
|
|
144
|
+
|
|
145
|
+
✓ 37 have a policy
|
|
146
|
+
✗ chat_messages.user_id no policy (found by foreign key, association, column name)
|
|
147
|
+
? question_blocks.blocked_user_id guessed from the column name; declare a policy, or `ignore` it with a reason if it does not point at User
|
|
148
|
+
! config/lethe.rb:14 :badges User has no association :badges
|
|
149
|
+
|
|
150
|
+
2 problems, 1 to confirm.
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
| mark | means | fails the check |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `✓` | Has a policy. | no |
|
|
156
|
+
| `✗` | Certainly points at the subject, with no policy. | yes |
|
|
157
|
+
| `!` | A declaration is wrong or names something that is gone. | yes |
|
|
158
|
+
| `?` | Might point at the subject, from its name alone, with no policy. | with `STRICT=1` |
|
|
159
|
+
|
|
160
|
+
It exits with 0 when there are no problems, 1 when there are, and 2 when
|
|
161
|
+
`config/lethe.rb` cannot be loaded.
|
|
162
|
+
|
|
163
|
+
| option | meaning |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `FORMAT=github` | GitHub Actions annotations; `FORMAT=json` for tools |
|
|
166
|
+
| `STRICT=1` | Fail on `?` too |
|
|
167
|
+
| `LOCALE=ja` | Messages in Japanese |
|
|
168
|
+
|
|
169
|
+
In a test instead:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
it "has a policy for every table that points at a user" do
|
|
173
|
+
Lethe.check!
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Inside a Rails app, lethe loads `config/lethe.rb` the first time it is
|
|
178
|
+
needed, and loads every model once where `eager_load` is off, so that
|
|
179
|
+
development and test see the same associations as CI.
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## Exporting
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
% bin/rails lethe:export LOCALE=ja
|
|
186
|
+
lethe: wrote docs/lethe.md
|
|
187
|
+
lethe: wrote docs/lethe.json
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```markdown
|
|
191
|
+
| Data | When they leave | Kept for | Reason |
|
|
192
|
+
| --- | --- | --- | --- |
|
|
193
|
+
| The User itself | Kept as deleted (deleted_at), with nickname, email overwritten | | |
|
|
194
|
+
| Avatar (attachment) | Deleted | | |
|
|
195
|
+
| Order | Kept, with phone, address overwritten | 7 years | Bookkeeping |
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Names come from your app's translations, such as `activerecord.models.order`.
|
|
199
|
+
Nothing is written while the check has problems. `CHECK=1` compares instead
|
|
200
|
+
of writing and fails when the files are out of date; run it in CI:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
bin/rails lethe:check lethe:export CHECK=1 FORMAT=github
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
## Erasing
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class AccountsController < ApplicationController
|
|
211
|
+
def destroy
|
|
212
|
+
Lethe.erase!(current_user)
|
|
213
|
+
reset_session
|
|
214
|
+
head :no_content
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`erase!` checks the subject's definition first and raises `Lethe::Unclean`
|
|
220
|
+
without touching anything when it has problems. Then, in one transaction,
|
|
221
|
+
it runs the `before_erase` hooks, deletes rows (those with foreign keys to
|
|
222
|
+
other deleted tables first), overwrites columns, marks or deletes the
|
|
223
|
+
subject's own row, runs the `after_erase` hooks and records the erasure. If
|
|
224
|
+
anything fails, all of it is rolled back. Attachments are detached in the
|
|
225
|
+
transaction and their files purged after it commits.
|
|
226
|
+
|
|
227
|
+
| erasing | how |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `destroy` | `delete_all` on the table, with no callbacks; `cascade: true` uses the model's `destroy_all`, so `dependent:` associations and attachments go too |
|
|
230
|
+
| `scrub` | `update_all`; a Proc in `set:` is called for each row, given the row if it takes an argument |
|
|
231
|
+
| `keep` | Nothing now; `lethe:sweep` deletes the rows once `for:` has passed (as does `scrub ..., for:`) |
|
|
232
|
+
| polymorphic | The type always becomes part of the condition, so erasing User 5 never touches Post 5's rows |
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Lethe.define User do
|
|
236
|
+
subject soft_delete: :deleted_at,
|
|
237
|
+
scrub: {screen_name: ->(user) { "deleted_#{user.id}_#{SecureRandom.hex(8)}" }}
|
|
238
|
+
destroy :episodes, cascade: true
|
|
239
|
+
destroy "ratings.target_id", where: {target_type: "User"}
|
|
240
|
+
|
|
241
|
+
before_erase { |user| Follow.decrement_counters_for(user) }
|
|
242
|
+
after_erase { |user| DeleteFirebaseAccountJob.perform_later(user.firebase_uid) }
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Hooks run inside the transaction. Anything that cannot be rolled back,
|
|
247
|
+
such as a call to another service, belongs in a job enqueued from
|
|
248
|
+
`after_erase`.
|
|
249
|
+
|
|
250
|
+
`Lethe.plan(user)` lists the steps and how many rows each touches, without
|
|
251
|
+
changing anything. `erase!` returns the rows per table and policy, and
|
|
252
|
+
sends an `erase.lethe` notification with the subject's class, id and those
|
|
253
|
+
counts, never anything personal. Files that could not be purged after the
|
|
254
|
+
commit are logged and listed as `purge_failures` (blob ids) rather than
|
|
255
|
+
raised, since the erasure itself has already committed.
|
|
256
|
+
|
|
257
|
+
Running `erase!` again is safe: what is gone stays gone, and a soft
|
|
258
|
+
delete keeps the time of the first erasure.
|
|
259
|
+
|
|
260
|
+
### Proving It in Tests
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
it "leaves nothing of a user behind but what we keep" do
|
|
264
|
+
user = create(:user, :with_everything)
|
|
265
|
+
Lethe.erase!(user)
|
|
266
|
+
|
|
267
|
+
expect(Lethe.leftovers(user)).to be_empty
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`leftovers` lists rows that should be gone, columns that should have been
|
|
272
|
+
overwritten (values from a Proc are not compared), attachments, and the
|
|
273
|
+
subject's row.
|
|
274
|
+
|
|
275
|
+
### Sweeping
|
|
276
|
+
|
|
277
|
+
Run this daily, from cron or a recurring job:
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
% bin/rails lethe:sweep
|
|
281
|
+
lethe: swept 3 from User :inquiries
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
It deletes the rows of each `keep ..., for:` and `scrub ..., for:` whose
|
|
285
|
+
time has passed since their subject was first erased, one subject at a time
|
|
286
|
+
in a transaction. If other rows still point at them through a foreign key,
|
|
287
|
+
it stops there; add `cascade: true` to delete through the model and its
|
|
288
|
+
`dependent:` associations instead.
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
## Configuring lethe
|
|
292
|
+
|
|
293
|
+
In `config/initializers/lethe.rb`, or anywhere loaded before the tasks run:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
Lethe.configure do |config|
|
|
297
|
+
config.definition_path = "config/lethe.rb" # the default
|
|
298
|
+
config.export_path = "docs/lethe" # .md and .json are added
|
|
299
|
+
config.skip_tables = %w[audit_logs] # besides Rails' own
|
|
300
|
+
config.guess_columns[User] = %w[follower_id following_id questioner_id]
|
|
301
|
+
config.purge = :later # or :now, for erased attachments' files
|
|
302
|
+
end
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
## Questions, Feedback
|
|
307
|
+
|
|
308
|
+
Open an issue on GitHub. Report security problems privately as described in
|
|
309
|
+
[SECURITY.md](SECURITY.md).
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
## Contributing to lethe
|
|
313
|
+
|
|
314
|
+
Fork, fix, then send a pull request. [CONTRIBUTING.md](CONTRIBUTING.md) has the
|
|
315
|
+
details.
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
% nix develop # or use Ruby 3.4+ without Nix
|
|
319
|
+
% bundle install
|
|
320
|
+
% bundle exec rake
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
## Copyright
|
|
325
|
+
|
|
326
|
+
Copyright (c) 2026- KAIBYAKU K.K. See [LICENSE](LICENSE) for further details.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Lethe
|
|
7
|
+
module Generators
|
|
8
|
+
# bin/rails generate lethe:install: the lethe_erasures table and a
|
|
9
|
+
# config/lethe.rb to start from.
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
include ActiveRecord::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template "create_lethe_erasures.rb.tt", File.join(db_migrate_path, "create_lethe_erasures.rb")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_definitions
|
|
20
|
+
template "lethe.rb.tt", "config/lethe.rb", skip: true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# One row each time lethe erases a subject or sweeps its kept rows: the
|
|
2
|
+
# subject's class and id, and the number of rows per table. Nothing personal.
|
|
3
|
+
class CreateLetheErasures < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :lethe_erasures do |t|
|
|
6
|
+
t.string :subject_type, null: false
|
|
7
|
+
t.string :subject_id, null: false
|
|
8
|
+
t.string :kind, null: false
|
|
9
|
+
t.text :counts, null: false
|
|
10
|
+
t.datetime :created_at, null: false
|
|
11
|
+
end
|
|
12
|
+
add_index :lethe_erasures, [:subject_type, :subject_id]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Compares one definition with what Discovery finds, and returns a Result
|
|
5
|
+
# for every reference and for every declaration that is wrong.
|
|
6
|
+
class Checker
|
|
7
|
+
def initialize(definition, models:, connection:, configuration:)
|
|
8
|
+
@definition = definition
|
|
9
|
+
@models = models
|
|
10
|
+
@connection = connection
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
@subject = resolve_subject
|
|
16
|
+
return [invalid(nil, "subject_not_a_model", location: first_location, subject: subject_name)] unless @subject
|
|
17
|
+
|
|
18
|
+
discovery = Discovery.new(@subject, models: models, connection: @connection, configuration: @configuration)
|
|
19
|
+
@tables = discovery.tables
|
|
20
|
+
by_key, problems = declared_keys
|
|
21
|
+
|
|
22
|
+
results = problems + contradictions(by_key)
|
|
23
|
+
results.concat(discovery.call.map { |reference| result_for(reference, by_key.delete(reference.key) || []) })
|
|
24
|
+
# Declared and valid, but not something Discovery found: trust the declaration.
|
|
25
|
+
results.concat(by_key.map { |key, policies| result_for(Reference.column(*key.split(".", 2), :declared), policies) })
|
|
26
|
+
results << subject_result
|
|
27
|
+
results.concat(blocked_by_foreign_keys(results))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The schema questions Rules asks.
|
|
31
|
+
def column_names(table)
|
|
32
|
+
@column_names ||= {}
|
|
33
|
+
@column_names[table] ||= @connection.columns(table).map(&:name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_for_table(table)
|
|
37
|
+
models.find do |model|
|
|
38
|
+
!model.abstract_class? && model.table_name == table && model.base_class == model
|
|
39
|
+
rescue ActiveRecord::ActiveRecordError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def primary_key(table)
|
|
45
|
+
@connection.primary_key(table)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def subject_name
|
|
51
|
+
@definition.subject_name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def models
|
|
55
|
+
@models || Lethe.application_models
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_subject
|
|
59
|
+
model = subject_name.safe_constantize
|
|
60
|
+
(model.is_a?(Class) && model < ActiveRecord::Base && !model.abstract_class?) ? model : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def first_location
|
|
64
|
+
@definition.policies.first&.location || @definition.subject_policy&.location
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns [key, nil], or [nil, an :invalid Result].
|
|
68
|
+
def resolve(policy)
|
|
69
|
+
target = policy.target
|
|
70
|
+
case target.kind
|
|
71
|
+
when :association then resolve_association(policy)
|
|
72
|
+
when :attachment then resolve_attachment(policy)
|
|
73
|
+
else resolve_column(policy, target.table, target.column)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_association(policy)
|
|
78
|
+
name = policy.target.name
|
|
79
|
+
reflection = @subject.reflect_on_association(name)
|
|
80
|
+
return [nil, invalid(policy, "no_association", name: name)] unless reflection
|
|
81
|
+
return [nil, invalid(policy, "through_association", name: name)] if reflection.through_reflection?
|
|
82
|
+
|
|
83
|
+
case reflection.macro
|
|
84
|
+
when :has_many, :has_one
|
|
85
|
+
table = begin
|
|
86
|
+
reflection.klass.table_name
|
|
87
|
+
rescue NameError, ArgumentError
|
|
88
|
+
return [nil, invalid(policy, "association_class_missing", name: name)]
|
|
89
|
+
end
|
|
90
|
+
resolve_column(policy, table, reflection.foreign_key.to_s)
|
|
91
|
+
when :has_and_belongs_to_many
|
|
92
|
+
resolve_column(policy, reflection.join_table.to_s, reflection.foreign_key.to_s)
|
|
93
|
+
else
|
|
94
|
+
[nil, invalid(policy, "belongs_to_association", name: name)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_attachment(policy)
|
|
99
|
+
name = policy.target.name
|
|
100
|
+
found = @subject.respond_to?(:reflect_on_attachment) && @subject.reflect_on_attachment(name)
|
|
101
|
+
found ? ["attachment:#{name}", nil] : [nil, invalid(policy, "no_attachment", name: name)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def resolve_column(policy, table, column)
|
|
105
|
+
return [nil, invalid(policy, "no_table", table: table)] unless @tables.include?(table)
|
|
106
|
+
return [nil, invalid(policy, "no_column", table: table, column: column)] unless column_names(table).include?(column)
|
|
107
|
+
|
|
108
|
+
["#{table}.#{column}", nil]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# { "table.column" => [valid policies] }, and the problems with the rest.
|
|
112
|
+
def declared_keys
|
|
113
|
+
rules = Rules.new(@subject, self) { |policy, message_key, **values| invalid(policy, message_key, **values) }
|
|
114
|
+
problems = []
|
|
115
|
+
by_key = Hash.new { |hash, key| hash[key] = [] }
|
|
116
|
+
@definition.policies.each do |policy|
|
|
117
|
+
key, error = resolve(policy)
|
|
118
|
+
next problems << error if error
|
|
119
|
+
|
|
120
|
+
found = rules.call(policy, key)
|
|
121
|
+
found.empty? ? by_key[key] << policy : problems.concat(found)
|
|
122
|
+
end
|
|
123
|
+
[by_key, problems]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# When the subject's row is deleted, rows that stay and point at it
|
|
127
|
+
# through a foreign key would make the delete fail.
|
|
128
|
+
def blocked_by_foreign_keys(results)
|
|
129
|
+
return [] unless @definition.subject_policy&.destroy
|
|
130
|
+
|
|
131
|
+
results.filter_map do |result|
|
|
132
|
+
next unless result.status == :covered && result.reference&.vias&.include?(:foreign_key)
|
|
133
|
+
|
|
134
|
+
staying = result.policies.find { |policy| %i[keep scrub].include?(policy.action) }
|
|
135
|
+
invalid(staying, "kept_rows_block_destroy", key: result.key) if staying
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Two policies for the same rows that say different things. The rows stay
|
|
140
|
+
# covered; the second declaration is reported.
|
|
141
|
+
def contradictions(by_key)
|
|
142
|
+
by_key.flat_map do |key, policies|
|
|
143
|
+
unconditional = policies.select { |policy| policy.where.nil? }
|
|
144
|
+
next [] if unconditional.map(&:action).uniq.size < 2
|
|
145
|
+
|
|
146
|
+
unconditional.drop(1).map do |policy|
|
|
147
|
+
invalid(policy, "contradiction", key: key, other: unconditional.first.location)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def result_for(reference, policies)
|
|
153
|
+
status =
|
|
154
|
+
if policies.empty?
|
|
155
|
+
reference.certain? ? :missing : :guess
|
|
156
|
+
elsif policies.all?(&:ignore?)
|
|
157
|
+
:ignored
|
|
158
|
+
else
|
|
159
|
+
:covered
|
|
160
|
+
end
|
|
161
|
+
Result.new(
|
|
162
|
+
status: status, subject: subject_name, key: reference.key, reference: reference,
|
|
163
|
+
policies: policies, message: nil, location: policies.first&.location
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def subject_result
|
|
168
|
+
policy = @definition.subject_policy
|
|
169
|
+
message = policy ? subject_problem(policy) : nil
|
|
170
|
+
Result.new(
|
|
171
|
+
status: if policy.nil?
|
|
172
|
+
:missing
|
|
173
|
+
else
|
|
174
|
+
(message ? :invalid : :covered)
|
|
175
|
+
end, subject: subject_name, key: "subject",
|
|
176
|
+
reference: nil, policies: [], message: message, location: policy&.location
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# What is wrong with the subject declaration, as [message key, values], or nil.
|
|
181
|
+
def subject_problem(policy)
|
|
182
|
+
table = @subject.table_name
|
|
183
|
+
columns = column_names(table)
|
|
184
|
+
return ["subject_both", {}] if policy.destroy && policy.soft_delete
|
|
185
|
+
return ["subject_neither", {}] if !policy.destroy && policy.soft_delete.nil?
|
|
186
|
+
if policy.soft_delete && !columns.include?(policy.soft_delete.to_s)
|
|
187
|
+
return ["unknown_column", {option: "soft_delete", table: table, column: policy.soft_delete.to_s}]
|
|
188
|
+
end
|
|
189
|
+
return ["subject_scrub_not_hash", {}] unless policy.scrub.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
unknown = policy.scrub.keys.map(&:to_s) - columns
|
|
192
|
+
return ["unknown_column", {option: "scrub", table: table, column: unknown.first}] if unknown.any?
|
|
193
|
+
|
|
194
|
+
column, value = Enums.unknown(@subject, policy.scrub).first
|
|
195
|
+
["unknown_enum_value", {option: "scrub", table: table, column: column, value: value.inspect}] if column
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def invalid(policy, message_key, location: policy&.location, subject: subject_name, **values)
|
|
199
|
+
Result.new(
|
|
200
|
+
status: :invalid, subject: subject, key: policy ? policy.target.to_s : subject, reference: nil,
|
|
201
|
+
policies: policy ? [policy] : [], message: [message_key, values], location: location
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|