clickwrap 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Resolves the optional request evidence for one capture: the IP address the
|
|
5
|
+
# server observed, the browser user-agent the client supplied, and whatever a
|
|
6
|
+
# resolver was authorized to estimate from that address.
|
|
7
|
+
#
|
|
8
|
+
# Four properties of this class are load-bearing.
|
|
9
|
+
#
|
|
10
|
+
# It runs SYNCHRONOUSLY, before the evidence and domain transaction opens. Not
|
|
11
|
+
# in a job, not after commit, not in a rescue that fills the gap in later.
|
|
12
|
+
# Evidence that arrives after the action it was supposed to accompany is a
|
|
13
|
+
# different claim from evidence that accompanied it, and a receipt cannot tell
|
|
14
|
+
# the two apart once they are in the same columns. Resolving before the
|
|
15
|
+
# transaction opens also keeps a provider lookup from holding locks on
|
|
16
|
+
# evidence rows while it waits on somebody else's network.
|
|
17
|
+
#
|
|
18
|
+
# It WRITES NOTHING. It returns a value object holding the attribute hash for
|
|
19
|
+
# one `clickwrap_request_evidence` row, and the caller writes that row inside
|
|
20
|
+
# the transaction that carries the protected action. Required evidence and the
|
|
21
|
+
# action commit together or not at all.
|
|
22
|
+
#
|
|
23
|
+
# It reads ONLY from the HTTP request, through the host's configured readers.
|
|
24
|
+
# No parameter, no hidden form field, and no client-supplied header can select
|
|
25
|
+
# a policy, a resolver, a field, a precision, or a retention rule. A browser
|
|
26
|
+
# may answer a policy; it may never author one.
|
|
27
|
+
#
|
|
28
|
+
# It RECORDS WHAT IT COULD NOT GET. Missing, refused, failed, and answered are
|
|
29
|
+
# four different states, and every one of them ends up in a column with a
|
|
30
|
+
# reason attached. Nothing here silently substitutes a blank, a zero, or the
|
|
31
|
+
# word "Unknown" for an answer nobody gave.
|
|
32
|
+
#
|
|
33
|
+
# Nothing is collected unless the COMPILED policy says so by name. Application
|
|
34
|
+
# defaults are merged into that policy at boot, not here at capture time, so a
|
|
35
|
+
# configuration change necessarily produces a different policy revision and a
|
|
36
|
+
# policy can explicitly narrow an application default.
|
|
37
|
+
class RequestEvidenceExtractor
|
|
38
|
+
# What one extraction produced.
|
|
39
|
+
#
|
|
40
|
+
# `attributes` is the exact attribute hash for a `clickwrap_request_evidence`
|
|
41
|
+
# row, minus `event_id` and `created_at`, which the writer supplies once the
|
|
42
|
+
# event has an identifier. `authorized_fields` is the same manifest that
|
|
43
|
+
# travels in the row: the list of what the server-owned policy ALLOWED,
|
|
44
|
+
# which is a different and more useful fact than what happens to be present.
|
|
45
|
+
# A country column that is blank because the policy never authorized a
|
|
46
|
+
# country reads nothing like one that is blank because a provider had no
|
|
47
|
+
# answer, and the manifest is what keeps them apart.
|
|
48
|
+
Resolved = Data.define(:attributes, :authorized_fields, :records_anything) do
|
|
49
|
+
def initialize(attributes: {}, authorized_fields: {}, records_anything: false)
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def records_anything? = records_anything == true
|
|
54
|
+
|
|
55
|
+
# This policy records nothing about the request. No row is written at all
|
|
56
|
+
# — an empty annex row would be indistinguishable from one whose fields
|
|
57
|
+
# were later deleted under a retention rule.
|
|
58
|
+
def self.none(authorized_fields:)
|
|
59
|
+
new(attributes: {}, authorized_fields: authorized_fields, records_anything: false)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Reason strings are permanent evidence vocabulary. They are added to, never
|
|
64
|
+
# renamed or repurposed: a receipt written today is read by code that may be
|
|
65
|
+
# years newer, and a reason that changed meaning underneath it would make
|
|
66
|
+
# old evidence say something it never said.
|
|
67
|
+
NO_HTTP_REQUEST = "no_http_request"
|
|
68
|
+
CHANNEL_CARRIES_NO_HTTP_REQUEST = "capture_channel_carries_no_http_request"
|
|
69
|
+
NO_IP_ADDRESS_ON_HTTP_REQUEST = "no_ip_address_on_http_request"
|
|
70
|
+
FORWARDED_CHAIN_REFUSED = "ip_address_reader_returned_a_forwarded_chain"
|
|
71
|
+
NO_BROWSER_USER_AGENT_ON_HTTP_REQUEST = "no_browser_user_agent_on_http_request"
|
|
72
|
+
RESOLVER_RETURNED_NO_RESULT = "resolver_returned_no_result"
|
|
73
|
+
RESOLVER_CANNOT_SUPPLY_AUTHORIZED_FIELDS = "resolver_cannot_supply_authorized_fields"
|
|
74
|
+
PROVIDER_SUPPLIED_NO_AUTHORIZED_FIELD = "provider_supplied_no_authorized_field"
|
|
75
|
+
|
|
76
|
+
# What produced the stored address. Rails' `request.remote_ip` is the
|
|
77
|
+
# conventional reader and the one Clickwrap ships with; anything else is the
|
|
78
|
+
# host's own, and the receipt says so rather than implying Rails' spoof
|
|
79
|
+
# checks and trusted-proxy handling were involved when they were not.
|
|
80
|
+
RAILS_REQUEST_REMOTE_IP_READER_NAME = "rails_request_remote_ip"
|
|
81
|
+
HOST_CONFIGURED_READER_NAME = "host_configured_reader"
|
|
82
|
+
|
|
83
|
+
# Channels that structurally carry no HTTP request. A background job has no
|
|
84
|
+
# browser and never had one; that is a fact about the capture, not a failure
|
|
85
|
+
# to collect something, and the reason string says which it was.
|
|
86
|
+
CHANNELS_WITHOUT_AN_HTTP_REQUEST = %w[background_job imported_provider system].freeze
|
|
87
|
+
|
|
88
|
+
# Reasons are stored in a string column. A pathological error class name
|
|
89
|
+
# must not turn a recorded unavailability into a failed INSERT that rolls
|
|
90
|
+
# back the protected action.
|
|
91
|
+
MAXIMUM_UNAVAILABLE_REASON_LENGTH = 200
|
|
92
|
+
|
|
93
|
+
# Exactly which columns each authorized field unlocks. This table IS the
|
|
94
|
+
# minimization guarantee, which is why it is a table rather than a run of
|
|
95
|
+
# conditionals: a reviewer can see at a glance that authorizing `country`
|
|
96
|
+
# unlocks a country code and name and nothing else, and adding a column here
|
|
97
|
+
# is a visible decision to store more. Coordinates are absent on purpose —
|
|
98
|
+
# they are a coupled pair and are handled separately below.
|
|
99
|
+
TEXT_COLUMNS_BY_AUTHORIZED_FIELD = {
|
|
100
|
+
"country" => { ip_geolocation_country_code: :country_code,
|
|
101
|
+
ip_geolocation_country_name: :country_name },
|
|
102
|
+
"region" => { ip_geolocation_region_name: :region_name,
|
|
103
|
+
ip_geolocation_region_code: :region_code },
|
|
104
|
+
"city" => { ip_geolocation_city_name: :city_name },
|
|
105
|
+
"postal_code" => { ip_geolocation_postal_code: :postal_code },
|
|
106
|
+
"timezone" => { ip_geolocation_timezone: :timezone },
|
|
107
|
+
"continent" => { ip_geolocation_continent_code: :continent_code },
|
|
108
|
+
"metro_code" => { ip_geolocation_metro_code: :metro_code }
|
|
109
|
+
}.freeze
|
|
110
|
+
|
|
111
|
+
class << self
|
|
112
|
+
# The source location of a freshly built Configuration's default IP-address
|
|
113
|
+
# reader. See `#ip_address_reader_name` for why this is a comparison
|
|
114
|
+
# against a fresh object rather than against a constant.
|
|
115
|
+
def default_ip_address_reader_source_location
|
|
116
|
+
return @default_ip_address_reader_source_location if defined?(@default_ip_address_reader_source_location)
|
|
117
|
+
|
|
118
|
+
@default_ip_address_reader_source_location =
|
|
119
|
+
begin
|
|
120
|
+
Configuration.new.read_ip_address_from_http_request_with.source_location
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `policy:` accepts a compiled Clickwrap::Policy or the RequestEvidencePolicy
|
|
128
|
+
# it carries, so a test can hand this class an allowlist directly.
|
|
129
|
+
# `http_request:` is nil for captures that genuinely have no request, and
|
|
130
|
+
# that absence is recorded rather than papered over.
|
|
131
|
+
def initialize(policy:, http_request: nil, capture_channel: nil)
|
|
132
|
+
@policy = policy.respond_to?(:request_evidence) ? policy.request_evidence : policy
|
|
133
|
+
@http_request = http_request
|
|
134
|
+
@capture_channel = capture_channel&.to_s
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract
|
|
138
|
+
return Resolved.none(authorized_fields: authorized_fields) unless records_anything?
|
|
139
|
+
|
|
140
|
+
attributes = { authorized_fields: authorized_fields }
|
|
141
|
+
.merge(ip_address_attributes)
|
|
142
|
+
.merge(browser_user_agent_attributes)
|
|
143
|
+
.merge(ip_geolocation_attributes)
|
|
144
|
+
|
|
145
|
+
Resolved.new(attributes: attributes.freeze, authorized_fields: authorized_fields,
|
|
146
|
+
records_anything: true)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
attr_reader :policy, :http_request, :capture_channel
|
|
152
|
+
|
|
153
|
+
def config = Clickwrap.config
|
|
154
|
+
def policy_key = policy.policy_key
|
|
155
|
+
|
|
156
|
+
# One clock reading for the whole extraction, so every field recorded in
|
|
157
|
+
# this capture shares one recorded-at and one retention deadline.
|
|
158
|
+
def now = @now ||= Clickwrap.now
|
|
159
|
+
|
|
160
|
+
# --- The compiled decision ------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def ip_address_setting = policy.ip_address
|
|
163
|
+
def browser_user_agent_setting = policy.browser_user_agent
|
|
164
|
+
def ip_geolocation_setting = policy.ip_geolocation
|
|
165
|
+
|
|
166
|
+
def declared_ip_geolocation_fields = policy.ip_geolocation_fields
|
|
167
|
+
|
|
168
|
+
def enabled_ip_geolocation_fields
|
|
169
|
+
@enabled_ip_geolocation_fields ||= declared_ip_geolocation_fields.select { |_, on| on }.keys.freeze
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def authorized_ip_geolocation_field?(field) = declared_ip_geolocation_fields.fetch(field, false)
|
|
173
|
+
|
|
174
|
+
def records_ip_address? = ip_address_setting.record?
|
|
175
|
+
def records_browser_user_agent? = browser_user_agent_setting.record?
|
|
176
|
+
def records_ip_geolocation? = ip_geolocation_setting.record? && enabled_ip_geolocation_fields.any?
|
|
177
|
+
|
|
178
|
+
def records_anything?
|
|
179
|
+
records_ip_address? || records_browser_user_agent? || records_ip_geolocation?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The manifest stored beside the values. It answers "what was this server
|
|
183
|
+
# allowed to keep", which is the question an auditor actually has, and it
|
|
184
|
+
# answers it from the policy rather than from whatever survived.
|
|
185
|
+
def authorized_fields
|
|
186
|
+
@authorized_fields ||= {
|
|
187
|
+
"ip_address" => records_ip_address?,
|
|
188
|
+
"browser_user_agent" => records_browser_user_agent?,
|
|
189
|
+
"ip_geolocation" => records_ip_geolocation? ? declared_ip_geolocation_fields : no_ip_geolocation_fields
|
|
190
|
+
}.freeze
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def no_ip_geolocation_fields
|
|
194
|
+
@no_ip_geolocation_fields ||= Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h { |field| [field, false] }.freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- The IP address -------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
# Resolved once, whether or not the address itself is stored: a policy may
|
|
200
|
+
# estimate a country from an address it never keeps, and that is a smaller
|
|
201
|
+
# collection than keeping the address, not a larger one.
|
|
202
|
+
def observed_ip_address
|
|
203
|
+
read_ip_address_once unless defined?(@observed_ip_address)
|
|
204
|
+
@observed_ip_address
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def ip_address_problem_reason
|
|
208
|
+
read_ip_address_once unless defined?(@observed_ip_address)
|
|
209
|
+
@ip_address_problem_reason
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def read_ip_address_once
|
|
213
|
+
@observed_ip_address = nil
|
|
214
|
+
@ip_address_problem_reason = nil
|
|
215
|
+
|
|
216
|
+
return @ip_address_problem_reason = missing_http_request_reason if http_request.nil?
|
|
217
|
+
|
|
218
|
+
value, failure = read_from_http_request(config.read_ip_address_from_http_request_with,
|
|
219
|
+
"ip_address_reader")
|
|
220
|
+
return @ip_address_problem_reason = failure if failure
|
|
221
|
+
|
|
222
|
+
classify_ip_address(value.to_s.strip)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def classify_ip_address(value)
|
|
226
|
+
if value.empty?
|
|
227
|
+
@ip_address_problem_reason = NO_IP_ADDRESS_ON_HTTP_REQUEST
|
|
228
|
+
elsif value.include?(",")
|
|
229
|
+
# A comma means the reader handed back a forwarding chain rather than
|
|
230
|
+
# one observed address. Clickwrap will not store it. Everything after
|
|
231
|
+
# the first trusted hop in such a chain is client-supplied and can be
|
|
232
|
+
# anything at all, and a whole chain filed under "the address the server
|
|
233
|
+
# observed" presents attacker-controlled input as an observation. A host
|
|
234
|
+
# whose topology needs a different address picks it in its own reader
|
|
235
|
+
# and owns that decision.
|
|
236
|
+
@ip_address_problem_reason = FORWARDED_CHAIN_REFUSED
|
|
237
|
+
else
|
|
238
|
+
@observed_ip_address = value
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def ip_address_attributes
|
|
243
|
+
return {} unless records_ip_address?
|
|
244
|
+
|
|
245
|
+
# Provenance is recorded even when the value is not, because "which reader
|
|
246
|
+
# was asked, under which reviewed proxy configuration" is what tells a
|
|
247
|
+
# later reader how much the address is worth.
|
|
248
|
+
provenance = {
|
|
249
|
+
ip_address_reader_name: ip_address_reader_name,
|
|
250
|
+
trusted_proxy_configuration_digest: policy.trusted_proxy_configuration_digest
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if observed_ip_address.nil?
|
|
254
|
+
fail_closed!(:ip_address, ip_address_setting, ip_address_problem_reason)
|
|
255
|
+
return provenance.merge(ip_address_unavailable_reason: ip_address_problem_reason)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
provenance
|
|
259
|
+
.merge(ip_address_ciphertext: observed_ip_address, ip_address_recorded_at: now)
|
|
260
|
+
.merge(retention_attributes(:ip_address, ip_address_setting))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Rails documents that `request.remote_ip` inspects forwarded headers,
|
|
264
|
+
# discards configured trusted proxies, and performs a spoof check — and that
|
|
265
|
+
# it can be wrong when the deployment does not match the proxy topology it
|
|
266
|
+
# was told about. Recording which reader produced the value is what lets a
|
|
267
|
+
# later reader judge that, so the label has to be accurate.
|
|
268
|
+
#
|
|
269
|
+
# The default reader is a lambda built per Configuration instance, so there
|
|
270
|
+
# is no constant to compare against; a fresh Configuration's reader gives us
|
|
271
|
+
# its source location instead. A host that assigns its own reader is labeled
|
|
272
|
+
# `host_configured_reader` even if the body is identical, which is the
|
|
273
|
+
# conservative answer: the host owns and documents that decision, and
|
|
274
|
+
# Clickwrap should not claim Rails' behavior on its behalf.
|
|
275
|
+
def ip_address_reader_name
|
|
276
|
+
default_location = self.class.default_ip_address_reader_source_location
|
|
277
|
+
reader = config.read_ip_address_from_http_request_with
|
|
278
|
+
|
|
279
|
+
return HOST_CONFIGURED_READER_NAME if default_location.nil?
|
|
280
|
+
return HOST_CONFIGURED_READER_NAME unless reader.respond_to?(:source_location)
|
|
281
|
+
return HOST_CONFIGURED_READER_NAME unless reader.source_location == default_location
|
|
282
|
+
|
|
283
|
+
RAILS_REQUEST_REMOTE_IP_READER_NAME
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# --- The browser user-agent -----------------------------------------------
|
|
287
|
+
|
|
288
|
+
def observed_browser_user_agent
|
|
289
|
+
read_browser_user_agent_once unless defined?(@observed_browser_user_agent)
|
|
290
|
+
@observed_browser_user_agent
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def browser_user_agent_problem_reason
|
|
294
|
+
read_browser_user_agent_once unless defined?(@observed_browser_user_agent)
|
|
295
|
+
@browser_user_agent_problem_reason
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def read_browser_user_agent_once
|
|
299
|
+
@observed_browser_user_agent = nil
|
|
300
|
+
@browser_user_agent_problem_reason = nil
|
|
301
|
+
|
|
302
|
+
return @browser_user_agent_problem_reason = missing_http_request_reason if http_request.nil?
|
|
303
|
+
|
|
304
|
+
value, failure = read_from_http_request(config.read_browser_user_agent_from_http_request_with,
|
|
305
|
+
"browser_user_agent_reader")
|
|
306
|
+
return @browser_user_agent_problem_reason = failure if failure
|
|
307
|
+
|
|
308
|
+
value = value.to_s.strip
|
|
309
|
+
if value.empty?
|
|
310
|
+
@browser_user_agent_problem_reason = NO_BROWSER_USER_AGENT_ON_HTTP_REQUEST
|
|
311
|
+
else
|
|
312
|
+
@observed_browser_user_agent = value
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def browser_user_agent_attributes
|
|
317
|
+
return {} unless records_browser_user_agent?
|
|
318
|
+
|
|
319
|
+
# Always true, and not a formality. The value is whatever the client chose
|
|
320
|
+
# to send: it can be edited or omitted, and browsers report less of it
|
|
321
|
+
# every year. Recording that it was client-supplied is what stops a
|
|
322
|
+
# receipt from reading like a device identification. Clickwrap stores the
|
|
323
|
+
# raw header only — no canvas, font, hardware, or high-entropy client-hint
|
|
324
|
+
# probe is emitted anywhere in this gem.
|
|
325
|
+
base = { browser_user_agent_was_client_supplied: true }
|
|
326
|
+
|
|
327
|
+
if observed_browser_user_agent.nil?
|
|
328
|
+
fail_closed!(:browser_user_agent, browser_user_agent_setting, browser_user_agent_problem_reason)
|
|
329
|
+
return base.merge(browser_user_agent_unavailable_reason: browser_user_agent_problem_reason)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
base
|
|
333
|
+
.merge(browser_user_agent_ciphertext: observed_browser_user_agent,
|
|
334
|
+
browser_user_agent_recorded_at: now)
|
|
335
|
+
.merge(retention_attributes(:browser_user_agent, browser_user_agent_setting))
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# --- The IP geolocation estimate ------------------------------------------
|
|
339
|
+
|
|
340
|
+
def resolver
|
|
341
|
+
@resolver ||= config.ip_geolocation_resolver_for(policy.ip_geolocation_resolver_name) ||
|
|
342
|
+
IpGeolocation::NullResolver.new
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def resolved_location
|
|
346
|
+
return @resolved_location if defined?(@resolved_location)
|
|
347
|
+
|
|
348
|
+
@resolver_error = nil
|
|
349
|
+
@resolved_location = observed_ip_address.nil? ? nil : call_resolver
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def call_resolver
|
|
353
|
+
resolver.resolve(observed_ip_address, http_request: http_request)
|
|
354
|
+
rescue StandardError => error
|
|
355
|
+
# Recorded, never swallowed. The reason carries the error CLASS and never
|
|
356
|
+
# its message: a provider's message can quote the address it was given,
|
|
357
|
+
# and this string lands in a column that a redacted receipt may show.
|
|
358
|
+
@resolver_error = error
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def ip_geolocation_attributes
|
|
363
|
+
return {} unless records_ip_geolocation?
|
|
364
|
+
|
|
365
|
+
location = resolved_location
|
|
366
|
+
values = location.nil? ? {} : authorized_ip_geolocation_values(location)
|
|
367
|
+
reason = ip_geolocation_problem_reason(location, values)
|
|
368
|
+
provenance = ip_geolocation_provenance(location)
|
|
369
|
+
|
|
370
|
+
if reason
|
|
371
|
+
fail_closed!(:ip_geolocation, ip_geolocation_setting, reason)
|
|
372
|
+
return provenance.merge(ip_geolocation_unavailable_reason: reason)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
provenance
|
|
376
|
+
.merge(values)
|
|
377
|
+
.merge(ip_geolocation_recorded_at: now)
|
|
378
|
+
.merge(retention_attributes(:ip_geolocation, ip_geolocation_setting))
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Provenance is not optional and not a policy choice. A country code with no
|
|
382
|
+
# provider behind it, or coordinates with no resolution time, invites a
|
|
383
|
+
# reader to treat a guess about an address as a fact about a person. These
|
|
384
|
+
# columns travel with any stored estimate and with every failure to produce
|
|
385
|
+
# one.
|
|
386
|
+
def ip_geolocation_provenance(location)
|
|
387
|
+
{
|
|
388
|
+
ip_geolocation_provider_name: text_value(location&.provider_name),
|
|
389
|
+
ip_geolocation_provider_source: text_value(location&.provider_source),
|
|
390
|
+
ip_geolocation_database_version: text_value(location&.database_version),
|
|
391
|
+
ip_geolocation_database_sha256: text_value(location&.database_sha256),
|
|
392
|
+
# An IP-geolocation result is an estimate about an address. Nothing a
|
|
393
|
+
# resolver reports and nothing a policy enables changes that.
|
|
394
|
+
ip_geolocation_was_estimated: location.nil? || location.estimated?,
|
|
395
|
+
ip_geolocation_source_was_verified_by_host: location&.source_was_verified_by_host? || false,
|
|
396
|
+
# Resolution time is not optional either. When a resolver does not
|
|
397
|
+
# report one, the server's own clock at extraction stands in — which is
|
|
398
|
+
# what the column means anyway: time recorded by the application server.
|
|
399
|
+
ip_geolocation_resolved_at: location&.resolved_at || now
|
|
400
|
+
}
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Five distinct ways this can produce nothing, kept distinct because they
|
|
404
|
+
# tell an auditor completely different things: there was no address to
|
|
405
|
+
# resolve; the resolver blew up; it returned nothing at all; it explained
|
|
406
|
+
# why it had no answer; or it answered but had no value for any field this
|
|
407
|
+
# policy authorized. The last one splits again — a provider that CANNOT ever
|
|
408
|
+
# supply the authorized fields is a configuration problem, while one that
|
|
409
|
+
# simply had no value for this address is not.
|
|
410
|
+
def ip_geolocation_problem_reason(location, values)
|
|
411
|
+
return ip_address_problem_reason if observed_ip_address.nil?
|
|
412
|
+
return truncate("ip_geolocation_resolver_raised_#{@resolver_error.class}") if @resolver_error
|
|
413
|
+
return RESOLVER_RETURNED_NO_RESULT if location.nil?
|
|
414
|
+
return truncate(location.unavailable_reason) if location.unavailable?
|
|
415
|
+
return nil if values.any?
|
|
416
|
+
|
|
417
|
+
return PROVIDER_SUPPLIED_NO_AUTHORIZED_FIELD if resolver_can_supply_an_authorized_field?
|
|
418
|
+
|
|
419
|
+
RESOLVER_CANNOT_SUPPLY_AUTHORIZED_FIELDS
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def resolver_can_supply_an_authorized_field?
|
|
423
|
+
capabilities = resolver_capabilities
|
|
424
|
+
return true if capabilities.nil?
|
|
425
|
+
|
|
426
|
+
enabled_ip_geolocation_fields.intersect?(capabilities)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def resolver_capabilities
|
|
430
|
+
return nil unless resolver.respond_to?(:capabilities)
|
|
431
|
+
|
|
432
|
+
Array(resolver.capabilities).map(&:to_s)
|
|
433
|
+
rescue StandardError, NotImplementedError
|
|
434
|
+
# A resolver that cannot say what it supports gets the benefit of the
|
|
435
|
+
# doubt: Clickwrap reports that the provider had no value, rather than
|
|
436
|
+
# accusing it of being unable to supply one.
|
|
437
|
+
nil
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Exactly the fields the server-owned policy authorized, copied one at a
|
|
441
|
+
# time. The resolver's result object is never persisted wholesale: a
|
|
442
|
+
# provider that gains a field upstream must never widen what this gem stores
|
|
443
|
+
# without someone deciding to store it.
|
|
444
|
+
def authorized_ip_geolocation_values(location)
|
|
445
|
+
ip_geolocation_data_field_values(location).compact.merge(accuracy_radius_values(location))
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def ip_geolocation_data_field_values(location)
|
|
449
|
+
values = TEXT_COLUMNS_BY_AUTHORIZED_FIELD.each_with_object({}) do |(field, columns), collected|
|
|
450
|
+
next unless authorized_ip_geolocation_field?(field)
|
|
451
|
+
|
|
452
|
+
columns.each { |column, reader| collected[column] = text_value(location.public_send(reader)) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
values.merge(coordinate_values(location))
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# One coupled choice, and `coordinates?` is the reason it is written as a
|
|
459
|
+
# pair: half a coordinate is not a result, and a latitude presented on its
|
|
460
|
+
# own would be read as one.
|
|
461
|
+
def coordinate_values(location)
|
|
462
|
+
return {} unless authorized_ip_geolocation_field?("latitude_and_longitude")
|
|
463
|
+
return {} unless location.coordinates?
|
|
464
|
+
|
|
465
|
+
{ ip_geolocation_latitude: location.latitude, ip_geolocation_longitude: location.longitude }
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The confidence percentage travels with the radius it qualifies. It is not
|
|
469
|
+
# separately selectable, for the same reason the radius is not separately
|
|
470
|
+
# discardable: a number of kilometres means nothing without the confidence
|
|
471
|
+
# the provider attaches to it.
|
|
472
|
+
def accuracy_radius_values(location)
|
|
473
|
+
return {} unless location.accuracy_radius?
|
|
474
|
+
return {} unless authorized_ip_geolocation_field?("accuracy_radius_in_kilometers")
|
|
475
|
+
|
|
476
|
+
{
|
|
477
|
+
ip_geolocation_accuracy_radius_in_kilometers:
|
|
478
|
+
integer_value(location.accuracy_radius_in_kilometers),
|
|
479
|
+
ip_geolocation_accuracy_radius_confidence_percentage:
|
|
480
|
+
integer_value(location.accuracy_radius_confidence_percentage)
|
|
481
|
+
}.compact
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# --- Retention ------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
# Every recorded field leaves here with a disposal rule: a date, or the name
|
|
487
|
+
# of a host rule that will produce one. There is no keep-forever default
|
|
488
|
+
# anywhere in this gem, and a recorded field with neither is a configuration
|
|
489
|
+
# bug caught before the row is written rather than a row nobody ever deletes.
|
|
490
|
+
#
|
|
491
|
+
# `retain_until` names a host calculation instead of a duration because real
|
|
492
|
+
# record-keeping schedules are not always durations — "five years, or three
|
|
493
|
+
# years after this contract is liquidated, whichever is later" cannot be
|
|
494
|
+
# expressed as a number of days at capture time.
|
|
495
|
+
def retention_attributes(category, setting)
|
|
496
|
+
return { "#{category}_delete_after": now + setting.delete_after } if setting.delete_after
|
|
497
|
+
return { "#{category}_retain_until_rule": setting.retain_until.to_s } if setting.retain_until
|
|
498
|
+
|
|
499
|
+
class_rule = retention_class_rule_for(category)
|
|
500
|
+
return { "#{category}_delete_after": now + class_rule.duration } if class_rule&.duration?
|
|
501
|
+
return { "#{category}_retain_until_rule": class_rule.host_event_name.to_s } if class_rule&.host_event?
|
|
502
|
+
|
|
503
|
+
raise ConfigurationError, missing_retention_message(category)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def retention_class_rule_for(category)
|
|
507
|
+
return nil if policy.retention_class_key.nil?
|
|
508
|
+
|
|
509
|
+
Clickwrap.retention_class!(policy.retention_class_key).rule_for(category)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def missing_retention_message(category)
|
|
513
|
+
"Clickwrap is about to record #{category} for policy #{policy_key} and nothing says when " \
|
|
514
|
+
"to delete it. Give the policy a rule — `delete_after:` with a reviewed period, or " \
|
|
515
|
+
"`retain_until:` naming a host retention calculation — or add a #{category} rule to " \
|
|
516
|
+
"retention class #{policy.retention_class_key.inspect}. Clickwrap has no keep-forever " \
|
|
517
|
+
"default and will not choose a period for you."
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# --- Failing closed -------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
# A policy can decide that evidence it cannot get is worse than no capture
|
|
523
|
+
# at all. When it has, the capture and the protected action roll back
|
|
524
|
+
# together; nothing is written half-formed.
|
|
525
|
+
#
|
|
526
|
+
# The message names the policy, the category, and the reason, and never the
|
|
527
|
+
# value: an exception message travels into logs, error trackers, and issue
|
|
528
|
+
# trackers, which is exactly where a recorded IP address must not appear.
|
|
529
|
+
def fail_closed!(category, setting, reason)
|
|
530
|
+
requirement = requirement_for(category, setting)
|
|
531
|
+
return if requirement.nil?
|
|
532
|
+
|
|
533
|
+
raise RequestEvidenceUnavailable,
|
|
534
|
+
"Policy #{policy_key} records #{category} and #{requirement}, but this capture " \
|
|
535
|
+
"could not supply it (#{reason}). Nothing was written: required request evidence " \
|
|
536
|
+
"and the action it protects commit together or not at all. Either capture from a " \
|
|
537
|
+
"request that carries the value#{channel_note}, or drop that requirement and accept " \
|
|
538
|
+
"an explicit unavailable state on the receipt."
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def requirement_for(_category, setting)
|
|
542
|
+
return "the policy sets `fail_if_unavailable: true`" if setting.fail_if_unavailable?
|
|
543
|
+
|
|
544
|
+
nil
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def channel_note
|
|
548
|
+
return "" if capture_channel.nil?
|
|
549
|
+
|
|
550
|
+
" (this one arrived on the #{capture_channel} channel)"
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# --- Shared helpers -------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
def missing_http_request_reason
|
|
556
|
+
return CHANNEL_CARRIES_NO_HTTP_REQUEST if CHANNELS_WITHOUT_AN_HTTP_REQUEST.include?(capture_channel)
|
|
557
|
+
|
|
558
|
+
NO_HTTP_REQUEST
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Returns `[value, failure_reason]`. A host reader can raise — Rails' own
|
|
562
|
+
# raises `IpSpoofAttackError` when the forwarded headers contradict each
|
|
563
|
+
# other, which is a genuinely useful thing to find written on a receipt.
|
|
564
|
+
# The failure becomes an unavailable state naming the error class, and a
|
|
565
|
+
# policy that requires the field still fails closed on it.
|
|
566
|
+
def read_from_http_request(reader, label)
|
|
567
|
+
[reader.call(http_request), nil]
|
|
568
|
+
rescue StandardError => error
|
|
569
|
+
[nil, truncate("#{label}_raised_#{error.class}")]
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def text_value(value)
|
|
573
|
+
return nil if value.nil?
|
|
574
|
+
|
|
575
|
+
string = value.to_s.strip
|
|
576
|
+
string.empty? ? nil : string
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def integer_value(value)
|
|
580
|
+
return nil if value.nil?
|
|
581
|
+
return nil if value.to_s.strip.empty?
|
|
582
|
+
|
|
583
|
+
Integer(value, exception: false) || Float(value, exception: false)&.round
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def truncate(reason)
|
|
587
|
+
reason.to_s[0, MAXIMUM_UNAVAILABLE_REASON_LENGTH]
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|