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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,1129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Clickwrap
|
|
6
|
+
# The single configuration object the host populates in
|
|
7
|
+
# `config/initializers/clickwrap.rb` via `Clickwrap.configure do |config| ... end`.
|
|
8
|
+
#
|
|
9
|
+
# Design rules:
|
|
10
|
+
# - Runtime adapters and class names are read at the point of use. Policy
|
|
11
|
+
# semantics — including merged request-evidence defaults — are copied into
|
|
12
|
+
# the immutable compiled policy revision at boot, so changing this object
|
|
13
|
+
# cannot mutate a form that was already rendered.
|
|
14
|
+
# - Class names are stored as strings and constantized lazily, so the
|
|
15
|
+
# initializer works no matter when the app loads (the User model may not
|
|
16
|
+
# exist yet at boot).
|
|
17
|
+
# - Validating setters normalize their input and raise a plain-English
|
|
18
|
+
# ConfigurationError on a bad value — failing at the assignment line
|
|
19
|
+
# rather than at 3am with a NoMethodError. `validate!` runs once more at
|
|
20
|
+
# the end of `configure` for the cross-field checks.
|
|
21
|
+
# - Every hook defaults to a no-op, so the gem works untouched and a host
|
|
22
|
+
# wires hooks only as needed.
|
|
23
|
+
# - Nothing here collects personal data by default. Every `record_*` flag
|
|
24
|
+
# starts false, and turning one on without a purpose and a retention
|
|
25
|
+
# decision is a configuration error, not a warning.
|
|
26
|
+
#
|
|
27
|
+
# One setting deserves its own note: there is deliberately no
|
|
28
|
+
# `gdpr_compliant_mode`, `maximum_evidence`, `full_evidence`, or
|
|
29
|
+
# `legal_proof`. An option that silently enables a category of personal data
|
|
30
|
+
# is exactly the thing this gem exists not to do, and no runtime flag can
|
|
31
|
+
# make a legal determination on your behalf.
|
|
32
|
+
class Configuration
|
|
33
|
+
DOCUMENT_STORES = %i[database active_storage resolver].freeze
|
|
34
|
+
DIGEST_ALGORITHMS = %i[sha256 sha384 sha512].freeze
|
|
35
|
+
|
|
36
|
+
# The readers are grouped by section on purpose, and kept that way even
|
|
37
|
+
# though rubocop would happily collapse them into one line. The initializer
|
|
38
|
+
# this class backs is the main thing a host reads about Clickwrap, and the
|
|
39
|
+
# shape of these groups is the shape of that file.
|
|
40
|
+
#
|
|
41
|
+
# --- Identity -------------------------------------------------------------
|
|
42
|
+
attr_reader :actor_class_name, :current_actor_method_name, :parent_controller_class_name
|
|
43
|
+
attr_reader :find_current_tenant_with, :identify_actor_with, :snapshot_actor_with
|
|
44
|
+
attr_reader :describe_authentication_with
|
|
45
|
+
|
|
46
|
+
# --- Documents and policies -----------------------------------------------
|
|
47
|
+
attr_reader :store_document_contents_in, :document_renderer, :document_resolver
|
|
48
|
+
attr_reader :document_link_html_options_with
|
|
49
|
+
attr_reader :hotwire_native_document_links
|
|
50
|
+
attr_reader :publish_documents_after_database_preparation
|
|
51
|
+
attr_accessor :policy_paths
|
|
52
|
+
|
|
53
|
+
attr_reader :raise_on_missing_translation
|
|
54
|
+
|
|
55
|
+
# --- Development aids -----------------------------------------------------
|
|
56
|
+
attr_accessor :lint_presentations
|
|
57
|
+
|
|
58
|
+
# --- Presentation ---------------------------------------------------------
|
|
59
|
+
attr_reader :presentation_valid_for
|
|
60
|
+
attr_reader :remediation_token_valid_for
|
|
61
|
+
|
|
62
|
+
# --- Integrity ------------------------------------------------------------
|
|
63
|
+
attr_reader :digest_canonical_receipts_with, :chain_event_history_with
|
|
64
|
+
attr_reader :anchor_event_history_with, :timestamp_receipts_with
|
|
65
|
+
attr_reader :application_version, :template_version
|
|
66
|
+
|
|
67
|
+
# --- Authorization --------------------------------------------------------
|
|
68
|
+
attr_reader :authorize_receipt_access_with, :authorize_unredacted_request_evidence_access_with
|
|
69
|
+
attr_reader :verify_actor_can_act_for_represented_party_with
|
|
70
|
+
attr_reader :authorize_clickwrap_remediation_subject_with
|
|
71
|
+
attr_reader :authorize_clickwrap_remediation_represented_party_with
|
|
72
|
+
|
|
73
|
+
# --- Request evidence: what is recorded by default ------------------------
|
|
74
|
+
#
|
|
75
|
+
# One reader per IP-geolocation field, spelled out rather than generated,
|
|
76
|
+
# because each is a separate decision about what to keep about someone's
|
|
77
|
+
# network context and each should be greppable by its own name.
|
|
78
|
+
attr_reader :record_ip_address_by_default, :record_browser_user_agent_by_default
|
|
79
|
+
attr_reader :record_ip_geolocation_country_by_default,
|
|
80
|
+
:record_ip_geolocation_region_by_default,
|
|
81
|
+
:record_ip_geolocation_city_by_default,
|
|
82
|
+
:record_ip_geolocation_postal_code_by_default,
|
|
83
|
+
:record_ip_geolocation_latitude_and_longitude_by_default,
|
|
84
|
+
:record_ip_geolocation_timezone_by_default,
|
|
85
|
+
:record_ip_geolocation_continent_by_default,
|
|
86
|
+
:record_ip_geolocation_metro_code_by_default,
|
|
87
|
+
:record_ip_geolocation_accuracy_radius_in_kilometers_by_default
|
|
88
|
+
|
|
89
|
+
# --- Request evidence: why, how long, and how it is protected -------------
|
|
90
|
+
attr_accessor :reason_for_recording_ip_addresses_by_default,
|
|
91
|
+
:reason_for_recording_browser_user_agents_by_default,
|
|
92
|
+
:reason_for_recording_ip_geolocation_by_default,
|
|
93
|
+
:legal_basis_reference_for_recording_ip_addresses_by_default,
|
|
94
|
+
:legal_basis_reference_for_recording_browser_user_agents_by_default,
|
|
95
|
+
:legal_basis_reference_for_recording_ip_geolocation_by_default,
|
|
96
|
+
:review_default_request_evidence_configuration_on
|
|
97
|
+
|
|
98
|
+
attr_reader :encrypt_recorded_ip_addresses, :encrypt_recorded_browser_user_agents,
|
|
99
|
+
:encrypt_recorded_ip_geolocation
|
|
100
|
+
attr_reader :delete_recorded_ip_addresses_after, :delete_recorded_browser_user_agents_after,
|
|
101
|
+
:delete_recorded_ip_geolocation_after
|
|
102
|
+
attr_reader :read_ip_address_from_http_request_with, :read_browser_user_agent_from_http_request_with
|
|
103
|
+
attr_reader :ip_geolocation_resolver, :fail_capture_when_ip_geolocation_is_unavailable
|
|
104
|
+
attr_reader :trusted_proxy_configuration_digest, :reason_for_storing_request_evidence_unencrypted
|
|
105
|
+
attr_reader :find_request_evidence_binding_key_with
|
|
106
|
+
|
|
107
|
+
# --- Hooks ----------------------------------------------------------------
|
|
108
|
+
attr_reader :after_event_is_committed, :report_after_commit_failure_with
|
|
109
|
+
|
|
110
|
+
def initialize
|
|
111
|
+
# Identity. "User" is the overwhelmingly common case; the host overrides
|
|
112
|
+
# it if their actor model is "Account", "Member", or something else.
|
|
113
|
+
@actor_class_name = "User"
|
|
114
|
+
@current_actor_method_name = :current_user
|
|
115
|
+
@parent_controller_class_name = "ApplicationController"
|
|
116
|
+
@find_current_tenant_with = ->(_controller) {}
|
|
117
|
+
|
|
118
|
+
# How an actor is referenced in evidence. Prefer a host override, then a
|
|
119
|
+
# GlobalID when available, then a stable class/id string in minimal Rails.
|
|
120
|
+
# The resulting string survives row deletion instead of becoming a
|
|
121
|
+
# cascading foreign-key loss.
|
|
122
|
+
@identify_actor_with = ->(actor) { default_actor_reference(actor) }
|
|
123
|
+
|
|
124
|
+
# Actor snapshots contain only what the host names. Clickwrap never
|
|
125
|
+
# serializes a whole user into evidence: a receipt should carry the
|
|
126
|
+
# fields someone reviewed and chose, not every column that happened to
|
|
127
|
+
# exist on the day it was written.
|
|
128
|
+
@snapshot_actor_with = ->(_actor) { {} }
|
|
129
|
+
|
|
130
|
+
# An honest default: when the controller can name a current actor, the
|
|
131
|
+
# request ran under an application-authenticated session; when it cannot
|
|
132
|
+
# — a signup form, a public capture screen, a controller with no
|
|
133
|
+
# authentication at all — nothing is claimed. The temptation this guards
|
|
134
|
+
# against is describing every request as authenticated merely because it
|
|
135
|
+
# passed through ApplicationController. Deliberately gentler than the
|
|
136
|
+
# capture path's actor resolution: describing authentication is context,
|
|
137
|
+
# not identity, so a missing actor method here means "nothing to
|
|
138
|
+
# describe", never an error.
|
|
139
|
+
configuration = self
|
|
140
|
+
@describe_authentication_with = lambda do |controller|
|
|
141
|
+
method_name = configuration.current_actor_method_name
|
|
142
|
+
actor = controller.respond_to?(method_name, true) ? controller.send(method_name) : nil
|
|
143
|
+
actor ? { method: :authenticated_session } : {}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Documents and policies.
|
|
147
|
+
@store_document_contents_in = :database
|
|
148
|
+
@document_renderer = DocumentRenderer.new
|
|
149
|
+
@document_resolver = nil
|
|
150
|
+
@document_link_html_options_with = ->(_document) { { target: "_blank", rel: "noopener" } }
|
|
151
|
+
@hotwire_native_document_links = nil
|
|
152
|
+
@policy_paths = ["config/clickwrap.rb", "config/clickwrap/*.rb"]
|
|
153
|
+
|
|
154
|
+
# Publishing rides `db:prepare`, so the deploy step everyone forgets
|
|
155
|
+
# does not exist: by the time the server takes traffic, every declared
|
|
156
|
+
# document version has an immutable snapshot. Idempotent — an
|
|
157
|
+
# already-published version is left untouched — and a publish refusal
|
|
158
|
+
# (a reused label over changed bytes) fails the deploy loudly, which is
|
|
159
|
+
# strictly better than signups failing quietly later.
|
|
160
|
+
@publish_documents_after_database_preparation = true
|
|
161
|
+
|
|
162
|
+
# A required legal statement with no translation is not presentable. Fail
|
|
163
|
+
# rather than show a raw I18n key, a blank, or an unexpected language.
|
|
164
|
+
@raise_on_missing_translation = true
|
|
165
|
+
|
|
166
|
+
# nil means "decide from the environment": on in development and test,
|
|
167
|
+
# off everywhere else. `true` and `false` answer for a host that
|
|
168
|
+
# disagrees with either half — a linter nobody can turn off is a warning
|
|
169
|
+
# people learn to scroll past.
|
|
170
|
+
@lint_presentations = nil
|
|
171
|
+
|
|
172
|
+
# Presentation manifests are short-lived by design: they bind a render to
|
|
173
|
+
# a submit, and a token that stayed valid for days would weaken exactly
|
|
174
|
+
# the substitution check it exists to make.
|
|
175
|
+
@presentation_valid_for = 2.hours
|
|
176
|
+
@remediation_token_valid_for = 2.hours
|
|
177
|
+
|
|
178
|
+
# Integrity. The baseline detects accidental or ordinary mutation of the
|
|
179
|
+
# verified bytes. Chains, anchors, and third-party timestamps are separate,
|
|
180
|
+
# explicitly enabled tiers, and each one claims only what it supplies.
|
|
181
|
+
@digest_canonical_receipts_with = :sha256
|
|
182
|
+
@chain_event_history_with = nil
|
|
183
|
+
@anchor_event_history_with = nil
|
|
184
|
+
@timestamp_receipts_with = nil
|
|
185
|
+
@application_version = -> {}
|
|
186
|
+
@template_version = -> {}
|
|
187
|
+
|
|
188
|
+
# Authorization. Actors can read their own receipts; anything wider is
|
|
189
|
+
# the host's decision, and unredacted request evidence needs a reason.
|
|
190
|
+
@authorize_receipt_access_with = ->(_controller, _receipt) { false }
|
|
191
|
+
@authorize_unredacted_request_evidence_access_with = ->(_controller, _receipt, _because) { false }
|
|
192
|
+
@verify_actor_can_act_for_represented_party_with = lambda do |actor:, represented_party:, policy:,
|
|
193
|
+
authentication_context:, tenant:|
|
|
194
|
+
AuthorityDecision.new(authorized: false)
|
|
195
|
+
end
|
|
196
|
+
@authorize_clickwrap_remediation_subject_with = lambda do |actor:, subject:, policy:, controller:|
|
|
197
|
+
subject.nil?
|
|
198
|
+
end
|
|
199
|
+
@authorize_clickwrap_remediation_represented_party_with =
|
|
200
|
+
lambda do |actor:, represented_party:, policy:, controller:|
|
|
201
|
+
represented_party.nil?
|
|
202
|
+
end
|
|
203
|
+
@remediation_subject_authorization_configured = false
|
|
204
|
+
@remediation_represented_party_authorization_configured = false
|
|
205
|
+
@represented_party_authority_adapters = {
|
|
206
|
+
"organizations_membership" => Integrations::OrganizationsAuthority.new
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Request evidence. Every one of these is false, and that is the whole
|
|
210
|
+
# point. Recording an IP address is a decision with consequences; the
|
|
211
|
+
# library will not make it silently on a host's behalf.
|
|
212
|
+
@record_ip_address_by_default = false
|
|
213
|
+
@record_browser_user_agent_by_default = false
|
|
214
|
+
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.each do |field|
|
|
215
|
+
instance_variable_set(:"@record_ip_geolocation_#{field}_by_default", false)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@reason_for_recording_ip_addresses_by_default = nil
|
|
219
|
+
@reason_for_recording_browser_user_agents_by_default = nil
|
|
220
|
+
@reason_for_recording_ip_geolocation_by_default = nil
|
|
221
|
+
@legal_basis_reference_for_recording_ip_addresses_by_default = nil
|
|
222
|
+
@legal_basis_reference_for_recording_browser_user_agents_by_default = nil
|
|
223
|
+
@legal_basis_reference_for_recording_ip_geolocation_by_default = nil
|
|
224
|
+
@review_default_request_evidence_configuration_on = nil
|
|
225
|
+
|
|
226
|
+
@encrypt_recorded_ip_addresses = true
|
|
227
|
+
@encrypt_recorded_browser_user_agents = true
|
|
228
|
+
@encrypt_recorded_ip_geolocation = true
|
|
229
|
+
|
|
230
|
+
# nil means "every policy that enables the field must supply its own
|
|
231
|
+
# rule". There is no keep-forever default anywhere in this gem.
|
|
232
|
+
@delete_recorded_ip_addresses_after = nil
|
|
233
|
+
@delete_recorded_browser_user_agents_after = nil
|
|
234
|
+
@delete_recorded_ip_geolocation_after = nil
|
|
235
|
+
|
|
236
|
+
# Rails' request.remote_ip is the conventional reader. The host remains
|
|
237
|
+
# responsible for configuring and testing trusted proxies correctly:
|
|
238
|
+
# https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html
|
|
239
|
+
@read_ip_address_from_http_request_with = ->(http_request) { http_request.remote_ip }
|
|
240
|
+
@read_browser_user_agent_from_http_request_with = ->(http_request) { http_request.user_agent }
|
|
241
|
+
@trusted_proxy_configuration_digest = nil
|
|
242
|
+
|
|
243
|
+
@ip_geolocation_resolver = nil
|
|
244
|
+
@ip_geolocation_resolvers = {}
|
|
245
|
+
@fail_capture_when_ip_geolocation_is_unavailable = false
|
|
246
|
+
|
|
247
|
+
# The keyed annex digest carries a key ID so a host can rotate keys
|
|
248
|
+
# without making old annexes unverifiable. The default derives one key
|
|
249
|
+
# from Rails' key generator and names it by a non-secret fingerprint.
|
|
250
|
+
# Production hosts with explicit key rotation can replace both settings
|
|
251
|
+
# with a credentials-backed keyring.
|
|
252
|
+
@current_request_evidence_binding_key_id = nil
|
|
253
|
+
@find_request_evidence_binding_key_with = lambda do |requested_key_id|
|
|
254
|
+
key = default_request_evidence_binding_key
|
|
255
|
+
expected_id = default_request_evidence_binding_key_id(key)
|
|
256
|
+
key if key && Digest.secure_compare?(requested_key_id.to_s, expected_id.to_s)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Hooks. These run only after required evidence and domain state have
|
|
260
|
+
# committed, and a failure here is reported but can never undo the
|
|
261
|
+
# committed action.
|
|
262
|
+
@after_event_is_committed = ->(_event) {}
|
|
263
|
+
@report_after_commit_failure_with = ->(_error, _event) {}
|
|
264
|
+
|
|
265
|
+
# Host-registered retention calculations, keyed by the name a retention
|
|
266
|
+
# class refers to.
|
|
267
|
+
@retention_time_calculators = {}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- Identity setters -----------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def actor_class_name=(value)
|
|
273
|
+
@actor_class_name = ensure_class_name(value, "actor_class_name")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def current_actor_method_name=(value)
|
|
277
|
+
@current_actor_method_name = ensure_present_symbol(value, "current_actor_method_name")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def parent_controller_class_name=(value)
|
|
281
|
+
@parent_controller_class_name = ensure_class_name(value, "parent_controller_class_name")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def find_current_tenant_with=(value)
|
|
285
|
+
@find_current_tenant_with = ensure_callable(value, "find_current_tenant_with")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def identify_actor_with=(value)
|
|
289
|
+
@identify_actor_with = ensure_callable(value, "identify_actor_with")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def snapshot_actor_with=(value)
|
|
293
|
+
@snapshot_actor_with = ensure_callable(value, "snapshot_actor_with")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def describe_authentication_with=(value)
|
|
297
|
+
@describe_authentication_with = ensure_callable(value, "describe_authentication_with")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# The constantized actor class, resolved lazily on first use. Lazy on
|
|
301
|
+
# purpose: the initializer that sets `config.actor_class_name = "User"` runs
|
|
302
|
+
# before the User model is necessarily loaded.
|
|
303
|
+
def actor_class
|
|
304
|
+
name = actor_class_name
|
|
305
|
+
if @actor_class.nil? || @actor_class_name_at_resolution != name
|
|
306
|
+
@actor_class = name.constantize
|
|
307
|
+
@actor_class_name_at_resolution = name
|
|
308
|
+
end
|
|
309
|
+
@actor_class
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def parent_controller_class = parent_controller_class_name.constantize
|
|
313
|
+
|
|
314
|
+
# --- Document and policy setters -----------------------------------------
|
|
315
|
+
|
|
316
|
+
def store_document_contents_in=(value)
|
|
317
|
+
normalized = value.to_s.to_sym
|
|
318
|
+
unless DOCUMENT_STORES.include?(normalized)
|
|
319
|
+
raise ConfigurationError,
|
|
320
|
+
"store_document_contents_in must be one of #{DOCUMENT_STORES.inspect}, " \
|
|
321
|
+
"got #{value.inspect}. Whichever you choose, the adapter has to return immutable " \
|
|
322
|
+
"bytes plus a verifiable digest — a URL alone is never a document version."
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
@store_document_contents_in = normalized
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Chooses how document bytes become the representation people are offered.
|
|
329
|
+
# `:safe_text` (the default) escapes everything into a faithful <pre>
|
|
330
|
+
# block; `:markdown` renders real HTML through whichever Markdown library
|
|
331
|
+
# the application already bundles (commonmarker, redcarpet, or kramdown —
|
|
332
|
+
# no new dependency); `:markdown_rails` renders through the application's
|
|
333
|
+
# OWN registered markdown-rails renderer — the exact pipeline its public
|
|
334
|
+
# `.md` pages already go through — so the stored snapshot is byte-identical
|
|
335
|
+
# to the page readers see, by construction. A custom renderer object must
|
|
336
|
+
# return the exact rendered bytes it offered, because Clickwrap stores
|
|
337
|
+
# their digest alongside the original source digest. That is what preserves
|
|
338
|
+
# the difference between "this Markdown file existed" and "this rendered
|
|
339
|
+
# representation was offered".
|
|
340
|
+
def document_renderer=(value)
|
|
341
|
+
@document_renderer =
|
|
342
|
+
case value
|
|
343
|
+
when nil then nil
|
|
344
|
+
when :markdown then DocumentRenderers::Markdown.new
|
|
345
|
+
when :markdown_rails then DocumentRenderers::MarkdownRails.new
|
|
346
|
+
when :safe_text then DocumentRenderer.new
|
|
347
|
+
when Symbol
|
|
348
|
+
raise ConfigurationError,
|
|
349
|
+
"document_renderer accepts :safe_text, :markdown, :markdown_rails, or an " \
|
|
350
|
+
"object responding to call(bytes, definition) — not #{value.inspect}."
|
|
351
|
+
else ensure_callable(value, "document_renderer")
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def document_resolver=(value)
|
|
356
|
+
@document_resolver = value.nil? ? nil : ensure_callable(value, "document_resolver")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Navigation attributes only; the immutable href is signed into the
|
|
360
|
+
# presentation and cannot be replaced through this styling/client hook.
|
|
361
|
+
def document_link_html_options_with=(value)
|
|
362
|
+
@document_link_html_options_with = ensure_callable(value, "document_link_html_options_with")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
HOTWIRE_NATIVE_DOCUMENT_LINK_MODES = %i[external_browser same_screen].freeze
|
|
366
|
+
|
|
367
|
+
# One declarative answer for how document links behave inside a Hotwire
|
|
368
|
+
# Native app, doing both halves coherently — the signed href AND the
|
|
369
|
+
# navigation attributes:
|
|
370
|
+
#
|
|
371
|
+
# config.hotwire_native_document_links = {
|
|
372
|
+
# open_in: :external_browser,
|
|
373
|
+
# canonical_host: "https://www.example.com"
|
|
374
|
+
# }
|
|
375
|
+
#
|
|
376
|
+
# `:external_browser` absolutizes every document link against your
|
|
377
|
+
# canonical host and opens it outside the WebView — the right answer when
|
|
378
|
+
# clickwraps render on native AUTH SHEETS, where a same-host navigation
|
|
379
|
+
# pops the sheet and loses the half-filled form behind it. `:same_screen`
|
|
380
|
+
# leaves plain same-host links for your native path configuration to route
|
|
381
|
+
# (a modal document sheet inside a signed-in funnel, for example).
|
|
382
|
+
#
|
|
383
|
+
# When set, this answers native renders entirely; your
|
|
384
|
+
# `document_link_html_options_with` hook continues to answer everything
|
|
385
|
+
# else.
|
|
386
|
+
#
|
|
387
|
+
# `open_in:` also takes a callable, for an app whose native screens need
|
|
388
|
+
# different answers in different places — the auth sheet that must escape
|
|
389
|
+
# the WebView, the signed-in funnel that routes a document sheet itself:
|
|
390
|
+
#
|
|
391
|
+
# config.hotwire_native_document_links = {
|
|
392
|
+
# open_in: ->(controller) { controller.signing_up? ? :external_browser : :same_screen },
|
|
393
|
+
# canonical_host: "https://www.example.com"
|
|
394
|
+
# }
|
|
395
|
+
#
|
|
396
|
+
# It is asked twice per document link — once when the href is signed into
|
|
397
|
+
# the presentation, once when the link's attributes are rendered — and is
|
|
398
|
+
# handed the same controller both times, so it must answer from the
|
|
399
|
+
# request rather than from anything that changes between those moments.
|
|
400
|
+
# A callable `open_in:` needs `canonical_host:`, because nothing at boot
|
|
401
|
+
# can rule out its answering `:external_browser`.
|
|
402
|
+
def hotwire_native_document_links=(value)
|
|
403
|
+
if value.nil?
|
|
404
|
+
@hotwire_native_document_links = nil
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
unless value.is_a?(Hash)
|
|
409
|
+
raise ConfigurationError,
|
|
410
|
+
"hotwire_native_document_links takes nil or a Hash like " \
|
|
411
|
+
"{ open_in: :external_browser, canonical_host: \"https://www.example.com\" }."
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
options = value.symbolize_keys
|
|
415
|
+
open_in = options[:open_in]
|
|
416
|
+
unless HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.include?(open_in) || open_in.respond_to?(:call)
|
|
417
|
+
raise ConfigurationError,
|
|
418
|
+
"hotwire_native_document_links needs `open_in:` as one of " \
|
|
419
|
+
"#{HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.map(&:inspect).join(" or ")}, or a callable " \
|
|
420
|
+
"answering one of them per request — got #{open_in.inspect}."
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
unknown = options.keys - %i[open_in canonical_host]
|
|
424
|
+
unless unknown.empty?
|
|
425
|
+
raise ConfigurationError,
|
|
426
|
+
"hotwire_native_document_links has unknown option#{"s" if unknown.many?} " \
|
|
427
|
+
"#{unknown.map(&:inspect).join(", ")}. Supported options are :open_in and " \
|
|
428
|
+
":canonical_host."
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
canonical_host = options[:canonical_host]
|
|
432
|
+
if open_in == :external_browser || open_in.respond_to?(:call)
|
|
433
|
+
if canonical_host.nil?
|
|
434
|
+
raise ConfigurationError,
|
|
435
|
+
"hotwire_native_document_links with open_in: " \
|
|
436
|
+
"#{open_in.respond_to?(:call) ? "a callable" : ":external_browser"} needs a " \
|
|
437
|
+
"`canonical_host:` — the absolute https host the external browser opens, for " \
|
|
438
|
+
"example \"https://www.example.com\". A relative link would land back inside " \
|
|
439
|
+
"the WebView this setting exists to escape#{if open_in.respond_to?(:call)
|
|
440
|
+
", and nothing at boot can rule " \
|
|
441
|
+
"out a callable answering :external_browser"
|
|
442
|
+
end}."
|
|
443
|
+
end
|
|
444
|
+
validate_hotwire_native_canonical_host!(canonical_host) unless canonical_host.respond_to?(:call)
|
|
445
|
+
elsif canonical_host
|
|
446
|
+
raise ConfigurationError,
|
|
447
|
+
"hotwire_native_document_links with open_in: :same_screen keeps ordinary " \
|
|
448
|
+
"same-host links, so `canonical_host:` has no meaning there. Remove it, or use " \
|
|
449
|
+
"open_in: :external_browser."
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
@hotwire_native_document_links = { open_in: open_in, canonical_host: canonical_host }.freeze
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# The mode this render should use, resolved at use time so a callable can
|
|
456
|
+
# answer per request or per screen. `context` is the controller handling
|
|
457
|
+
# the request; both the href and the link attributes are resolved from the
|
|
458
|
+
# same one, so the two halves of a document link cannot disagree.
|
|
459
|
+
def hotwire_native_document_link_mode(context = nil)
|
|
460
|
+
configured = hotwire_native_document_links&.fetch(:open_in, nil)
|
|
461
|
+
return configured unless configured.respond_to?(:call)
|
|
462
|
+
|
|
463
|
+
resolved = (configured.arity.zero? ? configured.call : configured.call(context))&.to_sym
|
|
464
|
+
|
|
465
|
+
unless HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.include?(resolved)
|
|
466
|
+
raise ConfigurationError,
|
|
467
|
+
"hotwire_native_document_links `open_in:` answered #{resolved.inspect}. A callable " \
|
|
468
|
+
"there must answer #{HOTWIRE_NATIVE_DOCUMENT_LINK_MODES.map(&:inspect).join(" or ")} " \
|
|
469
|
+
"on every request — there is no third way for a document link to open."
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
resolved
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# The canonical host, resolved and validated at use time so a host that is
|
|
476
|
+
# only knowable after boot (application config, credentials) can be a
|
|
477
|
+
# callable. Trailing slashes are trimmed because the engine path this
|
|
478
|
+
# prefixes always begins with one.
|
|
479
|
+
def hotwire_native_canonical_host
|
|
480
|
+
configured = hotwire_native_document_links&.fetch(:canonical_host, nil)
|
|
481
|
+
return nil if configured.nil?
|
|
482
|
+
|
|
483
|
+
resolved = configured.respond_to?(:call) ? configured.call.to_s : configured.to_s
|
|
484
|
+
validate_hotwire_native_canonical_host!(resolved)
|
|
485
|
+
resolved.chomp("/")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def validate_hotwire_native_canonical_host!(value)
|
|
489
|
+
return if value.to_s.start_with?("https://")
|
|
490
|
+
|
|
491
|
+
raise ConfigurationError,
|
|
492
|
+
"hotwire_native_document_links canonical_host must be an absolute https:// URL " \
|
|
493
|
+
"(got #{value.inspect}). It is the address an external browser opens on a user's " \
|
|
494
|
+
"phone; anything else either stays inside the WebView or downgrades the transport."
|
|
495
|
+
end
|
|
496
|
+
private :validate_hotwire_native_canonical_host!
|
|
497
|
+
|
|
498
|
+
def raise_on_missing_translation=(value)
|
|
499
|
+
@raise_on_missing_translation = ensure_boolean(value, "raise_on_missing_translation")
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def publish_documents_after_database_preparation=(value)
|
|
503
|
+
@publish_documents_after_database_preparation =
|
|
504
|
+
ensure_boolean(value, "publish_documents_after_database_preparation")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def presentation_valid_for=(value)
|
|
508
|
+
@presentation_valid_for = ensure_duration(value, "presentation_valid_for")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def remediation_token_valid_for=(value)
|
|
512
|
+
@remediation_token_valid_for = ensure_duration(value, "remediation_token_valid_for")
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# --- Integrity setters ----------------------------------------------------
|
|
516
|
+
|
|
517
|
+
def digest_canonical_receipts_with=(value)
|
|
518
|
+
@digest_canonical_receipts_with = ensure_digest_algorithm(value, "digest_canonical_receipts_with")
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def chain_event_history_with=(value)
|
|
522
|
+
@chain_event_history_with =
|
|
523
|
+
value.nil? ? nil : ensure_digest_algorithm(value, "chain_event_history_with")
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def anchor_event_history_with=(value)
|
|
527
|
+
@anchor_event_history_with =
|
|
528
|
+
ensure_adapter(value, "anchor_event_history_with", :anchor, :verify, :capabilities)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def timestamp_receipts_with=(value)
|
|
532
|
+
@timestamp_receipts_with =
|
|
533
|
+
ensure_adapter(value, "timestamp_receipts_with", :timestamp, :verify, :capabilities)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def application_version=(value)
|
|
537
|
+
@application_version = value.respond_to?(:call) ? value : -> { value }
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def template_version=(value)
|
|
541
|
+
@template_version = value.respond_to?(:call) ? value : -> { value }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def resolved_application_version = application_version.call
|
|
545
|
+
def resolved_template_version = template_version.call
|
|
546
|
+
|
|
547
|
+
# --- Authorization setters ------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def authorize_receipt_access_with=(value)
|
|
550
|
+
@authorize_receipt_access_with = ensure_callable(value, "authorize_receipt_access_with")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def authorize_unredacted_request_evidence_access_with=(value)
|
|
554
|
+
@authorize_unredacted_request_evidence_access_with =
|
|
555
|
+
ensure_callable(value, "authorize_unredacted_request_evidence_access_with")
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def verify_actor_can_act_for_represented_party_with=(value)
|
|
559
|
+
@verify_actor_can_act_for_represented_party_with =
|
|
560
|
+
ensure_callable(value, "verify_actor_can_act_for_represented_party_with")
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def authorize_clickwrap_remediation_subject_with=(value)
|
|
564
|
+
@authorize_clickwrap_remediation_subject_with =
|
|
565
|
+
ensure_callable(value, "authorize_clickwrap_remediation_subject_with")
|
|
566
|
+
@remediation_subject_authorization_configured = true
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def authorize_clickwrap_remediation_represented_party_with=(value)
|
|
570
|
+
@authorize_clickwrap_remediation_represented_party_with =
|
|
571
|
+
ensure_callable(value, "authorize_clickwrap_remediation_represented_party_with")
|
|
572
|
+
@remediation_represented_party_authorization_configured = true
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def remediation_subject_authorization_configured? = @remediation_subject_authorization_configured
|
|
576
|
+
|
|
577
|
+
def remediation_represented_party_authorization_configured?
|
|
578
|
+
@remediation_represented_party_authorization_configured
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Register a named, server-side authority adapter. Policies refer to the
|
|
582
|
+
# name from their compiled revision; the browser never submits it.
|
|
583
|
+
#
|
|
584
|
+
# config.register_represented_party_authority :company_directory, MyAdapter.new
|
|
585
|
+
#
|
|
586
|
+
# The adapter receives actor:, represented_party:, authority_rule:, tenant:,
|
|
587
|
+
# and authentication_context:, and returns Clickwrap::AuthorityDecision.
|
|
588
|
+
def register_represented_party_authority(name, adapter)
|
|
589
|
+
key = ensure_present_symbol(name, "represented-party authority name").to_s
|
|
590
|
+
if @represented_party_authority_adapters.key?(key)
|
|
591
|
+
raise ConfigurationError,
|
|
592
|
+
"A represented-party authority adapter named #{key.inspect} is already registered. " \
|
|
593
|
+
"Use one stable name per adapter; Clickwrap will not silently replace an " \
|
|
594
|
+
"authorization decision because initializer order changed."
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
@represented_party_authority_adapters[key] =
|
|
598
|
+
ensure_adapter(adapter, "represented-party authority #{key}", :verify)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def represented_party_authority_adapter(name)
|
|
602
|
+
@represented_party_authority_adapters[name.to_s]
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def represented_party_authority_adapter_names
|
|
606
|
+
@represented_party_authority_adapters.keys.sort.freeze
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# --- Request-evidence setters --------------------------------------------
|
|
610
|
+
|
|
611
|
+
def record_ip_address_by_default=(value)
|
|
612
|
+
@record_ip_address_by_default = ensure_boolean(value, "record_ip_address_by_default")
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def record_browser_user_agent_by_default=(value)
|
|
616
|
+
@record_browser_user_agent_by_default = ensure_boolean(value, "record_browser_user_agent_by_default")
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# One setter per IP-geolocation data field, defined rather than written out
|
|
620
|
+
# nine times. Each one is a separate decision about what to keep about
|
|
621
|
+
# someone's network context, so each one gets its own name in the
|
|
622
|
+
# initializer, its own line in the privacy inventory, and its own entry in
|
|
623
|
+
# the receipt.
|
|
624
|
+
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.each do |field|
|
|
625
|
+
setting = :"record_ip_geolocation_#{field}_by_default"
|
|
626
|
+
|
|
627
|
+
define_method(:"#{setting}=") do |value|
|
|
628
|
+
instance_variable_set(:"@#{setting}", ensure_boolean(value, setting.to_s))
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def encrypt_recorded_ip_addresses=(value)
|
|
633
|
+
@encrypt_recorded_ip_addresses = ensure_encryption_choice(value, "encrypt_recorded_ip_addresses")
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def encrypt_recorded_browser_user_agents=(value)
|
|
637
|
+
@encrypt_recorded_browser_user_agents =
|
|
638
|
+
ensure_encryption_choice(value, "encrypt_recorded_browser_user_agents")
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def encrypt_recorded_ip_geolocation=(value)
|
|
642
|
+
@encrypt_recorded_ip_geolocation = ensure_encryption_choice(value, "encrypt_recorded_ip_geolocation")
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def delete_recorded_ip_addresses_after=(value)
|
|
646
|
+
@delete_recorded_ip_addresses_after =
|
|
647
|
+
ensure_positive_duration_or_nil(value, "delete_recorded_ip_addresses_after")
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def delete_recorded_browser_user_agents_after=(value)
|
|
651
|
+
@delete_recorded_browser_user_agents_after =
|
|
652
|
+
ensure_positive_duration_or_nil(value, "delete_recorded_browser_user_agents_after")
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def delete_recorded_ip_geolocation_after=(value)
|
|
656
|
+
@delete_recorded_ip_geolocation_after =
|
|
657
|
+
ensure_positive_duration_or_nil(value, "delete_recorded_ip_geolocation_after")
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def read_ip_address_from_http_request_with=(value)
|
|
661
|
+
@read_ip_address_from_http_request_with =
|
|
662
|
+
ensure_callable(value, "read_ip_address_from_http_request_with")
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def read_browser_user_agent_from_http_request_with=(value)
|
|
666
|
+
@read_browser_user_agent_from_http_request_with =
|
|
667
|
+
ensure_callable(value, "read_browser_user_agent_from_http_request_with")
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# A digest of the host's reviewed trusted-proxy configuration, stored beside
|
|
671
|
+
# any recorded IP address. It does not make the configuration correct; it
|
|
672
|
+
# records which configuration was in force when the address was observed, so
|
|
673
|
+
# a later reader can tell whether the value came through a path the host had
|
|
674
|
+
# actually verified.
|
|
675
|
+
def trusted_proxy_configuration_digest=(value)
|
|
676
|
+
if value.nil?
|
|
677
|
+
@trusted_proxy_configuration_digest = nil
|
|
678
|
+
return
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
normalized = value.to_s.strip
|
|
682
|
+
unless Digest.well_formed?(normalized)
|
|
683
|
+
raise ConfigurationError,
|
|
684
|
+
"trusted_proxy_configuration_digest must be a complete prefixed SHA-2 digest, " \
|
|
685
|
+
"such as `sha256:` followed by 64 lowercase hexadecimal characters. It records " \
|
|
686
|
+
"which reviewed proxy configuration was in force; a label or TODO is not a digest."
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
@trusted_proxy_configuration_digest = normalized
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def ip_geolocation_resolver=(value)
|
|
693
|
+
@ip_geolocation_resolver = ensure_ip_geolocation_resolver(value, "ip_geolocation_resolver")
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Register more than one resolver and let each server-owned policy select
|
|
697
|
+
# one by name with `record_ip_geolocation ..., using: :maxmind`.
|
|
698
|
+
def register_ip_geolocation_resolver(name, resolver)
|
|
699
|
+
key = ensure_present_symbol(name, "IP-geolocation resolver name").to_s
|
|
700
|
+
if key == "application_default" || @ip_geolocation_resolvers.key?(key)
|
|
701
|
+
raise ConfigurationError,
|
|
702
|
+
"An IP-geolocation resolver named #{key.inspect} is already reserved or registered. " \
|
|
703
|
+
"Choose one stable, unique name; Clickwrap will not silently replace a resolver " \
|
|
704
|
+
"because initializer order changed."
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
@ip_geolocation_resolvers[key] = ensure_ip_geolocation_resolver(
|
|
708
|
+
resolver,
|
|
709
|
+
"IP-geolocation resolver #{key}"
|
|
710
|
+
)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def ip_geolocation_resolver_for(name = nil)
|
|
714
|
+
return ip_geolocation_resolver if name.blank? || name.to_s == "application_default"
|
|
715
|
+
|
|
716
|
+
@ip_geolocation_resolvers[name.to_s]
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def ip_geolocation_resolver_names = @ip_geolocation_resolvers.keys.sort.freeze
|
|
720
|
+
|
|
721
|
+
# Plain-English key-rotation API. The ID is evidence and must stay stable;
|
|
722
|
+
# the callback returns key bytes for current OR historical IDs.
|
|
723
|
+
#
|
|
724
|
+
# config.current_request_evidence_binding_key_id = "request-evidence-2026-01"
|
|
725
|
+
# config.find_request_evidence_binding_key_with = ->(key_id) { keyring[key_id] }
|
|
726
|
+
def current_request_evidence_binding_key_id=(value)
|
|
727
|
+
@current_request_evidence_binding_key_id = ensure_present_string(
|
|
728
|
+
value, "current_request_evidence_binding_key_id"
|
|
729
|
+
)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def current_request_evidence_binding_key_id
|
|
733
|
+
@current_request_evidence_binding_key_id ||
|
|
734
|
+
default_request_evidence_binding_key_id(default_request_evidence_binding_key)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def find_request_evidence_binding_key_with=(value)
|
|
738
|
+
@find_request_evidence_binding_key_with =
|
|
739
|
+
ensure_callable(value, "find_request_evidence_binding_key_with")
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def request_evidence_binding_key_for(key_id)
|
|
743
|
+
key = find_request_evidence_binding_key_with.call(key_id)
|
|
744
|
+
return nil if key.nil?
|
|
745
|
+
|
|
746
|
+
bytes = key.to_s.b
|
|
747
|
+
if bytes.bytesize < 32
|
|
748
|
+
raise ConfigurationError,
|
|
749
|
+
"find_request_evidence_binding_key_with returned only #{bytes.bytesize} bytes for " \
|
|
750
|
+
"#{key_id.inspect}. Request-evidence binding keys must be at least 32 bytes."
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
bytes
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def fail_capture_when_ip_geolocation_is_unavailable=(value)
|
|
757
|
+
@fail_capture_when_ip_geolocation_is_unavailable =
|
|
758
|
+
ensure_boolean(value, "fail_capture_when_ip_geolocation_is_unavailable")
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# --- Hook setters ---------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
def after_event_is_committed=(value)
|
|
764
|
+
@after_event_is_committed = ensure_callable(value, "after_event_is_committed")
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def report_after_commit_failure_with=(value)
|
|
768
|
+
@report_after_commit_failure_with = ensure_callable(value, "report_after_commit_failure_with")
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# --- Retention calculations -----------------------------------------------
|
|
772
|
+
|
|
773
|
+
# Registers a host calculation for an event-based retention rule.
|
|
774
|
+
#
|
|
775
|
+
# config.calculate_retention_time_for :regulated_evidence_retention_ends do |event|
|
|
776
|
+
# [event.recorded_at_by_server + 5.years,
|
|
777
|
+
# event.subject_liquidated_at&.+(3.years)].compact.max
|
|
778
|
+
# end
|
|
779
|
+
#
|
|
780
|
+
# Returning nil is a legitimate answer: it means the triggering host event
|
|
781
|
+
# has not happened yet, so the record is not due for disposition and
|
|
782
|
+
# Clickwrap reports it as unresolved rather than inventing a date.
|
|
783
|
+
def calculate_retention_time_for(name, &block)
|
|
784
|
+
raise ConfigurationError, "calculate_retention_time_for needs a block" unless block
|
|
785
|
+
|
|
786
|
+
key = ensure_present_symbol(name, "retention calculation name")
|
|
787
|
+
if @retention_time_calculators.key?(key)
|
|
788
|
+
raise ConfigurationError,
|
|
789
|
+
"A retention calculation named #{key.inspect} is already registered. Use one " \
|
|
790
|
+
"stable name per calculation; Clickwrap will not silently replace a deletion " \
|
|
791
|
+
"deadline because initializer order changed."
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
@retention_time_calculators[key] = block
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Deliberate replacement for tests, staged migrations, or a host that is
|
|
798
|
+
# intentionally changing an existing calculation. The separate verb and
|
|
799
|
+
# required reason keep this from becoming last-initializer-wins behavior.
|
|
800
|
+
def replace_retention_time_calculation_for(name, because:, &block)
|
|
801
|
+
key = ensure_present_symbol(name, "retention calculation name")
|
|
802
|
+
unless @retention_time_calculators.key?(key)
|
|
803
|
+
raise ConfigurationError,
|
|
804
|
+
"No retention calculation named #{key.inspect} exists to replace. Register it " \
|
|
805
|
+
"first with `calculate_retention_time_for`."
|
|
806
|
+
end
|
|
807
|
+
unless block
|
|
808
|
+
raise ConfigurationError,
|
|
809
|
+
"replace_retention_time_calculation_for needs a block with the new calculation."
|
|
810
|
+
end
|
|
811
|
+
if because.to_s.strip.empty?
|
|
812
|
+
raise ConfigurationError,
|
|
813
|
+
"Replacing retention calculation #{key.inspect} needs a `because:` explaining " \
|
|
814
|
+
"the reviewed change."
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
@retention_time_calculators[key] = block
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def retention_time_calculator_names = @retention_time_calculators.keys
|
|
821
|
+
|
|
822
|
+
def resolve_retention_time(name, event)
|
|
823
|
+
calculator = @retention_time_calculators[name.to_sym]
|
|
824
|
+
|
|
825
|
+
unless calculator
|
|
826
|
+
raise ConfigurationError,
|
|
827
|
+
"No retention calculation is registered for #{name.inspect}. A retention class " \
|
|
828
|
+
"asked for it with `retain_..._until #{name.inspect}`. Register it with " \
|
|
829
|
+
"`config.calculate_retention_time_for #{name.inspect} do |event| ... end`."
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
calculator.call(event)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# --- Cross-field validation -----------------------------------------------
|
|
836
|
+
|
|
837
|
+
# Run at the end of `Clickwrap.configure`. The per-setter checks already
|
|
838
|
+
# caught the typos; these are the things that need the whole block resolved.
|
|
839
|
+
def validate!
|
|
840
|
+
validate_request_evidence_defaults!
|
|
841
|
+
validate_trusted_proxy_configuration!
|
|
842
|
+
validate_ip_geolocation_resolver!
|
|
843
|
+
true
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# A convenience the initializer template and `clickwrap:doctor` both use.
|
|
847
|
+
def records_any_request_evidence_by_default?
|
|
848
|
+
record_ip_address_by_default ||
|
|
849
|
+
record_browser_user_agent_by_default ||
|
|
850
|
+
enabled_default_ip_geolocation_fields.any?
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def enabled_default_ip_geolocation_fields
|
|
854
|
+
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.select do |field|
|
|
855
|
+
public_send(:"record_ip_geolocation_#{field}_by_default")
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
private
|
|
860
|
+
|
|
861
|
+
def default_actor_reference(actor)
|
|
862
|
+
return nil if actor.nil?
|
|
863
|
+
return actor.to_s if actor.is_a?(String) || actor.is_a?(Symbol)
|
|
864
|
+
return actor.clickwrap_actor_reference if actor.respond_to?(:clickwrap_actor_reference)
|
|
865
|
+
|
|
866
|
+
Reference.record(actor)
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def validate_request_evidence_defaults!
|
|
870
|
+
{
|
|
871
|
+
ip_address: [record_ip_address_by_default,
|
|
872
|
+
reason_for_recording_ip_addresses_by_default,
|
|
873
|
+
delete_recorded_ip_addresses_after],
|
|
874
|
+
browser_user_agent: [record_browser_user_agent_by_default,
|
|
875
|
+
reason_for_recording_browser_user_agents_by_default,
|
|
876
|
+
delete_recorded_browser_user_agents_after],
|
|
877
|
+
ip_geolocation: [enabled_default_ip_geolocation_fields.any?,
|
|
878
|
+
reason_for_recording_ip_geolocation_by_default,
|
|
879
|
+
delete_recorded_ip_geolocation_after]
|
|
880
|
+
}.each do |category, (enabled, reason, delete_after)|
|
|
881
|
+
next unless enabled
|
|
882
|
+
|
|
883
|
+
if reason.to_s.strip.empty?
|
|
884
|
+
raise ConfigurationError,
|
|
885
|
+
"Clickwrap is set to record #{category} for every policy by default, but " \
|
|
886
|
+
"`reason_for_recording_#{plural_for(category)}_by_default` is blank. Say in one " \
|
|
887
|
+
"plain sentence why the application needs it. If only some policies need it, " \
|
|
888
|
+
"turn the default off and enable it in those policies instead."
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
if ReviewedText.placeholder?(reason)
|
|
892
|
+
raise ConfigurationError,
|
|
893
|
+
"Clickwrap is set to record #{category} for every policy by default, but its " \
|
|
894
|
+
"reason is still scaffolding text (#{reason.inspect}). Replace it with the " \
|
|
895
|
+
"application's reviewed, present-tense reason, or turn that default off."
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
next unless delete_after.nil?
|
|
899
|
+
|
|
900
|
+
raise ConfigurationError,
|
|
901
|
+
"Clickwrap is set to record #{category} for every policy by default, but " \
|
|
902
|
+
"`delete_recorded_#{plural_for(category)}_after` is nil, so nothing would ever " \
|
|
903
|
+
"delete it. Set a reviewed period, or turn the default off and let each policy " \
|
|
904
|
+
"choose its own retention rule."
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def plural_for(category)
|
|
909
|
+
case category
|
|
910
|
+
when :ip_address then "ip_addresses"
|
|
911
|
+
when :browser_user_agent then "browser_user_agents"
|
|
912
|
+
else "ip_geolocation"
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def validate_trusted_proxy_configuration!
|
|
917
|
+
records_ip_derived_evidence =
|
|
918
|
+
record_ip_address_by_default || enabled_default_ip_geolocation_fields.any?
|
|
919
|
+
return unless records_ip_derived_evidence
|
|
920
|
+
return if trusted_proxy_configuration_digest.present?
|
|
921
|
+
|
|
922
|
+
raise ConfigurationError,
|
|
923
|
+
"Clickwrap is set to record an IP address or derive IP geolocation for every " \
|
|
924
|
+
"policy, but `trusted_proxy_configuration_digest` is blank. Review and test the " \
|
|
925
|
+
"deployment's trusted-proxy topology, digest that exact configuration, and set " \
|
|
926
|
+
"the complete prefixed SHA-2 digest (for example `sha256:...`). This records which " \
|
|
927
|
+
"proxy decision produced the address; it does not claim that decision was correct."
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def validate_ip_geolocation_resolver!
|
|
931
|
+
return if ip_geolocation_resolver
|
|
932
|
+
return if enabled_default_ip_geolocation_fields.empty? && !fail_capture_when_ip_geolocation_is_unavailable
|
|
933
|
+
|
|
934
|
+
if enabled_default_ip_geolocation_fields.any?
|
|
935
|
+
raise ConfigurationError,
|
|
936
|
+
"Clickwrap is set to record the IP-geolocation fields " \
|
|
937
|
+
"#{enabled_default_ip_geolocation_fields.join(", ")} but no " \
|
|
938
|
+
"`ip_geolocation_resolver` is configured, so there is nothing to resolve them. " \
|
|
939
|
+
"Set one (for example Clickwrap::IpGeolocation::TrackdownResolver.new) or turn " \
|
|
940
|
+
"the fields off."
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
raise ConfigurationError,
|
|
944
|
+
"`fail_capture_when_ip_geolocation_is_unavailable` is true but no " \
|
|
945
|
+
"`ip_geolocation_resolver` is configured, so every capture would fail."
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# --- Setter helpers -------------------------------------------------------
|
|
949
|
+
|
|
950
|
+
def ensure_callable(value, name)
|
|
951
|
+
unless value.respond_to?(:call)
|
|
952
|
+
raise ConfigurationError,
|
|
953
|
+
"#{name} must respond to #call (a proc or lambda), got #{value.inspect}"
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
value
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def ensure_adapter(value, name, *required_methods)
|
|
960
|
+
return nil if value.nil?
|
|
961
|
+
|
|
962
|
+
missing = required_methods.reject { |required_method| value.respond_to?(required_method) }
|
|
963
|
+
unless missing.empty?
|
|
964
|
+
raise ConfigurationError,
|
|
965
|
+
"#{name} must respond to #{missing.map { |method| "##{method}" }.join(", ")}, " \
|
|
966
|
+
"but #{value.inspect} does not. See the matching adapter section in README.md."
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
value
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def ensure_ip_geolocation_resolver(value, name)
|
|
973
|
+
adapter = ensure_adapter(value, name, :resolve, :capabilities)
|
|
974
|
+
return nil if adapter.nil?
|
|
975
|
+
|
|
976
|
+
parameters = adapter.method(:resolve).parameters
|
|
977
|
+
accepts_http_request = parameters.any? do |kind, parameter_name|
|
|
978
|
+
kind == :keyrest || (%i[key keyreq].include?(kind) && parameter_name == :http_request)
|
|
979
|
+
end
|
|
980
|
+
return adapter if accepts_http_request
|
|
981
|
+
|
|
982
|
+
raise ConfigurationError,
|
|
983
|
+
"#{name} must implement `#resolve(ip_address, http_request: nil)`. The request is " \
|
|
984
|
+
"explicit because request-backed providers such as Cloudflare need it to read " \
|
|
985
|
+
"their location headers and record whether the host verified that CDN path. " \
|
|
986
|
+
"Update #{adapter.class} to accept the `http_request:` keyword, even if that " \
|
|
987
|
+
"resolver does not use it."
|
|
988
|
+
rescue NameError
|
|
989
|
+
raise ConfigurationError,
|
|
990
|
+
"#{name} exposes #resolve but Clickwrap could not inspect its parameters. Define " \
|
|
991
|
+
"`#resolve(ip_address, http_request: nil)` explicitly so request provenance is " \
|
|
992
|
+
"never dropped by an opaque adapter."
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def ensure_class_name(value, name)
|
|
996
|
+
class_name = value.is_a?(Class) ? value.name : value.to_s
|
|
997
|
+
|
|
998
|
+
raise ConfigurationError, "#{name} can't be blank" if class_name.strip.empty?
|
|
999
|
+
|
|
1000
|
+
class_name
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def ensure_present_symbol(value, name)
|
|
1004
|
+
symbol = value.to_s.strip
|
|
1005
|
+
|
|
1006
|
+
raise ConfigurationError, "#{name} can't be blank" if symbol.empty?
|
|
1007
|
+
|
|
1008
|
+
symbol.to_sym
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
def ensure_present_string(value, name)
|
|
1012
|
+
string = value.to_s.strip
|
|
1013
|
+
raise ConfigurationError, "#{name} can't be blank" if string.empty?
|
|
1014
|
+
|
|
1015
|
+
string
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
def default_request_evidence_binding_key
|
|
1019
|
+
if defined?(::Rails) && ::Rails.application&.key_generator
|
|
1020
|
+
::Rails.application.key_generator.generate_key("clickwrap/request-evidence-binding", 32)
|
|
1021
|
+
else
|
|
1022
|
+
ENV.fetch("CLICKWRAP_REQUEST_EVIDENCE_BINDING_KEY", nil)
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
def default_request_evidence_binding_key_id(key)
|
|
1027
|
+
return nil if key.nil?
|
|
1028
|
+
|
|
1029
|
+
"rails_key_generator_#{Digest.hex(key)[0, 16]}"
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
def ensure_boolean(value, name)
|
|
1033
|
+
unless [true, false].include?(value)
|
|
1034
|
+
raise ConfigurationError, "#{name} must be true or false, got #{value.inspect}"
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
value
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# Storing raw IP addresses or browser user-agent strings unencrypted is
|
|
1041
|
+
# allowed, because some hosts have a reviewed reason for it and pretending
|
|
1042
|
+
# otherwise would just push them to store the values somewhere worse. But it
|
|
1043
|
+
# is never the default, and it is never a quiet one-character change.
|
|
1044
|
+
def ensure_encryption_choice(value, name)
|
|
1045
|
+
return value if value == true
|
|
1046
|
+
|
|
1047
|
+
if value == false
|
|
1048
|
+
return false if @deliberately_storing_request_evidence_unencrypted
|
|
1049
|
+
|
|
1050
|
+
raise ConfigurationError,
|
|
1051
|
+
"#{name} = false stores this personal data in plain text in your database, where " \
|
|
1052
|
+
"it will also appear in ordinary backups and database dumps. If that is a " \
|
|
1053
|
+
"reviewed decision, say so explicitly first:\n\n " \
|
|
1054
|
+
"config.deliberately_store_request_evidence_unencrypted!(\n " \
|
|
1055
|
+
"because: \"...your reviewed reason...\"\n " \
|
|
1056
|
+
")\n"
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
raise ConfigurationError, "#{name} must be true or false, got #{value.inspect}"
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def ensure_digest_algorithm(value, name)
|
|
1063
|
+
normalized = value.to_s.to_sym
|
|
1064
|
+
|
|
1065
|
+
unless DIGEST_ALGORITHMS.include?(normalized)
|
|
1066
|
+
raise ConfigurationError,
|
|
1067
|
+
"#{name} must be one of #{DIGEST_ALGORITHMS.inspect}, got #{value.inspect}"
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
normalized
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
def ensure_duration(value, name)
|
|
1074
|
+
unless value.respond_to?(:from_now) && value.respond_to?(:ago)
|
|
1075
|
+
raise ConfigurationError,
|
|
1076
|
+
"#{name} must be a duration (like 2.hours), got #{value.inspect}"
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
value
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
def ensure_positive_duration_or_nil(value, name)
|
|
1083
|
+
return nil if value.nil?
|
|
1084
|
+
|
|
1085
|
+
duration = ensure_duration(value, name)
|
|
1086
|
+
|
|
1087
|
+
unless duration.to_i.positive?
|
|
1088
|
+
raise ConfigurationError,
|
|
1089
|
+
"#{name} must be a period in the future, got #{value.inspect}. Use nil if each " \
|
|
1090
|
+
"policy should choose its own retention rule."
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
duration
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
public
|
|
1097
|
+
|
|
1098
|
+
# The deliberate, named escape hatch referenced by `ensure_encryption_choice`.
|
|
1099
|
+
# It exists so that turning encryption off is a sentence a reviewer can find
|
|
1100
|
+
# in a diff, with the host's own reason attached, rather than a `false`.
|
|
1101
|
+
def deliberately_store_request_evidence_unencrypted!(because:)
|
|
1102
|
+
if because.to_s.strip.empty?
|
|
1103
|
+
raise ConfigurationError,
|
|
1104
|
+
"deliberately_store_request_evidence_unencrypted! needs a `because:` explaining " \
|
|
1105
|
+
"the reviewed decision."
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
@deliberately_storing_request_evidence_unencrypted = true
|
|
1109
|
+
@reason_for_storing_request_evidence_unencrypted = because
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
def storing_request_evidence_unencrypted? = @deliberately_storing_request_evidence_unencrypted == true
|
|
1113
|
+
|
|
1114
|
+
def method_missing(name, *arguments, **options, &)
|
|
1115
|
+
if name.to_s.end_with?("=")
|
|
1116
|
+
setting = name.to_s.delete_suffix("=")
|
|
1117
|
+
raise ConfigurationError,
|
|
1118
|
+
"Clickwrap has no initializer setting named `config.#{setting}`. Check the " \
|
|
1119
|
+
"spelling; unknown settings are refused so a typo can never look configured."
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
super
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
def respond_to_missing?(name, include_private = false)
|
|
1126
|
+
super
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
end
|