clickwrap 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +154 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +187 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +729 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. 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