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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -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 +138 -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 +184 -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 +716 -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,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module DSL
5
+ # The block passed to `Clickwrap.policy`.
6
+ #
7
+ # Clickwrap.policy :signup do
8
+ # agree_to :terms
9
+ # acknowledge :privacy_notice
10
+ # retain_with :ordinary_agreement_evidence
11
+ # end
12
+ #
13
+ # The verbs are deliberate. `agree_to :terms` and `consent_to :marketing`
14
+ # are different sentences because they are different acts with different
15
+ # lifecycles, and a developer choosing between them is doing the one piece
16
+ # of thinking this gem cannot do for them.
17
+ class PolicyBuilder
18
+ STATEMENT_OPTIONS = %i[
19
+ document
20
+ statement
21
+ label
22
+ link_label
23
+ choices
24
+ purpose
25
+ withdrawal_path
26
+ valid_for
27
+ requires
28
+ subject_fingerprint_with
29
+ subject_fingerprint_version
30
+ record_protected_outcome_with
31
+ protected_outcome_version
32
+ optional
33
+ require_an_explicit_choice
34
+ one_time
35
+ require_current_version
36
+ ].freeze
37
+ DEFAULT_RETENTION_SETTING_NAMES = {
38
+ ip_address: :delete_recorded_ip_addresses_after,
39
+ browser_user_agent: :delete_recorded_browser_user_agents_after,
40
+ ip_geolocation: :delete_recorded_ip_geolocation_after
41
+ }.freeze
42
+
43
+ def initialize(key)
44
+ @key = key.to_s
45
+ @statements = []
46
+ @retention_class_key = nil
47
+ @request_evidence = {}
48
+ @ip_geolocation_fields = {}
49
+ @persist_presentations_for = nil
50
+ @persist_presentations_because = nil
51
+ @capture_channels = nil
52
+ @locales = nil
53
+ @tenant_scope = "optional"
54
+ @options = {}
55
+ @authority_rule = nil
56
+ @ip_geolocation_resolver_name = nil
57
+ @review_request_evidence_configuration_on = nil
58
+ end
59
+
60
+ # --- The six verbs --------------------------------------------------------
61
+
62
+ # Assent to contractual terms.
63
+ def agree_to(statement_key, **options)
64
+ add_statement(statement_key, "agreement", options)
65
+ end
66
+
67
+ # Affirmative receipt or awareness of a notice or risk. This is not
68
+ # permission, and it is not consent: a privacy notice is information the
69
+ # person is entitled to, not something they agree to.
70
+ def acknowledge(statement_key, **options)
71
+ add_statement(statement_key, "acknowledgment", options)
72
+ end
73
+
74
+ # Purpose-specific permission, where the host has decided consent is the
75
+ # right basis for this processing. Clickwrap does not make that decision;
76
+ # it makes the grant, the withdrawal, and the scope demonstrable.
77
+ def consent_to(statement_key, **options)
78
+ add_statement(statement_key, "consent", options)
79
+ end
80
+
81
+ # A factual statement made by the actor. A declaration can expire without
82
+ # implying it was false when it was made.
83
+ def declare(statement_key, **options)
84
+ add_statement(statement_key, "declaration", options)
85
+ end
86
+
87
+ # An operational fact affirmed by an authorized actor, usually an
88
+ # operator rather than an end user.
89
+ def attest(statement_key, **options)
90
+ add_statement(statement_key, "attestation", options)
91
+ end
92
+
93
+ # Narrow permission bound to one protected action. This is the difference
94
+ # between "the user once accepted something" and "this exact evidence
95
+ # authorized this exact operation".
96
+ def authorize(statement_key, **options)
97
+ add_statement(statement_key, "authorization", options)
98
+ end
99
+
100
+ # --- Policy-level settings ------------------------------------------------
101
+
102
+ def retain_with(retention_class_key)
103
+ @retention_class_key = retention_class_key.to_s
104
+ end
105
+
106
+ # Keep the presentation manifest for renders that were never submitted.
107
+ # The default path writes nothing on GET; this trades that for a record
108
+ # of display attempts, which some high-assurance flows want. An abandoned
109
+ # GET is labeled `presented_by_server` — never `accepted`, and never
110
+ # `seen_by_human`.
111
+ def persist_presentations_before_submission_for(duration, because: nil, **unknown_options)
112
+ refuse_unknown_options!("persist_presentations_before_submission_for", unknown_options)
113
+ @persist_presentations_for = duration
114
+ @persist_presentations_because = because
115
+ end
116
+
117
+ # Restrict which capture channels this policy accepts. By default all are
118
+ # allowed and the channel is recorded; a policy that should only ever be
119
+ # completed in a browser can say so.
120
+ def only_capture_from(*channels)
121
+ @capture_channels = channels.flatten.map(&:to_s)
122
+ end
123
+
124
+ # Restrict the policy to locales it can actually present. A required
125
+ # legal statement should not fall back to a language nobody chose.
126
+ def only_present_in(*locales)
127
+ @locales = locales.flatten.map(&:to_s)
128
+ end
129
+
130
+ # Declares whether this policy is personal, tenant-bound, or deliberately
131
+ # usable in either context. The same policy-level decision is applied to
132
+ # presentation, capture, and verification, so ambient organization state
133
+ # cannot appear on only one side of a signed submission.
134
+ #
135
+ # tenant_is :not_applicable # personal evidence; ignore ambient tenant
136
+ # tenant_is :required # every call must resolve a tenant
137
+ # tenant_is :optional # either context is deliberate (the default)
138
+ def tenant_is(scope)
139
+ @tenant_scope = scope.to_s
140
+ end
141
+
142
+ # Allow explicitly recorded system exemptions for this policy. Even when
143
+ # allowed, an exemption never answers `agreed_to?` — it answers
144
+ # `exempted_from?`. There is no "missing checkbox means system account"
145
+ # inference anywhere in this gem.
146
+ def permit_exemptions(because: nil, **unknown_options)
147
+ refuse_unknown_options!("permit_exemptions", unknown_options)
148
+ @options[:permit_exemptions] = true
149
+ @options[:permit_exemptions_because] = because
150
+ end
151
+
152
+ # Allow an actor to act for a represented party (an employee signing for
153
+ # an organization, a guardian, a service account). The receipt keeps the
154
+ # authenticated principal, the asserted actor, and the represented party
155
+ # as three separate facts. Clickwrap does not decide whether the
156
+ # authority is sufficient.
157
+ def permit_acting_for(*represented_party_types, using: :host,
158
+ when_actor_is_at_least: nil, when_actor_has_permission: nil,
159
+ including_when_this_action_creates_the_represented_party: false,
160
+ **unknown_options)
161
+ refuse_unknown_options!("permit_acting_for", unknown_options)
162
+ if represented_party_types.compact.all? { |type| type.to_s.strip.empty? }
163
+ raise DefinitionError,
164
+ "permit_acting_for needs at least one represented-party class name. " \
165
+ "Name every type this policy permits so it cannot authorize an unexpected kind of record."
166
+ end
167
+
168
+ @options[:permit_acting_for] = true
169
+ @authority_rule = AuthorityRule.new(
170
+ represented_party_types: represented_party_types,
171
+ adapter_name: using,
172
+ minimum_role: when_actor_is_at_least,
173
+ required_permission: when_actor_has_permission,
174
+ allow_represented_party_creation:
175
+ including_when_this_action_creates_the_represented_party
176
+ )
177
+ end
178
+
179
+ # One-line integration with https://github.com/rameerez/organizations.
180
+ # The User remains the human actor; Organizations::Organization is the
181
+ # represented party. A policy must name at least one reviewed authority
182
+ # criterion rather than silently treating every member as able to bind it.
183
+ #
184
+ # permit_acting_for_organization when_actor_is_at_least: :admin
185
+ #
186
+ def permit_acting_for_organization(when_actor_is_at_least: nil,
187
+ when_actor_has_permission: nil,
188
+ including_when_this_action_creates_the_organization: false,
189
+ **unknown_options)
190
+ refuse_unknown_options!("permit_acting_for_organization", unknown_options)
191
+ if when_actor_is_at_least.nil? && when_actor_has_permission.nil?
192
+ raise DefinitionError,
193
+ "permit_acting_for_organization needs `when_actor_is_at_least:` or " \
194
+ "`when_actor_has_permission:`. Organization membership alone does not establish " \
195
+ "legal authority to bind the organization."
196
+ end
197
+
198
+ permit_acting_for(
199
+ "Organizations::Organization",
200
+ using: :organizations_membership,
201
+ when_actor_is_at_least:,
202
+ when_actor_has_permission:,
203
+ including_when_this_action_creates_the_represented_party:
204
+ including_when_this_action_creates_the_organization
205
+ )
206
+ end
207
+
208
+ # --- Request evidence -----------------------------------------------------
209
+
210
+ def record_ip_address(encrypted: nil, delete_after: nil, retain_until: nil,
211
+ fail_if_unavailable: false, because: nil, legal_basis_reference: nil,
212
+ **unknown_options)
213
+ refuse_unknown_options!("record_ip_address", unknown_options)
214
+ @request_evidence[:ip_address] = RequestEvidencePolicy::Setting.new(
215
+ record: true, encrypted:, delete_after:, retain_until:, fail_if_unavailable:,
216
+ because:, legal_basis_reference:
217
+ )
218
+ end
219
+
220
+ # A policy-level refusal wins over an application-wide default. This is a
221
+ # named method rather than `record: false`: a privacy-reducing decision
222
+ # should read unambiguously in review and must not be confused with an
223
+ # omitted option.
224
+ def do_not_record_ip_address
225
+ @request_evidence[:ip_address] = RequestEvidencePolicy::NOT_RECORDED
226
+ end
227
+
228
+ def record_browser_user_agent(encrypted: nil, delete_after: nil, retain_until: nil,
229
+ fail_if_unavailable: false, because: nil,
230
+ legal_basis_reference: nil, **unknown_options)
231
+ refuse_unknown_options!("record_browser_user_agent", unknown_options)
232
+ @request_evidence[:browser_user_agent] = RequestEvidencePolicy::Setting.new(
233
+ record: true, encrypted:, delete_after:, retain_until:, fail_if_unavailable:,
234
+ because:, legal_basis_reference:
235
+ )
236
+ end
237
+
238
+ def do_not_record_browser_user_agent
239
+ @request_evidence[:browser_user_agent] = RequestEvidencePolicy::NOT_RECORDED
240
+ end
241
+
242
+ # Each IP-geolocation data field is named separately, because each one is
243
+ # a separate decision about what to keep about a person's network
244
+ # context. `latitude_and_longitude` is one coupled choice: half a
245
+ # coordinate is not a result. Whatever is enabled, the provider name,
246
+ # source, estimated status, resolution time, and any accuracy or database
247
+ # provenance the resolver supplies are stored with it automatically — a
248
+ # policy cannot keep the coordinates and drop the uncertainty needed to
249
+ # read them.
250
+ def record_ip_geolocation(country: false, region: false, city: false, postal_code: false,
251
+ latitude_and_longitude: false, timezone: false, continent: false,
252
+ metro_code: false, accuracy_radius_in_kilometers: false,
253
+ using: nil, encrypted: nil, delete_after: nil, retain_until: nil,
254
+ fail_if_unavailable: false, because: nil,
255
+ legal_basis_reference: nil,
256
+ data_protection_impact_assessment_reference: nil,
257
+ **unknown_options)
258
+ refuse_unknown_options!("record_ip_geolocation", unknown_options)
259
+ @ip_geolocation_fields = {
260
+ "country" => country,
261
+ "region" => region,
262
+ "city" => city,
263
+ "postal_code" => postal_code,
264
+ "latitude_and_longitude" => latitude_and_longitude,
265
+ "timezone" => timezone,
266
+ "continent" => continent,
267
+ "metro_code" => metro_code,
268
+ "accuracy_radius_in_kilometers" => accuracy_radius_in_kilometers
269
+ }
270
+ @ip_geolocation_resolver_name = using
271
+
272
+ @request_evidence[:ip_geolocation] = RequestEvidencePolicy::Setting.new(
273
+ record: true, encrypted:, delete_after:, retain_until:, fail_if_unavailable:,
274
+ because:, legal_basis_reference:, data_protection_impact_assessment_reference:
275
+ )
276
+ end
277
+
278
+ def do_not_record_ip_geolocation
279
+ @request_evidence[:ip_geolocation] = RequestEvidencePolicy::NOT_RECORDED
280
+ @ip_geolocation_fields = {}
281
+ @ip_geolocation_resolver_name = nil
282
+ end
283
+
284
+ # A date by which someone should look at this policy's request-evidence
285
+ # configuration again. `clickwrap:doctor` reports policies that collect
286
+ # personal data without one, and policies whose date has passed.
287
+ def review_request_evidence_configuration_on(date)
288
+ @review_request_evidence_configuration_on = date
289
+ end
290
+
291
+ # --- Compilation ----------------------------------------------------------
292
+
293
+ def compile
294
+ Policy.new(
295
+ key: @key,
296
+ statements: @statements,
297
+ retention_class_key: @retention_class_key,
298
+ request_evidence: build_request_evidence_policy,
299
+ persist_presentations_for: @persist_presentations_for,
300
+ persist_presentations_because: @persist_presentations_because,
301
+ capture_channels: @capture_channels,
302
+ locales: @locales,
303
+ tenant_scope: @tenant_scope,
304
+ authority_rule: @authority_rule,
305
+ options: @options
306
+ )
307
+ end
308
+
309
+ private
310
+
311
+ def add_statement(statement_key, kind, options)
312
+ refuse_unknown_statement_options!(statement_key, options)
313
+ @statements << Statement.new(
314
+ key: statement_key,
315
+ kind: kind,
316
+ ordinal: @statements.length,
317
+ options: default_statement_options(statement_key, kind).merge(options)
318
+ )
319
+ end
320
+
321
+ # A statement's document defaults to a document with the same key, and
322
+ # its assertion defaults to a conventional I18n key. Both are overridable;
323
+ # the defaults exist so the five-minute path needs no boilerplate, not so
324
+ # that anything is guessed silently — a missing document or translation
325
+ # still fails loudly.
326
+ def default_statement_options(statement_key, kind)
327
+ {
328
+ document: statement_key,
329
+ statement: :"clickwrap.statements.#{kind}.#{statement_key}"
330
+ }
331
+ end
332
+
333
+ def build_request_evidence_policy
334
+ ip_address = resolved_request_evidence_setting(:ip_address)
335
+ browser_user_agent = resolved_request_evidence_setting(:browser_user_agent)
336
+ ip_geolocation = resolved_request_evidence_setting(:ip_geolocation)
337
+ geolocation_fields = resolved_ip_geolocation_fields(ip_geolocation)
338
+
339
+ RequestEvidencePolicy.new(
340
+ policy_key: @key,
341
+ retention_class_key: @retention_class_key,
342
+ ip_address:,
343
+ browser_user_agent:,
344
+ ip_geolocation:,
345
+ ip_geolocation_fields: geolocation_fields,
346
+ ip_geolocation_resolver_name: resolved_ip_geolocation_resolver_name(ip_geolocation),
347
+ trusted_proxy_configuration_digest: resolved_trusted_proxy_configuration_digest(
348
+ ip_address:, ip_geolocation:
349
+ ),
350
+ review_configuration_on: @review_request_evidence_configuration_on ||
351
+ Clickwrap.config.review_default_request_evidence_configuration_on
352
+ )
353
+ end
354
+
355
+ def resolved_request_evidence_setting(category)
356
+ return @request_evidence.fetch(category) if @request_evidence.key?(category)
357
+
358
+ application_default_setting(category)
359
+ end
360
+
361
+ def application_default_setting(category)
362
+ enabled = case category
363
+ when :ip_address then Clickwrap.config.record_ip_address_by_default
364
+ when :browser_user_agent then Clickwrap.config.record_browser_user_agent_by_default
365
+ else Clickwrap.config.enabled_default_ip_geolocation_fields.any?
366
+ end
367
+ return RequestEvidencePolicy::NOT_RECORDED unless enabled
368
+
369
+ RequestEvidencePolicy::Setting.new(
370
+ record: true,
371
+ encrypted: application_default_encryption(category),
372
+ delete_after: Clickwrap.config.public_send(
373
+ DEFAULT_RETENTION_SETTING_NAMES.fetch(category)
374
+ ),
375
+ fail_if_unavailable: category == :ip_geolocation &&
376
+ Clickwrap.config.fail_capture_when_ip_geolocation_is_unavailable,
377
+ because: application_default_reason(category),
378
+ legal_basis_reference: application_default_legal_basis_reference(category)
379
+ )
380
+ end
381
+
382
+ def application_default_encryption(category)
383
+ case category
384
+ when :ip_address then Clickwrap.config.encrypt_recorded_ip_addresses
385
+ when :browser_user_agent then Clickwrap.config.encrypt_recorded_browser_user_agents
386
+ else Clickwrap.config.encrypt_recorded_ip_geolocation
387
+ end
388
+ end
389
+
390
+ def application_default_reason(category)
391
+ case category
392
+ when :ip_address then Clickwrap.config.reason_for_recording_ip_addresses_by_default
393
+ when :browser_user_agent then Clickwrap.config.reason_for_recording_browser_user_agents_by_default
394
+ else Clickwrap.config.reason_for_recording_ip_geolocation_by_default
395
+ end
396
+ end
397
+
398
+ def application_default_legal_basis_reference(category)
399
+ case category
400
+ when :ip_address then Clickwrap.config.legal_basis_reference_for_recording_ip_addresses_by_default
401
+ when :browser_user_agent
402
+ Clickwrap.config.legal_basis_reference_for_recording_browser_user_agents_by_default
403
+ else Clickwrap.config.legal_basis_reference_for_recording_ip_geolocation_by_default
404
+ end
405
+ end
406
+
407
+ def resolved_ip_geolocation_fields(setting)
408
+ return {} unless setting.record?
409
+ return @ip_geolocation_fields if @request_evidence.key?(:ip_geolocation)
410
+
411
+ Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
412
+ [field, Clickwrap.config.public_send(:"record_ip_geolocation_#{field}_by_default")]
413
+ end
414
+ end
415
+
416
+ def resolved_ip_geolocation_resolver_name(setting)
417
+ return nil unless setting.record?
418
+
419
+ @ip_geolocation_resolver_name || :application_default
420
+ end
421
+
422
+ def resolved_trusted_proxy_configuration_digest(ip_address:, ip_geolocation:)
423
+ return nil unless ip_address.record? || ip_geolocation.record?
424
+
425
+ Clickwrap.config.trusted_proxy_configuration_digest
426
+ end
427
+
428
+ # No table of former spellings. `documents:`, `assertion:`, and
429
+ # `link_labels:` were renamed before this gem was ever released, so there
430
+ # is no application anywhere that wrote them — and carrying a migration
431
+ # note for a migration nobody had to make invents a history the gem does
432
+ # not have. The full list of supported options is the answer.
433
+ def refuse_unknown_statement_options!(statement_key, options)
434
+ unknown = options.keys.map(&:to_sym) - STATEMENT_OPTIONS
435
+ return if unknown.empty?
436
+
437
+ raise DefinitionError,
438
+ "Statement #{statement_key} in policy #{@key} has unknown option#{"s" if unknown.many?} " \
439
+ "#{unknown.map { |option| "`#{option}:`" }.join(", ")}. " \
440
+ "Supported options are: #{STATEMENT_OPTIONS.map { |option| "`#{option}:`" }.join(", ")}. " \
441
+ "Clickwrap never ignores policy options."
442
+ end
443
+
444
+ def refuse_unknown_options!(method_name, options)
445
+ return if options.empty?
446
+
447
+ raise DefinitionError,
448
+ "Policy #{@key} calls `#{method_name}` with unknown option" \
449
+ "#{"s" if options.many?} #{options.keys.map { |option| "`#{option}:`" }.join(", ")}. " \
450
+ "Check the spelling; Clickwrap never ignores policy options."
451
+ end
452
+
453
+ def method_missing(name, *_arguments, **_options)
454
+ raise DefinitionError,
455
+ "Policy #{@key} calls unknown DSL method `#{name}`. Check the spelling; " \
456
+ "Clickwrap never ignores policy declarations."
457
+ end
458
+
459
+ def respond_to_missing?(_name, _include_private = false) = false
460
+ end
461
+ end
462
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module DSL
5
+ # The block passed to `Clickwrap.retention`.
6
+ #
7
+ # Clickwrap.retention :ordinary_agreement_evidence do
8
+ # retain_core_event_for 6.years
9
+ # delete_recorded_ip_address_after 90.days
10
+ # delete_recorded_browser_user_agent_after 90.days
11
+ # delete_recorded_ip_geolocation_after 90.days
12
+ # end
13
+ #
14
+ # Two vocabularies on purpose. `retain_..._for` and `retain_..._until` say
15
+ # how long evidence is kept; `delete_..._after` says when personal request
16
+ # evidence goes away. They read differently because they are different
17
+ # intentions, and the second one is the destructive one.
18
+ class RetentionBuilder
19
+ def initialize(key)
20
+ @key = key.to_s
21
+ @rules = {}
22
+ end
23
+
24
+ # --- The core event -------------------------------------------------------
25
+
26
+ def retain_core_event_for(duration)
27
+ assign_rule!(:core_event, duration:)
28
+ end
29
+
30
+ # For obligations a duration cannot express — "five years, or three years
31
+ # after this contract is liquidated, whichever is later". The named
32
+ # calculation is registered by the host on the configuration object, and
33
+ # it may legitimately return nil while the triggering event has not
34
+ # happened, in which case the record is simply not due yet.
35
+ def retain_core_event_until(host_event_name)
36
+ assign_rule!(:core_event, host_event_name:)
37
+ end
38
+
39
+ # --- Optional request evidence -------------------------------------------
40
+
41
+ def delete_recorded_ip_address_after(duration)
42
+ assign_rule!(:ip_address, duration:)
43
+ end
44
+
45
+ def delete_recorded_browser_user_agent_after(duration)
46
+ assign_rule!(:browser_user_agent, duration:)
47
+ end
48
+
49
+ def delete_recorded_ip_geolocation_after(duration)
50
+ assign_rule!(:ip_geolocation, duration:)
51
+ end
52
+
53
+ def retain_recorded_ip_address_until(host_event_name)
54
+ assign_rule!(:ip_address, host_event_name:)
55
+ end
56
+
57
+ def retain_recorded_browser_user_agent_until(host_event_name)
58
+ assign_rule!(:browser_user_agent, host_event_name:)
59
+ end
60
+
61
+ def retain_recorded_ip_geolocation_until(host_event_name)
62
+ assign_rule!(:ip_geolocation, host_event_name:)
63
+ end
64
+
65
+ def compile = RetentionClass.new(key: @key, rules: @rules)
66
+
67
+ private
68
+
69
+ def assign_rule!(part, duration: nil, host_event_name: nil)
70
+ if @rules.key?(part)
71
+ raise DefinitionError,
72
+ "Retention class #{@key} declares #{part} more than once. Keep one reviewed " \
73
+ "rule for each part; Clickwrap will not let line order silently replace a " \
74
+ "deletion deadline."
75
+ end
76
+
77
+ @rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:)
78
+ end
79
+
80
+ def method_missing(name, *_arguments, **_options)
81
+ raise DefinitionError,
82
+ "Retention class #{@key} calls unknown DSL method `#{name}`. Check the spelling; " \
83
+ "Clickwrap never ignores retention declarations."
84
+ end
85
+
86
+ def respond_to_missing?(_name, _include_private = false) = false
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Rails normally defers a record's `after_commit` callback to the outermost
5
+ # joinable transaction. A non-joinable outer transaction can instead make a
6
+ # nested savepoint run that callback while the outer transaction is still
7
+ # capable of rolling everything back. This tiny transaction record moves
8
+ # Clickwrap's externally visible work to that real outer boundary.
9
+ #
10
+ # The transaction-record protocol is stable across every supported Rails
11
+ # version (7.1 through 8.x), while the public
12
+ # `ActiveRecord.after_all_transactions_commit` helper only exists from 7.2
13
+ # and deliberately ignores non-joinable transactions.
14
+ class DurableCommitCallback
15
+ def self.defer(event)
16
+ callback = new(event)
17
+ ::ActiveRecord::Base.connection.add_transaction_record(callback)
18
+ callback
19
+ end
20
+
21
+ def initialize(event)
22
+ @event = event
23
+ end
24
+
25
+ def before_committed!; end
26
+
27
+ def committed!(should_run_callbacks: true)
28
+ @event.finalize_durable_commit! if should_run_callbacks
29
+ end
30
+
31
+ def rolledback!(force_restore_state: false, should_run_callbacks: true)
32
+ @event.invalidate_pending_receipts_after_rollback! if should_run_callbacks
33
+ end
34
+
35
+ def trigger_transactional_callbacks? = true
36
+ end
37
+ end