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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- 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
|