active_sanction 1.0.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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,331 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/version"
8
+ require "active_sanction/deprecation"
9
+ require "active_sanction/configuration"
10
+ require "active_sanction/name"
11
+ require "active_sanction/address"
12
+ require "active_sanction/identifier"
13
+ require "active_sanction/partial_date"
14
+ require "active_sanction/entity"
15
+ require "active_sanction/snapshot"
16
+ require "active_sanction/snapshot/bundle"
17
+ require "active_sanction/http_client"
18
+ require "active_sanction/validators"
19
+ require "active_sanction/validator_store"
20
+ require "active_sanction/fetcher"
21
+ require "active_sanction/payload_cache"
22
+ require "active_sanction/parsers"
23
+ require "active_sanction/sources"
24
+ require "active_sanction/storage"
25
+ require "active_sanction/normalizer"
26
+ require "active_sanction/similarity"
27
+ require "active_sanction/phonetics"
28
+ require "active_sanction/index"
29
+ require "active_sanction/country"
30
+ require "active_sanction/scorer"
31
+ require "active_sanction/query"
32
+ require "active_sanction/match_result"
33
+ require "active_sanction/matcher"
34
+ require "active_sanction/subject"
35
+ require "active_sanction/sync"
36
+ require "active_sanction/diff"
37
+ require "active_sanction/rescreen"
38
+ require "active_sanction/doctor"
39
+ require "active_sanction/client"
40
+ require "active_sanction/sources/ofac_sdn"
41
+ require "active_sanction/sources/ofac_consolidated"
42
+ require "active_sanction/sources/un_consolidated"
43
+ require "active_sanction/sources/canada_sema"
44
+ require "active_sanction/sources/eu_fsf"
45
+ require "active_sanction/sources/uk_sanctions_list"
46
+ require "active_sanction/sources/australia_dfat"
47
+
48
+ module ActiveSanction
49
+ # Guards the default client. Building one is cheap, but replacing it drops
50
+ # a matcher that indexed every stored list, and two threads racing to
51
+ # `configure` at boot should not each get a different one. A constant rather
52
+ # than a memoized ivar, because a lazily created lock is not one.
53
+ #
54
+ # @api private
55
+ CLIENT_LOCK = T.let(Mutex.new, Mutex)
56
+ private_constant :CLIENT_LOCK
57
+
58
+ # Where .with_configuration keeps the settings in force. Fiber-local, which
59
+ # is what `Thread#[]` means: two threads screening through two clients read
60
+ # two configurations, and neither can see the other's.
61
+ #
62
+ # @api private
63
+ CONFIGURATION_KEY = :active_sanction_configuration
64
+ private_constant :CONFIGURATION_KEY
65
+
66
+ class << self
67
+ extend T::Sig
68
+
69
+ # The client the module-level calls answer through, built from the
70
+ # defaults on first use so that nothing has to remember to initialize it.
71
+ #
72
+ # ActiveSanction.client.screen("Bosco Ntaganda") # same as ActiveSanction.screen(...)
73
+ #
74
+ # Everything below is sugar over this object. A process that needs two
75
+ # configurations at once -- a pinned list version for an audit re-run
76
+ # beside the current one for live traffic, one tenant's sources beside
77
+ # another's -- builds its own with `Client.new` and holds them itself;
78
+ # this one is what a script and the README quickstart use. See Client.
79
+ sig { returns(Client) }
80
+ def client
81
+ CLIENT_LOCK.synchronize { @client ||= T.let(Client.new, T.nilable(Client)) }
82
+ end
83
+
84
+ # The settings in force: whatever .with_configuration has installed on
85
+ # this fiber, or the default client's.
86
+ #
87
+ # Frozen, because it belongs to a built client. `configure` is how it is
88
+ # changed, and it changes it by building a new client rather than by
89
+ # editing this one -- a settings object that could move underneath a
90
+ # running index is the thing Client exists to remove.
91
+ sig { returns(Configuration) }
92
+ def config = Thread.current[CONFIGURATION_KEY] || client.configuration
93
+
94
+ # The one entry point an application is expected to call at boot:
95
+ #
96
+ # ActiveSanction.configure do |c|
97
+ # c.user_agent = "my-app/1.0 (compliance@example.com)"
98
+ # end
99
+ #
100
+ # The block is handed a mutable copy of what is configured now, so
101
+ # settings accumulate across calls, and the copy is frozen into a new
102
+ # default client when the block returns. That replaces the held matcher,
103
+ # which is the behaviour a changed store or source list needs: an
104
+ # initializer that names a store must not leave a matcher behind that
105
+ # indexed a different one.
106
+ #
107
+ # Configure at boot, before anything screens. Later is honoured from the
108
+ # next call and does not reach what has already happened -- names folded
109
+ # under the old dictionary are already in an index, and scores recorded
110
+ # under the old weights were recorded under the old weights.
111
+ sig { params(block: T.proc.params(config: Configuration).void).returns(Configuration) }
112
+ def configure(&block)
113
+ settings = client.configuration.dup
114
+ block.call(settings)
115
+ CLIENT_LOCK.synchronize { @client = T.let(Client.new(configuration: settings), T.nilable(Client)) }
116
+ settings
117
+ end
118
+
119
+ # Runs a block with `configuration` in force, so that everything reached
120
+ # from inside it reads those settings rather than the default client's.
121
+ # This is how a Client makes its own User-Agent, dictionary, XML backend
122
+ # and query defaults reach code that was written against the module --
123
+ # the fetch layer, the normalizer, Query -- without every one of them
124
+ # having to be handed a configuration it does not otherwise want.
125
+ #
126
+ # ActiveSanction.with_configuration(audit_client.configuration) { ... }
127
+ #
128
+ # The one limit is the one every fiber-local has: **a thread started
129
+ # inside the block does not inherit it**, and starts from the default
130
+ # client's settings. Code that fans out has to reinstall the
131
+ # configuration in each worker, which is what Sync does.
132
+ sig { params(configuration: Configuration, block: T.proc.returns(T.untyped)).returns(T.untyped) }
133
+ def with_configuration(configuration, &block)
134
+ previous = Thread.current[CONFIGURATION_KEY]
135
+ Thread.current[CONFIGURATION_KEY] = configuration
136
+ block.call
137
+ ensure
138
+ Thread.current[CONFIGURATION_KEY] = previous
139
+ end
140
+
141
+ # Drops the default client and anything this fiber had installed, so the
142
+ # next call builds one from the defaults. What a suite runs between
143
+ # examples, and the reason a spec that configures a store does not leak it
144
+ # into the next one:
145
+ #
146
+ # config.after { ActiveSanction.reset! }
147
+ #
148
+ # It clears the fiber-local on the calling thread only; a thread that
149
+ # exited holding one has already taken it with it.
150
+ sig { returns(T.self_type) }
151
+ def reset!
152
+ CLIENT_LOCK.synchronize { @client = T.let(nil, T.nilable(Client)) }
153
+ Thread.current[CONFIGURATION_KEY] = nil
154
+ self
155
+ end
156
+
157
+ # Where the default client's synced lists are read from. Gzipped JSON
158
+ # under `storage_dir` unless the application named its own -- see
159
+ # Configuration#storage.
160
+ sig { returns(Storage::Base) }
161
+ def storage = client.storage
162
+
163
+ # The default client's matcher, built from its store on first use.
164
+ # See Client#matcher, which is where all of it is documented.
165
+ sig { returns(Matcher) }
166
+ def matcher = client.matcher
167
+
168
+ # Screens one name against every configured list:
169
+ #
170
+ # ActiveSanction.screen(name: "Bosco Ntaganda", type: :individual, threshold: 75)
171
+ #
172
+ # Sugar over .matcher, which is where everything this does is documented.
173
+ sig { params(query: T.untyped, overrides: T.untyped).returns(T::Array[MatchResult]) }
174
+ def screen(query = nil, **overrides) = client.screen(query, **overrides)
175
+
176
+ # Screens a list of names, returning one array of results per query, in
177
+ # the order they were given. See Matcher#screen_all.
178
+ sig { params(queries: T.untyped, overrides: T.untyped).returns(T::Array[T::Array[MatchResult]]) }
179
+ def screen_all(queries, **overrides) = client.screen_all(queries, **overrides)
180
+
181
+ # Fetches, parses and stores every configured list, and returns what each
182
+ # one did:
183
+ #
184
+ # report = ActiveSanction.sync! # every configured source
185
+ # report = ActiveSanction.sync!(:ofac_sdn) # one
186
+ # report = ActiveSanction.sync!(force: true) # bypass conditional GET
187
+ # report = ActiveSanction.sync!(concurrency: 3) # fetch three publishers at once
188
+ #
189
+ # report.failed? # => false
190
+ # report[:ofac_sdn].status # => :updated
191
+ # exit report.exit_code # 1 if any source failed
192
+ #
193
+ # A failing source does not raise and does not stop the others: it is
194
+ # captured into the report and **its previous snapshot is kept**, because
195
+ # yesterday's list with a visible age is safer than no list. See Sync,
196
+ # which is where all of that is documented, and Sync::Report.
197
+ #
198
+ # The block, if given, is called with each Sync::Result as that source
199
+ # finishes -- the progress hook for a run that takes minutes.
200
+ #
201
+ # Drops the shared matcher when any list changed, so the next screening
202
+ # call is answered by what was just synced. Not concurrent-safe against
203
+ # another sync of the same storage; see Client.
204
+ sig do
205
+ params(sources: T.untyped, options: T.untyped,
206
+ block: T.nilable(T.proc.params(result: Sync::Result).void)).returns(Sync::Report)
207
+ end
208
+ def sync!(*sources, **options, &block) = T.unsafe(client).sync!(*sources, **options, &block)
209
+
210
+ # What changed between two snapshots of one source:
211
+ #
212
+ # diff = ActiveSanction.diff(:ofac_sdn, from: last_months_snapshot, to: todays_snapshot)
213
+ # diff = ActiveSanction.diff(:ofac_sdn, from: last_months_snapshot) # `to:` is what is stored now
214
+ #
215
+ # diff.added # => [Entity], newly listed
216
+ # diff.removed # => [Entity], delisted
217
+ # diff.modified # => [Diff::Change], amended, with the fields that moved
218
+ # diff.changed # => [Entity], what to re-screen a book of business against
219
+ #
220
+ # So that re-screening runs against the eleven records that moved rather
221
+ # than against the whole list. A first sync -- `from: nil` -- is a baseline
222
+ # rather than a list of additions, and an amended record reports as one
223
+ # modification rather than as a delisting and a new listing. See Diff,
224
+ # which is where all of that is documented.
225
+ sig { params(source: T.untyped, options: T.untyped).returns(Diff) }
226
+ def diff(source = nil, **options) = T.unsafe(client).diff(source, **options)
227
+
228
+ # Applies a snapshot diff to a book of subjects, and returns the alerts:
229
+ #
230
+ # book = [
231
+ # ActiveSanction::Subject.new(id: "cust_1", name: "Bosco Ntaganda", date_of_birth: "1973"),
232
+ # ActiveSanction::Subject.new(id: "cust_2", name: "Jane Miller")
233
+ # ]
234
+ #
235
+ # alerts = ActiveSanction.rescreen(book, diff: diff, threshold: 75)
236
+ #
237
+ # alerts.first.subject_id # => "cust_1"
238
+ # alerts.first.change # => :newly_listed | :delisted | :details_changed
239
+ # alerts.first.result # => a full MatchResult, with its explanation
240
+ # alerts.first.previous_score # => what it scored against the old list version
241
+ #
242
+ # Screening answers about a name; this answers about a book of business,
243
+ # and it is the step that turns a diff into an alert. The cost is the book
244
+ # times the handful of records that moved rather than the book times the
245
+ # whole corpus, which is what makes rescreening after every sync
246
+ # affordable -- an empty diff scores nothing at all.
247
+ #
248
+ # A delisting raises an alert too: it is a change of status a compliance
249
+ # team has to record, and it is the one that lets a customer back through
250
+ # the door. The block, if given, is called with each alert as it is
251
+ # raised, so a large book streams past a small diff without accumulating
252
+ # anything. See Rescreen, which is where all of that is documented.
253
+ sig do
254
+ params(subjects: T.untyped, diff: T.untyped, options: T.untyped,
255
+ block: T.nilable(T.proc.params(alert: Rescreen::Alert).void)).returns(T::Array[Rescreen::Alert])
256
+ end
257
+ def rescreen(subjects, diff:, **options, &block)
258
+ T.unsafe(client).rescreen(subjects, diff: diff, **options, &block)
259
+ end
260
+
261
+ # Diagnoses whether a source's format has drifted -- fetching each list,
262
+ # parsing it, and comparing what it measures against the version that was
263
+ # stored at the last sync:
264
+ #
265
+ # report = ActiveSanction.doctor # every configured source
266
+ # report = ActiveSanction.doctor(:ofac_sdn) # one
267
+ # report = ActiveSanction.doctor(tolerance: 0.05) # report smaller movements
268
+ #
269
+ # report.ok? # => false
270
+ # report.findings # => [Doctor::Finding, ...]
271
+ # puts report
272
+ # exit report.exit_code
273
+ #
274
+ # The failure this exists for is the one a sync cannot see: a file that
275
+ # still parses cleanly and means something different. 19,321 entities
276
+ # carrying zero passports looks exactly as healthy as 19,321 carrying
277
+ # 23,429 if all anyone counts is records, and screening a passport number
278
+ # against the first returns a clean result for somebody who is on the list.
279
+ #
280
+ # Nothing is written -- not the snapshot, not the payload cache, not the
281
+ # conditional-GET validators -- so a diagnosis can never be the reason a
282
+ # sync skipped a list that changed, and nothing here repairs anything.
283
+ # Deciding that a 40% drop in record count is a delisting wave rather than
284
+ # a broken parse is a judgment call this library does not make. See Doctor,
285
+ # which is where all of that is documented.
286
+ #
287
+ # The block, if given, is called with each Doctor::Diagnosis as that source
288
+ # finishes.
289
+ sig do
290
+ params(sources: T.untyped, options: T.untyped,
291
+ block: T.nilable(T.proc.params(diagnosis: Doctor::Diagnosis).void)).returns(Doctor::Report)
292
+ end
293
+ def doctor(*sources, **options, &block) = T.unsafe(client).doctor(*sources, **options, &block)
294
+
295
+ # Writes one stored list to a portable, optionally signed bundle file:
296
+ #
297
+ # ActiveSanction.export(:ofac_sdn, to: "ofac_sdn.asb")
298
+ # ActiveSanction.export(:ofac_sdn, to: "ofac_sdn.asb", sign_with: private_key)
299
+ #
300
+ # One file, produced once, that another machine loads and screens against
301
+ # without reaching the publisher at all -- which is what an air-gapped
302
+ # installation needs, and what a deploy needs on the afternoon OFAC is
303
+ # down. See Snapshot::Bundle, and docs/bundle_format.md, which specifies
304
+ # the format well enough to be implemented outside Ruby.
305
+ sig { params(source: T.untyped, options: T.untyped).returns(Snapshot::Bundle::Header) }
306
+ def export(source, **options) = T.unsafe(client).export(source, **options)
307
+
308
+ # Loads a bundle into the configured store and returns the snapshot it
309
+ # held:
310
+ #
311
+ # snapshot = ActiveSanction.import("ofac_sdn.asb", verify_with: public_key)
312
+ # snapshot.trusted? # => true
313
+ #
314
+ # A tampered bundle raises, a bundle signed by an unknown key raises
315
+ # something different, and both happen before anything is stored. See
316
+ # Client#import, which is where the one subtlety -- verification does not
317
+ # survive a write to disk -- is documented.
318
+ sig { params(path: T.untyped, options: T.untyped).returns(Snapshot) }
319
+ def import(path, **options) = T.unsafe(client).import(path, **options)
320
+
321
+ # Drops the shared matcher so the next screening call builds one over
322
+ # what is stored now. What a process calls after a sync -- a matcher is
323
+ # built once and never updated, which is what lets it be screened from
324
+ # many threads without a lock.
325
+ sig { returns(T.self_type) }
326
+ def reload!
327
+ client.reload!
328
+ self
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,60 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "rails/generators"
7
+ require "rails/generators/active_record"
8
+
9
+ module ActiveSanction
10
+ # Rails generators, loaded only by Rails' own generator lookup. Nothing here
11
+ # is required by `require "active_sanction"`, which is what keeps Rails and
12
+ # ActiveRecord out of this gem's runtime dependencies.
13
+ #
14
+ # @api private
15
+ module Generators
16
+ # The migration that creates the tables Storage::ActiveRecord reads and
17
+ # writes:
18
+ #
19
+ # $ rails generate active_sanction:install
20
+ # $ rails db:migrate
21
+ #
22
+ # Only ever loaded by Rails' generator lookup, which is what keeps
23
+ # ActiveRecord and Rails out of this gem's runtime dependencies: nothing
24
+ # under lib/generators is required by `require "active_sanction"`.
25
+ #
26
+ # The migration it copies is ordinary `create_table` calls rather than a
27
+ # call into the gem. A migration is immutable history -- `rails db:migrate`
28
+ # on a fresh checkout has to build the schema the existing one was built
29
+ # from -- and a migration whose body lived in gem code would silently mean
30
+ # something different after a `bundle update`. The cost is that a schema
31
+ # change ships as a second migration, which is the same cost every other
32
+ # table in the host application pays.
33
+ #
34
+ # @api private
35
+ class InstallGenerator < ::Rails::Generators::Base
36
+ extend T::Sig
37
+ include ::ActiveRecord::Generators::Migration
38
+
39
+ source_root File.expand_path("templates", __dir__)
40
+
41
+ desc "Creates the migration for the ActiveSanction storage tables."
42
+
43
+ sig { void }
44
+ def create_migration_file
45
+ migration_template "create_active_sanction_tables.rb.tt",
46
+ File.join(db_migrate_path, "create_active_sanction_tables.rb")
47
+ end
48
+
49
+ private
50
+
51
+ # Stamped into the generated class so the migration keeps running under
52
+ # the Rails compatibility layer it was written against, which is what
53
+ # `ActiveRecord::Migration[7.1]` means.
54
+ sig { returns(String) }
55
+ def migration_version
56
+ "[#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}]"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Storage for ActiveSanction::Storage::ActiveRecord.
4
+ #
5
+ # One row per synced list, one per record on it, and one per name, address and
6
+ # identifier hanging off that record. A full OFAC SDN sync is one snapshot row,
7
+ # 19,015 entity rows and some 65,000 child rows -- OFAC ships 39,468 names and
8
+ # 25,078 addresses -- written with `insert_all` inside a single transaction.
9
+ #
10
+ # ### Why the children carry `snapshot_id` as well as `entity_id`
11
+ #
12
+ # So that reading a list is four indexed range scans rather than an `IN` clause
13
+ # holding 19,015 entity ids, so that replacing one is four `delete_all`s, and
14
+ # so that a host can scope a lookup to the list it means to screen against.
15
+ #
16
+ # ### Why `normalized_value` is indexed
17
+ #
18
+ # It is the candidate-generation path: an equality probe that narrows 19,015
19
+ # records to the few hundred worth scoring in Ruby, before any of them are
20
+ # loaded. Write the key with
21
+ # `ActiveSanction::Storage::ActiveRecord.prefilter_key` at query time -- it is
22
+ # the same fold this adapter applied on the way in, and a key built any other
23
+ # way will not find the rows.
24
+ #
25
+ # ### Why there are no foreign keys
26
+ #
27
+ # These tables are owned wholly by the adapter, which deletes children before
28
+ # parents inside the transaction that replaces a list, so nothing here depends
29
+ # on the database enforcing the order. Adding the constraints costs a check per
30
+ # row on a write that inserts tens of thousands of them. A host that wants them
31
+ # anyway can add them: the delete order above is already the one they require.
32
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
33
+ def change
34
+ create_table :active_sanction_snapshots do |t|
35
+ t.string :source, null: false
36
+ t.datetime :fetched_at, null: false
37
+ # sha256:<64 hex>, and what a match result cites: "which list version
38
+ # cleared this customer" is answered by comparing an audit record's
39
+ # checksum against this column, without loading either list.
40
+ t.string :checksum, null: false
41
+ t.integer :record_count, null: false
42
+ t.integer :schema_version, null: false
43
+ t.string :source_version
44
+
45
+ # One stored list per source, enforced by the database rather than by
46
+ # the adapter alone: a sync replaces a list, and two rows for one source
47
+ # would mean screening against whichever the query planner returned.
48
+ t.index :source, unique: true
49
+ end
50
+
51
+ create_table :active_sanction_entities do |t|
52
+ t.bigint :snapshot_id, null: false
53
+ # The order the publisher emitted the record in. A snapshot checksums its
54
+ # entities order-independently, so nothing else would catch a store
55
+ # handing rows back in whatever order the planner chose -- and a report
56
+ # whose rows move between two runs against an unchanged list is a report
57
+ # an examiner cannot cite.
58
+ t.integer :position, null: false
59
+ # ActiveSanction::Entity#id: the publisher's own reference, namespaced by
60
+ # source, e.g. "ofac_sdn:2674". Not unique across the table -- two
61
+ # snapshots of the same list both hold it.
62
+ t.string :external_id, null: false
63
+ t.string :source, null: false
64
+ # `type` is Rails' single-table-inheritance column, so the canonical
65
+ # record's `type` (individual, organization, vessel, aircraft) is stored
66
+ # under a name that will not make ActiveRecord try to instantiate it.
67
+ t.string :entity_type, null: false
68
+ t.string :source_ref
69
+ # JSON, as the canonical value objects serialize themselves. These are
70
+ # small, repeated, and nothing screens on them structurally, so they do
71
+ # not earn tables of their own the way names and identifiers do. `text`
72
+ # rather than a native JSON column so one migration runs everywhere; a
73
+ # host on PostgreSQL that wants to query them can change the type.
74
+ t.text :dates_of_birth
75
+ t.text :nationalities
76
+ t.text :programs
77
+ t.text :listed_on
78
+ t.text :remarks
79
+
80
+ t.index %i[snapshot_id position], unique: true
81
+ t.index %i[snapshot_id external_id]
82
+ end
83
+
84
+ create_table :active_sanction_names do |t|
85
+ t.bigint :entity_id, null: false
86
+ t.bigint :snapshot_id, null: false
87
+ t.integer :position, null: false
88
+ # `text`, because what the publisher wrote has to come back byte for byte
89
+ # -- the snapshot's checksum is computed over it, so a column that
90
+ # truncated a long organization name would fail the read rather than
91
+ # quietly shorten a name.
92
+ t.text :value, null: false
93
+ # Bounded, because it is indexed and MySQL will not index an unbounded
94
+ # column. 512 is comfortably past the longest name any of the launch
95
+ # lists publishes, and the key builder truncates to it so that a
96
+ # pathological name still produces a key a query can reproduce.
97
+ t.string :normalized_value, null: false, limit: 512
98
+ t.string :kind, null: false
99
+ t.string :quality
100
+ t.string :script
101
+
102
+ t.index %i[entity_id position], unique: true
103
+ t.index :snapshot_id
104
+ t.index :normalized_value
105
+ end
106
+
107
+ create_table :active_sanction_addresses do |t|
108
+ t.bigint :entity_id, null: false
109
+ t.bigint :snapshot_id, null: false
110
+ t.integer :position, null: false
111
+ t.text :street
112
+ t.string :city
113
+ t.string :state_province
114
+ t.string :postal_code
115
+ t.string :country
116
+ t.text :note
117
+
118
+ t.index %i[entity_id position], unique: true
119
+ t.index :snapshot_id
120
+ end
121
+
122
+ create_table :active_sanction_identifiers do |t|
123
+ t.bigint :entity_id, null: false
124
+ t.bigint :snapshot_id, null: false
125
+ t.integer :position, null: false
126
+ t.string :kind, null: false
127
+ t.text :value, null: false
128
+ # ActiveSanction::Identifier#normalized_value: the number with everything
129
+ # that is not alphanumeric removed, which is what lets OFAC's
130
+ # `AB-123 456` and the UN's `AB123456` find each other.
131
+ t.string :normalized_value, null: false, limit: 512
132
+ t.string :country
133
+ t.text :issued_on
134
+ t.text :expires_on
135
+ t.text :note
136
+
137
+ t.index %i[entity_id position], unique: true
138
+ t.index :snapshot_id
139
+ t.index :normalized_value
140
+ end
141
+ end
142
+ end