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,74 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "active_sanction/error"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
# Where a synced list lives between the sync that fetched it and the
|
|
8
|
+
# screening run that reads it.
|
|
9
|
+
#
|
|
10
|
+
# store = ActiveSanction::Storage::Memory.new
|
|
11
|
+
# store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
|
|
12
|
+
# store.read_snapshot(:ofac_sdn) # => Snapshot
|
|
13
|
+
#
|
|
14
|
+
# The namespace holds one interface (Storage::Base), the in-memory
|
|
15
|
+
# implementation of it, and the metadata object a store can answer with
|
|
16
|
+
# without loading a list. The adapters that persist anywhere else -- gzipped
|
|
17
|
+
# JSON on disk (#24), ActiveRecord (#25), whatever a host writes privately --
|
|
18
|
+
# are subclasses of Base and nothing here has to know they exist.
|
|
19
|
+
module Storage
|
|
20
|
+
# A source that has never been synced, or whose snapshot has been deleted,
|
|
21
|
+
# asked for by name. Raised rather than answered with nil wherever a caller
|
|
22
|
+
# named the source itself: screening against a list that turns out not to
|
|
23
|
+
# be there has to fail loudly, because the result of screening against
|
|
24
|
+
# nothing is a clean report.
|
|
25
|
+
class MissingSnapshot < StorageError; end
|
|
26
|
+
|
|
27
|
+
# A stored snapshot that cannot be trusted to be what it says it is: a
|
|
28
|
+
# truncated file, bytes that no longer hash to the checksum recorded beside
|
|
29
|
+
# them, a sidecar that is not JSON, a list filed under one source that
|
|
30
|
+
# claims to be another.
|
|
31
|
+
#
|
|
32
|
+
# Raised rather than repaired, and rather than returning whatever could
|
|
33
|
+
# still be read. A store that hands back the 8,000 records it managed to
|
|
34
|
+
# parse out of 19,015 produces a report that looks exactly like a clean
|
|
35
|
+
# one, which is the most expensive thing this library can get wrong. An
|
|
36
|
+
# operator can always delete the list and re-sync; nobody can recover a
|
|
37
|
+
# screening decision made against a list that was quietly half there.
|
|
38
|
+
class CorruptSnapshot < StorageError; end
|
|
39
|
+
|
|
40
|
+
# A stored snapshot written under a Snapshot::SCHEMA_VERSION this code does
|
|
41
|
+
# not know how to read -- almost always because the directory was written
|
|
42
|
+
# by a newer active_sanction than the one now reading it.
|
|
43
|
+
#
|
|
44
|
+
# Separate from CorruptSnapshot because the file is fine and the fix is
|
|
45
|
+
# different: upgrade the gem, or discard the list and re-sync under this
|
|
46
|
+
# one. It has to be caught before the list is parsed, because a newer
|
|
47
|
+
# schema will usually still deserialize -- into records missing whatever
|
|
48
|
+
# the new version added, with a checksum that verifies, and with no
|
|
49
|
+
# symptom other than names that stop matching.
|
|
50
|
+
class UnsupportedSchema < StorageError; end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
require "active_sanction/storage/meta"
|
|
55
|
+
require "active_sanction/storage/base"
|
|
56
|
+
require "active_sanction/storage/memory"
|
|
57
|
+
require "active_sanction/storage/file_system"
|
|
58
|
+
|
|
59
|
+
# Storage::ActiveRecord (#25) is optional in the strong sense: ActiveRecord is
|
|
60
|
+
# not a dependency of this gem and must not become one, so the adapter is
|
|
61
|
+
# loaded only where it can be, and the gem is fully usable without it.
|
|
62
|
+
#
|
|
63
|
+
# Both orders have to work, which is why this is two clauses rather than one.
|
|
64
|
+
# A script that requires ActiveRecord itself has already defined the constant
|
|
65
|
+
# by the time this file runs, and the first clause loads the adapter now. A
|
|
66
|
+
# Rails application loads ActiveSupport long before ActiveRecord::Base -- the
|
|
67
|
+
# framework is deliberately lazy about it -- so the second clause books the
|
|
68
|
+
# adapter onto the hook Rails runs when Base is finally loaded. An application
|
|
69
|
+
# that wants it unconditionally can always require it by name.
|
|
70
|
+
if defined?(ActiveRecord::Base)
|
|
71
|
+
require "active_sanction/storage/active_record"
|
|
72
|
+
elsif defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
|
|
73
|
+
ActiveSupport.on_load(:active_record) { require "active_sanction/storage/active_record" }
|
|
74
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/query"
|
|
8
|
+
require "active_sanction/scorer/subject"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
# One entry in a book of business: an application's own id, and everything
|
|
12
|
+
# it knows about the person or company behind it.
|
|
13
|
+
#
|
|
14
|
+
# subject = ActiveSanction::Subject.new(
|
|
15
|
+
# id: "cust_1",
|
|
16
|
+
# name: "Bosco Ntaganda",
|
|
17
|
+
# type: :individual,
|
|
18
|
+
# date_of_birth: "1973",
|
|
19
|
+
# country: "CD"
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
# subject.id # => "cust_1"
|
|
23
|
+
# subject.name # => "Bosco Ntaganda"
|
|
24
|
+
#
|
|
25
|
+
# ### Why an id is the whole of what this adds
|
|
26
|
+
#
|
|
27
|
+
# `screen` answers about a name. Rescreening answers about *a customer*, and
|
|
28
|
+
# the two are not the same question: an alert has to name the row in the
|
|
29
|
+
# host's database that a compliance team is going to open, hold and
|
|
30
|
+
# eventually dispose of. A book screened as bare names comes back as an
|
|
31
|
+
# array somebody has to re-join by position, which works exactly until a
|
|
32
|
+
# book is filtered, streamed in batches, or contains the same name twice --
|
|
33
|
+
# and two customers called Jane Miller is not a corner case, it is Tuesday.
|
|
34
|
+
#
|
|
35
|
+
# So the id is required, it is the caller's own, and nothing here interprets
|
|
36
|
+
# it. It travels onto every alert the subject produces (Rescreen::Alert), so
|
|
37
|
+
# a run's output joins back to the host's records without the host having
|
|
38
|
+
# kept the order it sent them in.
|
|
39
|
+
#
|
|
40
|
+
# ### It takes what `screen` takes
|
|
41
|
+
#
|
|
42
|
+
# Every evidence field a screening call accepts is accepted here, in every
|
|
43
|
+
# spelling Query accepts it in -- `date_of_birth:` and `dates_of_birth:`,
|
|
44
|
+
# `country:`, `countries:` and `nationalities:`, `identifier:` and
|
|
45
|
+
# `identifiers:`. A subject is a query about a customer, and a caller should
|
|
46
|
+
# not have to learn a second vocabulary to write one.
|
|
47
|
+
#
|
|
48
|
+
# The two things it refuses are the search *options* that a rescreen decides
|
|
49
|
+
# for itself. `sources:` is settled by the diff being applied -- a rescreen
|
|
50
|
+
# runs against one list version pair and nothing else -- and `limit:` would
|
|
51
|
+
# cap the alerts a subject can raise, which is not a thing this library is
|
|
52
|
+
# willing to do: an alert dropped for being eleventh is a sanctions hit
|
|
53
|
+
# nobody sees. Both are refused rather than ignored, because a search option
|
|
54
|
+
# that is silently dropped is a caller screening under a rule they think
|
|
55
|
+
# they set.
|
|
56
|
+
#
|
|
57
|
+
# `threshold:` is accepted, and is the one policy knob a subject carries.
|
|
58
|
+
# Risk-based screening is ordinary -- a correspondent bank at 70, a retail
|
|
59
|
+
# customer at 85 -- and a book that could not express it would force a host
|
|
60
|
+
# into one call per tier. Left unset, the subject is screened at whatever
|
|
61
|
+
# threshold the run names. See Rescreen.
|
|
62
|
+
#
|
|
63
|
+
# ### Where the evidence lives
|
|
64
|
+
#
|
|
65
|
+
# In a Scorer::Subject, built once here, which is the same object a Query
|
|
66
|
+
# holds and hands the scorer. The name is folded once, at construction, and
|
|
67
|
+
# a rescreening run compares that one folded form against every changed
|
|
68
|
+
# record rather than re-folding per comparison -- which is what makes
|
|
69
|
+
# streaming a large book past a small diff cheap.
|
|
70
|
+
#
|
|
71
|
+
# Instances are frozen on construction and compare by value.
|
|
72
|
+
class Subject
|
|
73
|
+
extend T::Sig
|
|
74
|
+
|
|
75
|
+
# The canonical spelling of every field, and the shape `#to_h` emits.
|
|
76
|
+
#
|
|
77
|
+
# @api private
|
|
78
|
+
MEMBERS = T.let((%i[id] + Scorer::Subject::MEMBERS + %i[threshold]).freeze, T::Array[Symbol])
|
|
79
|
+
|
|
80
|
+
# Search options a rescreen settles for itself. See the class comment.
|
|
81
|
+
#
|
|
82
|
+
# @api private
|
|
83
|
+
REFUSED = T.let(
|
|
84
|
+
{
|
|
85
|
+
sources: "the diff being applied names the list -- a rescreen runs against one pair of list versions",
|
|
86
|
+
limit: "a rescreen reports every changed record a subject matches -- an alert dropped for being " \
|
|
87
|
+
"eleventh is a hit nobody sees"
|
|
88
|
+
}.freeze,
|
|
89
|
+
T::Hash[Symbol, String]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# The caller's own id for this subject, carried onto every alert.
|
|
93
|
+
sig { returns(String) }
|
|
94
|
+
attr_reader :id
|
|
95
|
+
|
|
96
|
+
# The evidence, folded once. What the scorer compares against a record.
|
|
97
|
+
sig { returns(Scorer::Subject) }
|
|
98
|
+
attr_reader :evidence
|
|
99
|
+
|
|
100
|
+
# The lowest score worth an alert for this subject, or nil to take the
|
|
101
|
+
# run's. See the class comment.
|
|
102
|
+
sig { returns(T.nilable(Float)) }
|
|
103
|
+
attr_reader :threshold
|
|
104
|
+
|
|
105
|
+
class << self
|
|
106
|
+
extend T::Sig
|
|
107
|
+
|
|
108
|
+
# Whatever a caller had, as a Subject:
|
|
109
|
+
#
|
|
110
|
+
# Subject.build(subject) # itself
|
|
111
|
+
# Subject.build(id: "cust_1", name: "Bosco Ntaganda") # a Hash, string keys or symbol
|
|
112
|
+
#
|
|
113
|
+
# What a book of business is streamed through, so a host can hand this
|
|
114
|
+
# library the rows it already has rather than mapping them first.
|
|
115
|
+
sig { params(value: T.untyped).returns(Subject) }
|
|
116
|
+
def build(value)
|
|
117
|
+
case value
|
|
118
|
+
when Subject then value
|
|
119
|
+
when Hash then from_h(value)
|
|
120
|
+
else
|
|
121
|
+
raise InvalidArgument,
|
|
122
|
+
"a book holds ActiveSanction::Subject or Hash entries, got #{value.class}. A rescreen reports " \
|
|
123
|
+
"alerts against a caller's own id, so a bare name is not enough to raise one"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Rebuilds a subject from #to_h output, accepting string keys so a book
|
|
128
|
+
# read out of a database or a JSON payload needs no translation.
|
|
129
|
+
sig { params(hash: T.untyped).returns(Subject) }
|
|
130
|
+
def from_h(hash)
|
|
131
|
+
# `new(**hash)` past a required keyword parameter is one of the few
|
|
132
|
+
# things Sorbet cannot check statically. #initialize validates what
|
|
133
|
+
# arrives, which is where a bad round-trip is caught.
|
|
134
|
+
T.unsafe(self).new(**hash.to_h.transform_keys { |key| key.to_s.to_sym })
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# `id` and `name` are required; everything else is optional, for the
|
|
139
|
+
# reason Scorer::Subject gives -- most callers have a name and little
|
|
140
|
+
# else, and a field absent on either side is neutral rather than a
|
|
141
|
+
# conflict.
|
|
142
|
+
#
|
|
143
|
+
# The evidence fields are taken in any spelling Query accepts them in, and
|
|
144
|
+
# resolved here rather than in a separate builder: unlike a Query, which a
|
|
145
|
+
# screening call constructs on a caller's behalf, this is the object a
|
|
146
|
+
# host writes out by hand, so `.new` is the door everything comes through.
|
|
147
|
+
sig { params(id: T.untyped, fields: T.untyped).void }
|
|
148
|
+
def initialize(id:, **fields)
|
|
149
|
+
attributes = normalize(fields)
|
|
150
|
+
@id = T.let(id!(id), String)
|
|
151
|
+
@evidence = T.let(
|
|
152
|
+
Scorer::Subject.new(name: attributes[:name], type: attributes[:type],
|
|
153
|
+
dates_of_birth: attributes[:dates_of_birth] || [],
|
|
154
|
+
nationalities: attributes[:nationalities] || [],
|
|
155
|
+
identifiers: attributes[:identifiers] || []),
|
|
156
|
+
Scorer::Subject
|
|
157
|
+
)
|
|
158
|
+
@threshold = T.let(threshold!(attributes[:threshold]), T.nilable(Float))
|
|
159
|
+
freeze
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The name as the caller wrote it, which is what an alert quotes back.
|
|
163
|
+
sig { returns(String) }
|
|
164
|
+
def name = evidence.name
|
|
165
|
+
|
|
166
|
+
sig { returns(T.nilable(Symbol)) }
|
|
167
|
+
def type = evidence.type
|
|
168
|
+
|
|
169
|
+
sig { returns(T::Array[PartialDate]) }
|
|
170
|
+
def dates_of_birth = evidence.dates_of_birth
|
|
171
|
+
|
|
172
|
+
sig { returns(T::Array[String]) }
|
|
173
|
+
def nationalities = evidence.nationalities
|
|
174
|
+
|
|
175
|
+
sig { returns(T::Array[Identifier]) }
|
|
176
|
+
def identifiers = evidence.identifiers
|
|
177
|
+
|
|
178
|
+
# The folded name every comparison runs against, folded once at
|
|
179
|
+
# construction. What a rescreening run hands the index rather than the
|
|
180
|
+
# string it came from.
|
|
181
|
+
sig { returns(Normalizer::Form) }
|
|
182
|
+
def form = evidence.form
|
|
183
|
+
|
|
184
|
+
# This subject as the screening call it is, at the threshold given or its
|
|
185
|
+
# own -- which is what a MatchResult records as the question that was
|
|
186
|
+
# asked. `sources:` is the list the run covers.
|
|
187
|
+
sig { params(threshold: T.untyped, sources: T.untyped).returns(Query) }
|
|
188
|
+
def query(threshold: nil, sources: nil)
|
|
189
|
+
T.unsafe(Query).new(**evidence.to_h, sources: sources, threshold: threshold || self.threshold)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
193
|
+
def to_h = { id: id }.merge(evidence.to_h).merge(threshold: threshold)
|
|
194
|
+
|
|
195
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
196
|
+
def ==(other)
|
|
197
|
+
return false unless other.instance_of?(self.class)
|
|
198
|
+
|
|
199
|
+
to_h == other.to_h
|
|
200
|
+
end
|
|
201
|
+
alias eql? ==
|
|
202
|
+
|
|
203
|
+
sig { returns(Integer) }
|
|
204
|
+
def hash = [self.class, to_h].hash
|
|
205
|
+
|
|
206
|
+
sig { returns(String) }
|
|
207
|
+
def inspect = "#<#{self.class} #{id} #{name.inspect}>"
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
# Symbol keys, with every spelling Query accepts resolved to its canonical
|
|
212
|
+
# one -- and the two search options a rescreen settles for itself refused
|
|
213
|
+
# by name rather than ignored.
|
|
214
|
+
sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
215
|
+
def normalize(fields)
|
|
216
|
+
attributes = fields.each_with_object({}) do |(key, value), resolved|
|
|
217
|
+
member = Query::ALIASES.fetch(key, key)
|
|
218
|
+
reason = REFUSED[member]
|
|
219
|
+
raise InvalidArgument, "a Subject does not take #{member}: #{reason}" if reason
|
|
220
|
+
|
|
221
|
+
resolved[member] = wrap(member, value)
|
|
222
|
+
end
|
|
223
|
+
unknown = attributes.keys - MEMBERS
|
|
224
|
+
raise InvalidArgument, "unknown Subject attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
225
|
+
|
|
226
|
+
attributes
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# A single value where a collection is expected is a collection of one,
|
|
230
|
+
# for the reason Query gives: `dates_of_birth: "1973"` is what a
|
|
231
|
+
# caller with one date writes, and `Array(hash)` would read one identifier
|
|
232
|
+
# as a list of its pairs.
|
|
233
|
+
sig { params(member: Symbol, value: T.untyped).returns(T.untyped) }
|
|
234
|
+
def wrap(member, value)
|
|
235
|
+
return value unless Query::COLLECTIONS.include?(member)
|
|
236
|
+
return value if value.nil? || value.is_a?(Array)
|
|
237
|
+
|
|
238
|
+
[value]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Required, and taken as the caller wrote it: an id this library edited
|
|
242
|
+
# would not join back to the row it came from.
|
|
243
|
+
sig { params(value: T.untyped).returns(String) }
|
|
244
|
+
def id!(value)
|
|
245
|
+
string = value.to_s.strip
|
|
246
|
+
if string.empty?
|
|
247
|
+
raise InvalidArgument,
|
|
248
|
+
"a subject needs an id -- an alert has to name the record a compliance team will open, and a " \
|
|
249
|
+
"book screened by position cannot survive being filtered or streamed"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
-string
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# nil means "whatever the run says", which is the common case and the
|
|
256
|
+
# reason this is not defaulted from configuration here: a subject that
|
|
257
|
+
# quietly carried the configured threshold could not be told apart from
|
|
258
|
+
# one that asked for it, and the run could never override it.
|
|
259
|
+
sig { params(value: T.untyped).returns(T.nilable(Float)) }
|
|
260
|
+
def threshold!(value)
|
|
261
|
+
return nil if value.nil?
|
|
262
|
+
|
|
263
|
+
number = begin
|
|
264
|
+
Float(value)
|
|
265
|
+
rescue TypeError, ArgumentError
|
|
266
|
+
raise InvalidArgument, "threshold must be a number between 0 and 100, got #{value.inspect}"
|
|
267
|
+
end
|
|
268
|
+
Scorer.threshold!(number)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/sync/result"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
class Sync
|
|
11
|
+
# What a whole sync run did, one Result per source.
|
|
12
|
+
#
|
|
13
|
+
# report = ActiveSanction.sync!
|
|
14
|
+
#
|
|
15
|
+
# report.failed? # => true
|
|
16
|
+
# report[:un_consolidated].error # => "Net::ReadTimeout: ..."
|
|
17
|
+
# report.updated.map(&:source) # => [:ofac_sdn]
|
|
18
|
+
# puts report # => the table below
|
|
19
|
+
#
|
|
20
|
+
# 4 sources in 13.08s: 1 updated, 2 unchanged, 1 failed
|
|
21
|
+
# ofac_sdn updated 19015 records just fetched 12.41s
|
|
22
|
+
# ofac_consolidated unchanged 1203 records 2h old 0.28s
|
|
23
|
+
# canada_sema unchanged 684 records 2h old 0.19s
|
|
24
|
+
# un_consolidated failed 612 records 3d old 1.11s Net::ReadTimeout: execution expired
|
|
25
|
+
#
|
|
26
|
+
# ### It is an object, not console output
|
|
27
|
+
#
|
|
28
|
+
# This is the operational surface of a sync: it is what a host application
|
|
29
|
+
# alerts on, what a scheduled job exits with, and what the instrumentation
|
|
30
|
+
# hooks (#59) emit. So it serializes to a documented shape and `.from_h`
|
|
31
|
+
# rebuilds it -- a summary that only existed as printed text would mean
|
|
32
|
+
# every host that wants to notice a degrading source has to scrape a log.
|
|
33
|
+
#
|
|
34
|
+
# Note what the table prints beside a failure: the record count and age of
|
|
35
|
+
# the snapshot that source is *still* being screened against. A failed sync
|
|
36
|
+
# keeps its previous snapshot, which is the right call and is only safe
|
|
37
|
+
# while the age of what is being screened against is visible.
|
|
38
|
+
#
|
|
39
|
+
# Instances are frozen on construction and compare by value.
|
|
40
|
+
class Report
|
|
41
|
+
extend T::Sig
|
|
42
|
+
extend T::Generic
|
|
43
|
+
include Enumerable
|
|
44
|
+
|
|
45
|
+
# @api private
|
|
46
|
+
Elem = type_member { { fixed: Result } }
|
|
47
|
+
|
|
48
|
+
# @api private
|
|
49
|
+
MEMBERS = T.let(%i[results started_at duration].freeze, T::Array[Symbol])
|
|
50
|
+
|
|
51
|
+
sig { returns(T::Array[Result]) }
|
|
52
|
+
attr_reader :results
|
|
53
|
+
|
|
54
|
+
# When the run began, UTC.
|
|
55
|
+
sig { returns(Time) }
|
|
56
|
+
attr_reader :started_at
|
|
57
|
+
|
|
58
|
+
# Wall-clock seconds for the whole run, which is less than the sum of the
|
|
59
|
+
# per-source durations when sources ran in parallel.
|
|
60
|
+
sig { returns(Float) }
|
|
61
|
+
attr_reader :duration
|
|
62
|
+
|
|
63
|
+
# Rebuilds from #to_h output, accepting string keys so a report survives
|
|
64
|
+
# the trip through JSON.
|
|
65
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
66
|
+
def self.from_h(hash)
|
|
67
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
68
|
+
unknown = attributes.keys - MEMBERS
|
|
69
|
+
raise InvalidArgument, "unknown Sync::Report attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
70
|
+
|
|
71
|
+
T.unsafe(self).new(**attributes)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { params(results: T.untyped, started_at: T.untyped, duration: T.untyped).void }
|
|
75
|
+
def initialize(results:, started_at: nil, duration: 0.0)
|
|
76
|
+
@results = T.let(results!(results), T::Array[Result])
|
|
77
|
+
@started_at = T.let(time!(started_at), Time)
|
|
78
|
+
@duration = T.let(duration.to_f, Float)
|
|
79
|
+
freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { override.params(block: T.nilable(T.proc.params(result: Result).void)).returns(T.untyped) }
|
|
83
|
+
def each(&block)
|
|
84
|
+
return enum_for(:each) unless block
|
|
85
|
+
|
|
86
|
+
results.each(&block)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One source's result, or nil if the run did not cover it. A run that did
|
|
91
|
+
# not cover a source is not the same as one where it succeeded, which is
|
|
92
|
+
# why this does not raise: a caller asking about a source it did not sync
|
|
93
|
+
# is asking a question with an answer.
|
|
94
|
+
sig { params(source: T.untyped).returns(T.nilable(Result)) }
|
|
95
|
+
def [](source)
|
|
96
|
+
key = source.to_sym
|
|
97
|
+
results.find { |result| result.source == key }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { returns(T::Array[Symbol]) }
|
|
101
|
+
def sources = results.map(&:source)
|
|
102
|
+
|
|
103
|
+
sig { returns(T::Array[Result]) }
|
|
104
|
+
def updated = results.select(&:updated?)
|
|
105
|
+
|
|
106
|
+
sig { returns(T::Array[Result]) }
|
|
107
|
+
def unchanged = results.select(&:unchanged?)
|
|
108
|
+
|
|
109
|
+
sig { returns(T::Array[Result]) }
|
|
110
|
+
def failed = results.select(&:failed?)
|
|
111
|
+
|
|
112
|
+
# Sources that came out of this run with no snapshot stored at all, and
|
|
113
|
+
# so are not covered by screening. Louder than `failed` and rarer: a
|
|
114
|
+
# source that failed but kept its previous list is stale, one that has
|
|
115
|
+
# nothing stored is missing.
|
|
116
|
+
sig { returns(T::Array[Result]) }
|
|
117
|
+
def unscreenable = results.reject(&:stored?)
|
|
118
|
+
|
|
119
|
+
sig { returns(T::Boolean) }
|
|
120
|
+
def failed? = results.any?(&:failed?)
|
|
121
|
+
|
|
122
|
+
sig { returns(T::Boolean) }
|
|
123
|
+
def success? = !failed?
|
|
124
|
+
|
|
125
|
+
sig { returns(Integer) }
|
|
126
|
+
def size = results.size
|
|
127
|
+
|
|
128
|
+
sig { returns(T::Boolean) }
|
|
129
|
+
def empty? = results.empty?
|
|
130
|
+
|
|
131
|
+
# Records stored across every source the run covered, failures included:
|
|
132
|
+
# what is screenable now, rather than what was downloaded.
|
|
133
|
+
sig { returns(Integer) }
|
|
134
|
+
def record_count = results.sum { |result| result.record_count || 0 }
|
|
135
|
+
|
|
136
|
+
# The age of the stalest list this run left behind, in seconds. The one
|
|
137
|
+
# number to alert on if a host only wants one.
|
|
138
|
+
sig { returns(T.nilable(Integer)) }
|
|
139
|
+
def oldest_age = results.filter_map(&:age).max
|
|
140
|
+
|
|
141
|
+
# What a scheduled job should exit with, so that cron mails somebody and
|
|
142
|
+
# CI goes red when a source is failing. Deliberately here rather than
|
|
143
|
+
# left to each caller to derive: "one source failed" has to mean the same
|
|
144
|
+
# thing to every wrapper anyone writes around a sync.
|
|
145
|
+
#
|
|
146
|
+
# exit ActiveSanction.sync!.exit_code
|
|
147
|
+
sig { returns(Integer) }
|
|
148
|
+
def exit_code = failed? ? 1 : 0
|
|
149
|
+
|
|
150
|
+
# For a caller that wants any failure to be fatal, in the manner of
|
|
151
|
+
# Fetcher::Result#success!. Note that this is not what `sync!` does: the
|
|
152
|
+
# run has already finished and every other source has already been
|
|
153
|
+
# stored, so raising here reports a failure rather than causing one.
|
|
154
|
+
sig { returns(T.self_type) }
|
|
155
|
+
def success!
|
|
156
|
+
return self if success?
|
|
157
|
+
|
|
158
|
+
raise Failed, self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
162
|
+
def to_h
|
|
163
|
+
{
|
|
164
|
+
results: results.map(&:to_h),
|
|
165
|
+
started_at: started_at.iso8601,
|
|
166
|
+
duration: duration
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The sentence a failing run should put in front of a human: which
|
|
171
|
+
# sources failed, out of how many, and why.
|
|
172
|
+
sig { params(result: Result).returns(String) }
|
|
173
|
+
def records(result) = result.record_count&.to_s || "-"
|
|
174
|
+
|
|
175
|
+
sig { params(block: T.proc.params(result: Result).returns(String)).returns(Integer) }
|
|
176
|
+
def width(&block) = results.map { |result| block.call(result).length }.max.to_i
|
|
177
|
+
|
|
178
|
+
sig { returns(String) }
|
|
179
|
+
def failure_message
|
|
180
|
+
"#{failed.size} of #{size} source(s) failed to sync: " +
|
|
181
|
+
failed.map { |result| "#{result.source} (#{result.error})" }.join("; ")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
sig { returns(String) }
|
|
185
|
+
def summary
|
|
186
|
+
counts = { updated: updated.size, unchanged: unchanged.size, failed: failed.size }
|
|
187
|
+
.reject { |_status, count| count.zero? }
|
|
188
|
+
.map { |status, count| "#{count} #{status}" }
|
|
189
|
+
"#{size} #{size == 1 ? "source" : "sources"} in #{format("%.2f", duration)}s" \
|
|
190
|
+
"#{": #{counts.join(", ")}" unless counts.empty?}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
sig { returns(String) }
|
|
194
|
+
def to_s = ([summary] + rows).join("\n")
|
|
195
|
+
|
|
196
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
197
|
+
def ==(other)
|
|
198
|
+
return false unless other.instance_of?(self.class)
|
|
199
|
+
|
|
200
|
+
to_h == other.to_h
|
|
201
|
+
end
|
|
202
|
+
alias eql? ==
|
|
203
|
+
|
|
204
|
+
sig { returns(Integer) }
|
|
205
|
+
def hash = [self.class, to_h].hash
|
|
206
|
+
|
|
207
|
+
sig { returns(String) }
|
|
208
|
+
def inspect = "#<#{self.class} #{summary}>"
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
# One padded line per source. Columns rather than sentences because the
|
|
213
|
+
# thing an operator is doing with this is scanning down it for the row
|
|
214
|
+
# that is not like the others.
|
|
215
|
+
sig { returns(T::Array[String]) }
|
|
216
|
+
def rows
|
|
217
|
+
name = width { |result| result.source.to_s }
|
|
218
|
+
count = width { |result| records(result) }
|
|
219
|
+
age = width(&:age_in_words)
|
|
220
|
+
results.map do |result|
|
|
221
|
+
" #{result.source.to_s.ljust(name)} #{result.status.to_s.ljust(9)} " \
|
|
222
|
+
"#{records(result).rjust(count)} records #{result.age_in_words.ljust(age)} " \
|
|
223
|
+
"#{format("%.2f", result.duration).rjust(6)}s#{" #{result.error}" if result.failed?}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
sig { params(value: T.untyped).returns(T::Array[Result]) }
|
|
228
|
+
def results!(value)
|
|
229
|
+
list = Array(value).map { |result| result.is_a?(Result) ? result : Result.from_h(result) }
|
|
230
|
+
list.freeze
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
sig { params(value: T.untyped).returns(Time) }
|
|
234
|
+
def time!(value)
|
|
235
|
+
time = case value
|
|
236
|
+
when nil then Time.now
|
|
237
|
+
when Time then value
|
|
238
|
+
when String then Time.parse(value)
|
|
239
|
+
else raise InvalidArgument, "started_at is not a time: #{value.inspect}"
|
|
240
|
+
end
|
|
241
|
+
Time.at(time.to_i).utc
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|