clickwrap 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Clickwrap
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate clickwrap:install` — the adaptive migration, one annotated
|
|
9
|
+
# initializer, a conventional policy file, placeholder legal content, and an
|
|
10
|
+
# optional engine mount.
|
|
11
|
+
#
|
|
12
|
+
# Two things make this installer different from an ordinary one.
|
|
13
|
+
#
|
|
14
|
+
# First, it asks. Recording an IP address or a provider-estimated city is a
|
|
15
|
+
# decision with consequences for the people using the host application, so
|
|
16
|
+
# every request-evidence field is a separate question in plain English with
|
|
17
|
+
# the consequence stated before the choice — never a category enabled as a
|
|
18
|
+
# side effect of something else.
|
|
19
|
+
#
|
|
20
|
+
# Second, it refuses to guess. The actor class is inferred only when it is
|
|
21
|
+
# unambiguous, legal text is never invented, and no purpose, retention
|
|
22
|
+
# period or resolver is written on the host's behalf. Proxy provenance is
|
|
23
|
+
# derived from the effective Rails rules themselves rather than invented.
|
|
24
|
+
# An incomplete personal-data choice stops the generator before it writes a
|
|
25
|
+
# file.
|
|
26
|
+
#
|
|
27
|
+
# The only `--request-evidence-recipe` is `privacy-minimized`, a convenient
|
|
28
|
+
# spelling of the off-by-default posture. It disappears after generation;
|
|
29
|
+
# it is deliberately not a runtime concept. There is no
|
|
30
|
+
# `record_network_context`, `record_everything`, `full_evidence`, or
|
|
31
|
+
# regulation-named mode switch anywhere in this gem, because a flag that
|
|
32
|
+
# enables a whole category of personal data is the thing this gem exists not
|
|
33
|
+
# to do, and no flag can make a legal determination on a host's behalf.
|
|
34
|
+
class InstallGenerator < Rails::Generators::Base
|
|
35
|
+
include ActiveRecord::Generators::Migration
|
|
36
|
+
|
|
37
|
+
source_root File.expand_path("templates", __dir__)
|
|
38
|
+
desc "Install clickwrap: adaptive migration, annotated initializer, policy file, and placeholders"
|
|
39
|
+
|
|
40
|
+
# The adapters the gem's portable core behavior is tested against.
|
|
41
|
+
SUPPORTED_ADAPTERS = %w[sqlite sqlite3 postgresql postgis mysql mysql2 trilogy].freeze
|
|
42
|
+
|
|
43
|
+
RECIPES = %w[privacy-minimized].freeze
|
|
44
|
+
|
|
45
|
+
# Places applications already keep the legal text their public routes
|
|
46
|
+
# serve. Both documents must exist for a convention to count; order
|
|
47
|
+
# matters, so a Sitepress-style content directory wins over files a
|
|
48
|
+
# previous install of this generator wrote.
|
|
49
|
+
#
|
|
50
|
+
# `rendered_by_the_application` separates the two cases: the Sitepress
|
|
51
|
+
# directory holds pages the application itself renders and serves, which
|
|
52
|
+
# is what makes rendering Clickwrap's snapshot through the application's
|
|
53
|
+
# own Markdown pipeline the right default. The second convention is a
|
|
54
|
+
# previous install's placeholder files, which nothing else renders.
|
|
55
|
+
EXISTING_LEGAL_CONVENTIONS = [
|
|
56
|
+
{ dir: "app/content/pages/legal", terms: "terms.html.md", privacy: "privacy.html.md",
|
|
57
|
+
rendered_by_the_application: true },
|
|
58
|
+
{ dir: "app/content/legal", terms: "terms.md", privacy: "privacy.md",
|
|
59
|
+
rendered_by_the_application: false }
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# The names the configuration uses, which are also the names this
|
|
63
|
+
# generator's one geolocation flag accepts. There used to be nine separate
|
|
64
|
+
# `class_option`s here, spelled in the plural, plus a table translating
|
|
65
|
+
# them back into the singular configuration names — twenty-odd lines of
|
|
66
|
+
# generator surface for one list.
|
|
67
|
+
IP_GEOLOCATION_FIELDS = Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS
|
|
68
|
+
|
|
69
|
+
# --- Request evidence: one explicit flag per field ------------------------
|
|
70
|
+
#
|
|
71
|
+
# No defaults are declared for these booleans on purpose: an absent key
|
|
72
|
+
# means "the operator did not choose", which is what lets an explicit
|
|
73
|
+
# `--no-record-...` override a recipe, and what stops the installer from
|
|
74
|
+
# asking a question that was already answered on the command line.
|
|
75
|
+
|
|
76
|
+
class_option :record_ip_addresses_by_default,
|
|
77
|
+
type: :boolean, desc: "Record the request IP address for every policy"
|
|
78
|
+
class_option :record_browser_user_agents_by_default,
|
|
79
|
+
type: :boolean, desc: "Record the browser User-Agent for every policy"
|
|
80
|
+
# One flag, one explicit allowlist. Naming a field here still does not
|
|
81
|
+
# enable a category as a side effect: each named field needs the same
|
|
82
|
+
# purpose, deletion rule, and resolver every other route to it needs, and
|
|
83
|
+
# a name that is not on the list stops the generator before it writes a
|
|
84
|
+
# file. The runtime keeps one setter per field — that is the posture a
|
|
85
|
+
# host reads and reviews; this is only the command line.
|
|
86
|
+
class_option :record_ip_geolocation_fields,
|
|
87
|
+
type: :array,
|
|
88
|
+
desc: "IP-geolocation fields to record by default, from: " \
|
|
89
|
+
"#{IP_GEOLOCATION_FIELDS.join(", ")}. Every one is an estimate for the " \
|
|
90
|
+
"IP address, never a person's physical location."
|
|
91
|
+
|
|
92
|
+
class_option :delete_recorded_ip_addresses_after_days,
|
|
93
|
+
type: :numeric, desc: "Delete recorded IP addresses after N days"
|
|
94
|
+
class_option :delete_recorded_browser_user_agents_after_days,
|
|
95
|
+
type: :numeric, desc: "Delete recorded browser User-Agent strings after N days"
|
|
96
|
+
class_option :delete_recorded_ip_geolocation_after_days,
|
|
97
|
+
type: :numeric, desc: "Delete recorded IP geolocation after N days"
|
|
98
|
+
|
|
99
|
+
class_option :reason_for_recording_ip_addresses_by_default,
|
|
100
|
+
type: :string,
|
|
101
|
+
desc: "Plain-English reason for recording IP addresses by default"
|
|
102
|
+
class_option :reason_for_recording_browser_user_agents_by_default,
|
|
103
|
+
type: :string,
|
|
104
|
+
desc: "Plain-English reason for recording browser User-Agent strings by default"
|
|
105
|
+
class_option :reason_for_recording_ip_geolocation_by_default,
|
|
106
|
+
type: :string,
|
|
107
|
+
desc: "Plain-English reason for recording IP geolocation by default"
|
|
108
|
+
class_option :trusted_proxy_configuration_digest,
|
|
109
|
+
type: :string,
|
|
110
|
+
desc: "Prefixed SHA-2 digest of the reviewed trusted-proxy configuration"
|
|
111
|
+
class_option :ip_geolocation_resolver_class_name,
|
|
112
|
+
type: :string,
|
|
113
|
+
desc: "Resolver class to instantiate when IP geolocation is enabled"
|
|
114
|
+
|
|
115
|
+
class_option :request_evidence_recipe,
|
|
116
|
+
type: :string, enum: RECIPES,
|
|
117
|
+
desc: "Use the privacy-minimized, all-fields-off scaffold. " \
|
|
118
|
+
"Generator-only: it does not survive as a runtime concept."
|
|
119
|
+
|
|
120
|
+
# --- Optional tables ------------------------------------------------------
|
|
121
|
+
#
|
|
122
|
+
# Seven of the seventeen tables this gem knows about cannot receive a row
|
|
123
|
+
# until a matching configuration is turned on, and every one of those is
|
|
124
|
+
# off by default. Emitting them all anyway makes an installation's schema
|
|
125
|
+
# claim capabilities and data categories that installation does not have.
|
|
126
|
+
#
|
|
127
|
+
# Each flag adds one migration, and re-running the installer later with
|
|
128
|
+
# the flag adds it then. `clickwrap:hardening` is the precedent: an opt-in
|
|
129
|
+
# generator, not a switch inside a migration nobody re-reads.
|
|
130
|
+
OPTIONAL_TABLE_MIGRATIONS = {
|
|
131
|
+
with_persisted_presentations: "create_clickwrap_presentation_tables",
|
|
132
|
+
with_request_evidence: "create_clickwrap_request_evidence_tables",
|
|
133
|
+
with_integrity: "create_clickwrap_integrity_tables",
|
|
134
|
+
with_retention_ops: "create_clickwrap_retention_tables",
|
|
135
|
+
with_external_actions: "create_clickwrap_external_action_tables"
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
138
|
+
# What each omitted flag would have added, in one line, for the
|
|
139
|
+
# post-install message.
|
|
140
|
+
OPTIONAL_TABLE_SUMMARIES = {
|
|
141
|
+
with_persisted_presentations:
|
|
142
|
+
"--with-persisted-presentations presentations retained before submission",
|
|
143
|
+
with_request_evidence:
|
|
144
|
+
"--with-request-evidence IP address / user-agent / geolocation annex",
|
|
145
|
+
with_integrity:
|
|
146
|
+
"--with-integrity event chaining, anchoring, timestamp attestations",
|
|
147
|
+
with_retention_ops:
|
|
148
|
+
"--with-retention-ops legal holds and reviewed disposition plans",
|
|
149
|
+
with_external_actions:
|
|
150
|
+
"--with-external-actions the outbox for authorize_external_action!"
|
|
151
|
+
}.freeze
|
|
152
|
+
|
|
153
|
+
class_option :with_persisted_presentations,
|
|
154
|
+
type: :boolean, default: false,
|
|
155
|
+
desc: "Add the table for policies that retain pre-submit presentations"
|
|
156
|
+
class_option :with_request_evidence,
|
|
157
|
+
type: :boolean, default: false,
|
|
158
|
+
desc: "Add the IP address / user-agent / geolocation annex table " \
|
|
159
|
+
"(implied when this run enables any of those fields)"
|
|
160
|
+
class_option :with_integrity,
|
|
161
|
+
type: :boolean, default: false,
|
|
162
|
+
desc: "Add the event-chain and anchoring/timestamp attestation tables"
|
|
163
|
+
class_option :with_retention_ops,
|
|
164
|
+
type: :boolean, default: false,
|
|
165
|
+
desc: "Add the legal-hold and reviewed-disposition tables"
|
|
166
|
+
class_option :with_external_actions,
|
|
167
|
+
type: :boolean, default: false,
|
|
168
|
+
desc: "Add the outbox table for Clickwrap.authorize_external_action!"
|
|
169
|
+
|
|
170
|
+
class_option :actor_class,
|
|
171
|
+
type: :string,
|
|
172
|
+
desc: "The model that can act (User, Account, Member…) when it can't be inferred"
|
|
173
|
+
class_option :skip_routes,
|
|
174
|
+
type: :boolean, default: false,
|
|
175
|
+
desc: "Don't offer to mount Clickwrap::Engine in config/routes.rb"
|
|
176
|
+
class_option :skip_questions,
|
|
177
|
+
type: :boolean, default: false,
|
|
178
|
+
desc: "Non-interactive: ask nothing and write the safe defaults"
|
|
179
|
+
|
|
180
|
+
def self.next_migration_number(dir)
|
|
181
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Everything this run decides about the host is resolved HERE, before a
|
|
185
|
+
# single file is written, and memoized. The steps below create
|
|
186
|
+
# config/initializers/clickwrap.rb, config/clickwrap.rb, and content files;
|
|
187
|
+
# re-reading the environment halfway through would answer a different
|
|
188
|
+
# question than the one this step asked, and the post-install message would
|
|
189
|
+
# describe an application that did not exist when the decisions were made.
|
|
190
|
+
def detect_environment!
|
|
191
|
+
database_adapter
|
|
192
|
+
primary_key_type
|
|
193
|
+
devise_detected?
|
|
194
|
+
rails_authentication_detected?
|
|
195
|
+
actor_class_name
|
|
196
|
+
|
|
197
|
+
say "\n☑️ Installing clickwrap.", :green
|
|
198
|
+
say " Database adapter: #{database_adapter || "not detected"}"
|
|
199
|
+
say " Primary key type: #{primary_key_type_description}"
|
|
200
|
+
say " Authentication: #{detected_authentication || "none detected (both integrations are optional)"}"
|
|
201
|
+
say " Actor class: #{actor_class_name || "not inferred — see the initializer"}"
|
|
202
|
+
|
|
203
|
+
return if actor_class_name
|
|
204
|
+
|
|
205
|
+
say "\n⚠️ Clickwrap could not infer your actor class unambiguously.", :yellow
|
|
206
|
+
say " #{actor_class_reason}", :yellow
|
|
207
|
+
say " `config.actor_class_name` is therefore left COMMENTED OUT in the", :yellow
|
|
208
|
+
say " generated initializer, with an explanation beside it. Which record", :yellow
|
|
209
|
+
say " can act is a security-relevant identity mapping, and a wrong guess", :yellow
|
|
210
|
+
say " attributes evidence to the wrong kind of record for years. Set it", :yellow
|
|
211
|
+
say " yourself, or re-run with --actor-class=YourModel.", :yellow
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The questions come verbatim from the request-evidence design: each one
|
|
215
|
+
# states the consequence before asking for the choice, because a developer
|
|
216
|
+
# who has not thought about IP addresses this week deserves the context in
|
|
217
|
+
# the question rather than in a document they will read later.
|
|
218
|
+
def ask_about_request_evidence
|
|
219
|
+
ask_request_evidence_questions unless skip_request_evidence_questions?
|
|
220
|
+
validate_request_evidence_choices!
|
|
221
|
+
|
|
222
|
+
# The summary prints in every mode, before any file is written, so the
|
|
223
|
+
# operator sees every enabled field, purpose, encryption choice,
|
|
224
|
+
# source posture, access behavior, and retention rule together.
|
|
225
|
+
summarize_request_evidence_choices
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def create_migration_file
|
|
229
|
+
migration_template "create_clickwrap_tables.rb.erb",
|
|
230
|
+
File.join(db_migrate_path, "create_clickwrap_tables.rb")
|
|
231
|
+
|
|
232
|
+
requested_optional_table_migrations.each do |name|
|
|
233
|
+
migration_template "#{name}.rb.erb", File.join(db_migrate_path, "#{name}.rb")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def create_initializer
|
|
238
|
+
template "initializer.rb.erb", "config/initializers/clickwrap.rb"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def create_policy_file
|
|
242
|
+
template "clickwrap_policies.rb.erb", "config/clickwrap.rb"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Placeholders only, and only when the application has no legal text
|
|
246
|
+
# anywhere Clickwrap recognizes. Clickwrap never invents legal text: the
|
|
247
|
+
# words are the host's, reviewed by the host's counsel, and a gem that
|
|
248
|
+
# shipped plausible-looking Terms would be inviting an application to
|
|
249
|
+
# publish text nobody read.
|
|
250
|
+
#
|
|
251
|
+
# When the app ALREADY serves legal pages (a Sitepress content directory,
|
|
252
|
+
# or a previous install's files), the generated config points at those
|
|
253
|
+
# exact files instead of writing a second set: what people accept must be
|
|
254
|
+
# the same bytes the public legal routes render, and two copies of the
|
|
255
|
+
# Terms is how they silently stop being the same document.
|
|
256
|
+
def create_legal_content_placeholders
|
|
257
|
+
if detected_legal_documents
|
|
258
|
+
say_status :found, "existing legal pages in #{detected_legal_documents[:dir]} — " \
|
|
259
|
+
"config/clickwrap.rb points at them; no placeholders written", :green
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
write_legal_placeholder "terms.md.erb", "app/content/legal/terms.md"
|
|
264
|
+
write_legal_placeholder "privacy.md.erb", "app/content/legal/privacy.md"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def mount_engine
|
|
268
|
+
return if options[:skip_routes]
|
|
269
|
+
return say_missing_routes_file unless routes_file?
|
|
270
|
+
return say_already_mounted if engine_already_mounted?
|
|
271
|
+
return mount_engine_without_asking unless interactive?
|
|
272
|
+
|
|
273
|
+
return unless ask_question(<<~QUESTION)
|
|
274
|
+
Mount the Clickwrap engine at "/agreements" in config/routes.rb?
|
|
275
|
+
It adds actor-owned capture, receipt, consent-withdrawal, and
|
|
276
|
+
document-history screens using your parent controller, layout, locale,
|
|
277
|
+
and authorization callbacks — so a required agreement can be completed
|
|
278
|
+
in place instead of becoming a dead end. Nothing is exposed publicly:
|
|
279
|
+
access still goes through your own authorization callbacks. [y/N]
|
|
280
|
+
QUESTION
|
|
281
|
+
|
|
282
|
+
route "mount Clickwrap::Engine => \"/agreements\""
|
|
283
|
+
@mounted_engine = true
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def print_unsupported_adapter_notes
|
|
287
|
+
return if supported_adapter?
|
|
288
|
+
|
|
289
|
+
say "\n⚠️ #{database_adapter || "Your database adapter"} is outside the set clickwrap tests.", :yellow
|
|
290
|
+
say " Tested: SQLite, PostgreSQL, and MySQL. On anything else these are", :yellow
|
|
291
|
+
say " unverified rather than known-broken, and worth checking yourself:", :yellow
|
|
292
|
+
say " • the JSON/JSONB column types and their defaults in the migration;", :yellow
|
|
293
|
+
say " • long document bodies (a silently truncated agreement is the", :yellow
|
|
294
|
+
say " worst failure this gem has);", :yellow
|
|
295
|
+
say " • whether the unique index on (policy_key, idempotency_key) really", :yellow
|
|
296
|
+
say " rejects a duplicate submit under concurrency; and", :yellow
|
|
297
|
+
say " • `rails generate clickwrap:hardening --database`, whose update and", :yellow
|
|
298
|
+
say " delete protections are written for PostgreSQL only.", :yellow
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def display_post_install_message
|
|
302
|
+
say "\n☑️ The `clickwrap` gem has been installed.", :green
|
|
303
|
+
say "\nTo complete the setup:"
|
|
304
|
+
|
|
305
|
+
step = 0
|
|
306
|
+
say " #{step += 1}. Run 'rails db:migrate' to create the clickwrap tables."
|
|
307
|
+
say " ⚠️ You must run migrations before starting your app!", :yellow
|
|
308
|
+
say_optional_tables
|
|
309
|
+
|
|
310
|
+
say " #{step += 1}. Declare which records can act:"
|
|
311
|
+
say " class #{actor_class_name || "User"} < ApplicationRecord"
|
|
312
|
+
say " has_clickwraps"
|
|
313
|
+
say " end"
|
|
314
|
+
say " (and set `config.actor_class_name` in the initializer)" unless actor_class_name
|
|
315
|
+
|
|
316
|
+
if detected_legal_documents
|
|
317
|
+
say " #{step += 1}. Your existing legal pages are the documents (same bytes people"
|
|
318
|
+
say " read and accept):"
|
|
319
|
+
say " #{terms_document_path}"
|
|
320
|
+
say " #{privacy_document_path}"
|
|
321
|
+
if documents_needing_an_explicit_version.empty?
|
|
322
|
+
say " Each page names its own version in its front matter, so config/clickwrap.rb"
|
|
323
|
+
say " declares both without a `version:` line."
|
|
324
|
+
else
|
|
325
|
+
say " A page that names its own version in its front matter is declared in"
|
|
326
|
+
say " config/clickwrap.rb without a `version:` line."
|
|
327
|
+
say_documents_needing_an_explicit_version
|
|
328
|
+
end
|
|
329
|
+
else
|
|
330
|
+
say " #{step += 1}. Replace the placeholder legal text with your own reviewed documents:"
|
|
331
|
+
say " #{terms_document_path}"
|
|
332
|
+
say " #{privacy_document_path}"
|
|
333
|
+
say " Keep the `last_updated:` front matter at the top of each file accurate:"
|
|
334
|
+
say " that line is the version label, so a text change is one edit in one file."
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
say " #{step += 1}. Publish immutable snapshots:"
|
|
338
|
+
say " bin/rails clickwrap:publish"
|
|
339
|
+
say " Deploys do this for you — publishing rides `db:prepare`, so a snapshot"
|
|
340
|
+
say " exists before the server takes traffic."
|
|
341
|
+
|
|
342
|
+
say " #{step += 1}. Render the policy and its bound submit action:"
|
|
343
|
+
say " <%= form.clickwrap :signup, submit: \"Create account\" %>"
|
|
344
|
+
say " That renders ONE line — a checkbox and a sentence with your documents"
|
|
345
|
+
say " linked inside it:"
|
|
346
|
+
say " [ ] I agree to the Terms and I acknowledge the Privacy Notice."
|
|
347
|
+
say " Point each document at the page people read it on, and the links in that"
|
|
348
|
+
say " sentence go there:"
|
|
349
|
+
say " Clickwrap.document :terms, from: ..., link: \"/legal/terms\""
|
|
350
|
+
|
|
351
|
+
step = say_the_authentication_door_step(step)
|
|
352
|
+
|
|
353
|
+
say " #{step += 1}. Set up your test suite (presentations refuse unpublished documents"
|
|
354
|
+
say " in tests exactly as in production):"
|
|
355
|
+
say " # test/test_helper.rb"
|
|
356
|
+
say " class ActiveSupport::TestCase"
|
|
357
|
+
say " include Clickwrap::TestHelpers"
|
|
358
|
+
say " parallelize_setup { Clickwrap.publish! } # per parallel worker..."
|
|
359
|
+
say " end"
|
|
360
|
+
say " Clickwrap.publish! # ...and once per process"
|
|
361
|
+
|
|
362
|
+
unless @mounted_engine
|
|
363
|
+
say " #{step += 1}. Mount the standalone capture/receipt/withdrawal screens when you want them:"
|
|
364
|
+
say " # config/routes.rb"
|
|
365
|
+
say " mount Clickwrap::Engine => \"/agreements\""
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if any_ip_geolocation_field?
|
|
369
|
+
say " #{step + 1}. Ensure #{ip_geolocation_resolver_class_name} and its data source", :yellow
|
|
370
|
+
say " are available in every environment. The initializer uses the resolver you", :yellow
|
|
371
|
+
say " explicitly selected; Clickwrap refuses to boot with fields it cannot resolve.", :yellow
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
print_review_checklist
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
# Named out loud, because a schema that quietly contains seven tables an
|
|
380
|
+
# installation can never write to is a schema that claims capabilities and
|
|
381
|
+
# data categories that installation does not have. Saying which ones were
|
|
382
|
+
# left out — and that one flag brings each back later — is what makes the
|
|
383
|
+
# omission a decision rather than a surprise.
|
|
384
|
+
def say_optional_tables
|
|
385
|
+
written = requested_optional_table_migrations
|
|
386
|
+
omitted = OPTIONAL_TABLE_MIGRATIONS.reject { |_option, name| written.include?(name) }
|
|
387
|
+
return if omitted.empty?
|
|
388
|
+
|
|
389
|
+
say " Emitted #{written.length + 1} migration#{"s" unless written.empty?}. Not emitted, " \
|
|
390
|
+
"because nothing in this install can write to them:"
|
|
391
|
+
omitted.each_key { |option| say " #{OPTIONAL_TABLE_SUMMARIES.fetch(option)}" }
|
|
392
|
+
say " Add any of them later by re-running this generator with the flag."
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Which optional migrations this run writes. The request-evidence annex is
|
|
396
|
+
# implied whenever the run turns a request-evidence field on: an
|
|
397
|
+
# installation that records IP addresses into a table it never created is
|
|
398
|
+
# not a schema choice, it is a broken install, and the operator already
|
|
399
|
+
# answered the question that matters.
|
|
400
|
+
def requested_optional_table_migrations
|
|
401
|
+
OPTIONAL_TABLE_MIGRATIONS.filter_map do |option, name|
|
|
402
|
+
next name if options[option]
|
|
403
|
+
next name if option == :with_request_evidence && any_request_evidence_field?
|
|
404
|
+
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def any_request_evidence_field?
|
|
410
|
+
record_ip_addresses? || record_browser_user_agents? || any_ip_geolocation_field?
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# The step nothing else can stand in for. `form.clickwrap` renders the
|
|
414
|
+
# policy and binds the submit action, but the form is only half the
|
|
415
|
+
# circuit: some line in the signup door has to be the one that creates
|
|
416
|
+
# the account and writes the evidence in the same transaction. Leave it
|
|
417
|
+
# out and everything still LOOKS right — the checkbox renders, the person
|
|
418
|
+
# ticks it, the account is created — and there is no evidence at all.
|
|
419
|
+
#
|
|
420
|
+
# This is the one instruction whose omission is silent, so it is printed
|
|
421
|
+
# in every run, with the host's own file path and class name filled in.
|
|
422
|
+
def say_the_authentication_door_step(step)
|
|
423
|
+
say " #{step += 1}. Record the acceptance in the door that creates the account."
|
|
424
|
+
say " ⚠️ Nothing else does this. Without the line below the form still", :yellow
|
|
425
|
+
say " renders and the box still ticks, and every account is created with", :yellow
|
|
426
|
+
say " no evidence — silently.", :yellow
|
|
427
|
+
|
|
428
|
+
say_devise_door_step if devise_detected?
|
|
429
|
+
say_generic_door_step
|
|
430
|
+
|
|
431
|
+
step
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def say_devise_door_step
|
|
435
|
+
say " # #{devise_registrations_controller_path}"
|
|
436
|
+
say " class #{devise_registrations_controller_class_name} < Devise::RegistrationsController"
|
|
437
|
+
say " clickwraps_registration_with :signup"
|
|
438
|
+
say " end"
|
|
439
|
+
say " # config/routes.rb — Devise has to route at that subclass:"
|
|
440
|
+
say " devise_for :#{devise_scope_name}, controllers: " \
|
|
441
|
+
"{ registrations: \"#{devise_registrations_controller_path_for_routes}\" }"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def say_generic_door_step
|
|
445
|
+
say " # Rails authentication, an OAuth finish screen, a service object —"
|
|
446
|
+
say " # any door that builds the record itself:"
|
|
447
|
+
say " unless register_with_clickwrap(:signup, user: @user) { @user.save! }"
|
|
448
|
+
say " return render :new, status: :unprocessable_entity"
|
|
449
|
+
say " end"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Devise's own convention scopes the registrations controller under the
|
|
453
|
+
# plural of the mapped model, so a `User` mapping is served by
|
|
454
|
+
# Users::RegistrationsController.
|
|
455
|
+
def devise_scope_name
|
|
456
|
+
(actor_class_name || "User").underscore.tr("/", "_").pluralize
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def devise_registrations_controller_class_name
|
|
460
|
+
"#{(actor_class_name || "User").pluralize}::RegistrationsController"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def devise_registrations_controller_path_for_routes
|
|
464
|
+
"#{(actor_class_name || "User").underscore.pluralize}/registrations"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def devise_registrations_controller_path
|
|
468
|
+
"app/controllers/#{devise_registrations_controller_path_for_routes}_controller.rb"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def migration_version
|
|
472
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Said only when a detected page could not name its own version. What was
|
|
476
|
+
# generated boots — it carries an explicit label — but the label now lives
|
|
477
|
+
# somewhere other than the text it describes, and that is exactly the pair
|
|
478
|
+
# that drifts apart the next time someone edits the page in a hurry.
|
|
479
|
+
def say_documents_needing_an_explicit_version
|
|
480
|
+
paths = documents_needing_an_explicit_version
|
|
481
|
+
return if paths.empty?
|
|
482
|
+
|
|
483
|
+
say " ⚠️ #{paths.join(" and ")}", :yellow
|
|
484
|
+
say " #{paths.one? ? "has" : "have"} no `clickwrap_version:` or `last_updated:` " \
|
|
485
|
+
"front-matter key, so", :yellow
|
|
486
|
+
say " config/clickwrap.rb carries an explicit `version: \"#{Date.today.iso8601}\"` " \
|
|
487
|
+
"for #{paths.one? ? "it" : "them"}.", :yellow
|
|
488
|
+
say " Move that label into the page's own front matter and delete the line, so", :yellow
|
|
489
|
+
say " the text names its version and there is only one copy of the label.", :yellow
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# --- The review checklist -------------------------------------------------
|
|
493
|
+
|
|
494
|
+
# Deliberately a list of things a human still has to do. The installer
|
|
495
|
+
# copied files; it did not review anything, and it is not in a position to
|
|
496
|
+
# tell anyone their agreements are in order.
|
|
497
|
+
def print_review_checklist
|
|
498
|
+
say "\nBefore this carries any weight, review — with counsel where that applies:"
|
|
499
|
+
if detected_legal_documents
|
|
500
|
+
say " ☐ Legal text — the Terms and Privacy Notice are yours; the gem points at your existing pages."
|
|
501
|
+
else
|
|
502
|
+
say " ☐ Legal text — the Terms and Privacy Notice are yours; the gem wrote placeholders."
|
|
503
|
+
end
|
|
504
|
+
say " ☐ Policy semantics — is each act the right verb? agree_to for terms, acknowledge"
|
|
505
|
+
say " for a notice, consent_to only where consent is genuinely your chosen basis,"
|
|
506
|
+
say " declare/attest for statements of fact, authorize for one protected action."
|
|
507
|
+
say " ☐ Lawful basis and, where applicable, a data-protection impact assessment for"
|
|
508
|
+
say " every request-evidence field you enabled."
|
|
509
|
+
say " ☐ Retention periods — the generated ones are placeholders, not recommendations."
|
|
510
|
+
say " ☐ Your privacy notice — does it describe what you now record, and for how long?"
|
|
511
|
+
say " ☐ Trusted proxies — Clickwrap reads request.remote_ip; if you record IP addresses,"
|
|
512
|
+
say " verify and TEST your ActionDispatch::RemoteIp configuration behind your"
|
|
513
|
+
say " load balancer or CDN, or you are recording a header someone else controls."
|
|
514
|
+
say " ☐ The whole page — placement, wording, contrast, the call to action, and"
|
|
515
|
+
say " accessibility of the screen your controls appear on. Clickwrap renders the"
|
|
516
|
+
say " controls; the page around them is yours."
|
|
517
|
+
say " ☐ Styles — add <%= stylesheet_link_tag \"clickwrap\" %> to the layouts that render"
|
|
518
|
+
say " clickwrap blocks (the engine's own screens emit their styles themselves)."
|
|
519
|
+
say " Every rule is scoped under .clickwrap, so it cannot repaint the rest of your"
|
|
520
|
+
say " page. Rather own the CSS? `rails generate clickwrap:views` ejects the templates."
|
|
521
|
+
say " ☐ Tests — a real signup, a refused submit, and a forced evidence-write failure"
|
|
522
|
+
say " that proves the account is not created without its evidence."
|
|
523
|
+
say "\nClickwrap records evidence mechanics and keeps them verifiable. The words, the"
|
|
524
|
+
say "lawful basis, the periods, and the legal judgment stay yours.\n", :green
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# --- Detection ------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def database_adapter
|
|
530
|
+
return @database_adapter if defined?(@database_adapter)
|
|
531
|
+
|
|
532
|
+
@database_adapter = ActiveRecord::Base.connection_db_config.adapter.to_s
|
|
533
|
+
rescue StandardError
|
|
534
|
+
@database_adapter = nil
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def supported_adapter?
|
|
538
|
+
SUPPORTED_ADAPTERS.include?(database_adapter.to_s.downcase)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# The same setting `rails g model` reads, so an application generated with
|
|
542
|
+
# `g.orm :active_record, primary_key_type: :uuid` gets uuid clickwrap
|
|
543
|
+
# tables and uuid foreign keys without being asked.
|
|
544
|
+
def primary_key_type
|
|
545
|
+
return @primary_key_type if defined?(@primary_key_type)
|
|
546
|
+
|
|
547
|
+
generators = Rails.configuration.generators
|
|
548
|
+
@primary_key_type = generators.options[generators.orm][:primary_key_type]
|
|
549
|
+
rescue StandardError
|
|
550
|
+
@primary_key_type = nil
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def primary_key_type_description
|
|
554
|
+
primary_key_type ? primary_key_type.to_s : "integer (the Rails default)"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def devise_detected?
|
|
558
|
+
return @devise_detected if defined?(@devise_detected)
|
|
559
|
+
|
|
560
|
+
@devise_detected = defined?(::Devise) ? true : false
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# The class names behind the host's `devise_for` scopes, as strings. Never
|
|
564
|
+
# constantized: a generator has no business coupling itself to the host's
|
|
565
|
+
# model boot order.
|
|
566
|
+
def devise_actor_class_names
|
|
567
|
+
return @devise_actor_class_names if defined?(@devise_actor_class_names)
|
|
568
|
+
return @devise_actor_class_names = [] unless devise_detected?
|
|
569
|
+
|
|
570
|
+
@devise_actor_class_names = ::Devise.mappings.values.map { |mapping| mapping.class_name.to_s }.uniq.sort
|
|
571
|
+
rescue StandardError
|
|
572
|
+
@devise_actor_class_names = []
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Rails 8's `bin/rails generate authentication` writes both an
|
|
576
|
+
# `app/models/session.rb` and an `Authentication` concern. Requiring both
|
|
577
|
+
# keeps an unrelated Session model in an older application from being read
|
|
578
|
+
# as omakase authentication.
|
|
579
|
+
def rails_authentication_detected?
|
|
580
|
+
return @rails_authentication_detected if defined?(@rails_authentication_detected)
|
|
581
|
+
|
|
582
|
+
@rails_authentication_detected =
|
|
583
|
+
host_file?("app/models/session.rb") && host_file?("app/controllers/concerns/authentication.rb")
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def detected_authentication
|
|
587
|
+
return @detected_authentication if defined?(@detected_authentication)
|
|
588
|
+
|
|
589
|
+
detected = []
|
|
590
|
+
detected << "Rails authentication" if rails_authentication_detected?
|
|
591
|
+
detected << "Devise" if devise_detected?
|
|
592
|
+
@detected_authentication = detected.any? ? detected.join(" + ") : nil
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def actor_class_name
|
|
596
|
+
return @actor_class_name if defined?(@actor_class_name)
|
|
597
|
+
|
|
598
|
+
@actor_class_name, @actor_class_reason = infer_actor_class
|
|
599
|
+
@actor_class_name
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def actor_class_reason
|
|
603
|
+
actor_class_name
|
|
604
|
+
@actor_class_reason
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# The reason travels into the generated initializer as a comment, so it is
|
|
608
|
+
# wrapped rather than left as one long line in someone else's file.
|
|
609
|
+
def actor_class_reason_comment
|
|
610
|
+
lines = actor_class_reason.to_s.split.each_with_object([""]) do |word, wrapped|
|
|
611
|
+
if wrapped.last.empty?
|
|
612
|
+
wrapped[-1] = word
|
|
613
|
+
elsif wrapped.last.length + word.length + 1 <= 72
|
|
614
|
+
wrapped[-1] = "#{wrapped.last} #{word}"
|
|
615
|
+
else
|
|
616
|
+
wrapped << word
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
lines.map { |line| " # #{line}" }.join("\n")
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Inference stops at the first ambiguity. "Probably User" is not a good
|
|
624
|
+
# enough answer for the question "whose agreement is this".
|
|
625
|
+
def infer_actor_class
|
|
626
|
+
explicit = options[:actor_class].to_s.strip
|
|
627
|
+
return [explicit, nil] unless explicit.empty?
|
|
628
|
+
|
|
629
|
+
names = devise_actor_class_names
|
|
630
|
+
|
|
631
|
+
if names.length > 1
|
|
632
|
+
return [nil, "Devise maps several models (#{names.join(", ")}), so there is no single " \
|
|
633
|
+
"conventional actor to choose."]
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
return [names.first, nil] if names.length == 1
|
|
637
|
+
return ["User", nil] if user_model?
|
|
638
|
+
|
|
639
|
+
[nil, "No `User` model was found, and nothing else identified itself as the conventional actor."]
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def user_model?
|
|
643
|
+
host_file?("app/models/user.rb") || (defined?(::User) ? true : false)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def current_actor_method_name
|
|
647
|
+
return "current_user" unless actor_class_name
|
|
648
|
+
|
|
649
|
+
"current_#{actor_class_name.underscore.tr("/", "_")}"
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Whether what we detected is what the gem already assumes. When it is,
|
|
653
|
+
# the initializer shows the setting commented with its value rather than
|
|
654
|
+
# writing a live line that changes nothing: a file full of restated
|
|
655
|
+
# defaults reads as a file full of decisions, and then nobody can tell
|
|
656
|
+
# which lines were actually chosen.
|
|
657
|
+
def default_identity_settings?
|
|
658
|
+
actor_class_name == "User" && current_actor_method_name == "current_user"
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def host_file?(path)
|
|
662
|
+
File.exist?(File.expand_path(path, destination_root))
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def detected_legal_documents
|
|
666
|
+
return @detected_legal_documents if defined?(@detected_legal_documents)
|
|
667
|
+
|
|
668
|
+
@detected_legal_documents = EXISTING_LEGAL_CONVENTIONS.find do |convention|
|
|
669
|
+
host_file?(File.join(convention[:dir], convention[:terms])) &&
|
|
670
|
+
host_file?(File.join(convention[:dir], convention[:privacy]))
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# The paths the generated config's `from:` lines point at — the app's own
|
|
675
|
+
# legal files when they exist, the freshly written placeholders otherwise.
|
|
676
|
+
def terms_document_path
|
|
677
|
+
convention = detected_legal_documents
|
|
678
|
+
convention ? File.join(convention[:dir], convention[:terms]) : "app/content/legal/terms.md"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def privacy_document_path
|
|
682
|
+
convention = detected_legal_documents
|
|
683
|
+
convention ? File.join(convention[:dir], convention[:privacy]) : "app/content/legal/privacy.md"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# A document declared without `version:` reads its label from the file's
|
|
687
|
+
# own leading front matter, which is where a version label belongs: in the
|
|
688
|
+
# file that IS the legal text, with nothing to drift against. The
|
|
689
|
+
# placeholders this installer writes carry one.
|
|
690
|
+
#
|
|
691
|
+
# An application's existing legal pages may not, and a versionless
|
|
692
|
+
# declaration over a page with no version key fails the next boot. So the
|
|
693
|
+
# generated declaration for THAT document carries an explicit label, plus
|
|
694
|
+
# the one line telling a developer where it really belongs.
|
|
695
|
+
def terms_document_names_its_own_version?
|
|
696
|
+
document_names_its_own_version?(terms_document_path)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def privacy_document_names_its_own_version?
|
|
700
|
+
document_names_its_own_version?(privacy_document_path)
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def documents_needing_an_explicit_version
|
|
704
|
+
{
|
|
705
|
+
terms_document_path => terms_document_names_its_own_version?,
|
|
706
|
+
privacy_document_path => privacy_document_names_its_own_version?
|
|
707
|
+
}.reject { |_path, names_its_own| names_its_own }.keys
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def document_names_its_own_version?(path)
|
|
711
|
+
# The placeholders this installer writes open with front matter, so a
|
|
712
|
+
# fresh install always has its version labels in the files themselves.
|
|
713
|
+
return true unless detected_legal_documents
|
|
714
|
+
|
|
715
|
+
@document_names_its_own_version ||= {}
|
|
716
|
+
return @document_names_its_own_version[path] if @document_names_its_own_version.key?(path)
|
|
717
|
+
|
|
718
|
+
@document_names_its_own_version[path] = front_matter_version_label_in(path).present?
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# An unreadable file answers the same way a file with no version key
|
|
722
|
+
# does. Writing the explicit label is the recoverable mistake; leaving a
|
|
723
|
+
# declaration that cannot resolve a version is a boot failure.
|
|
724
|
+
def front_matter_version_label_in(path)
|
|
725
|
+
Clickwrap::FrontMatter.version_label_in(
|
|
726
|
+
File.read(File.expand_path(path, destination_root))
|
|
727
|
+
)
|
|
728
|
+
rescue StandardError
|
|
729
|
+
nil
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# The lockfile rather than the Gemfile, because the lockfile is the bundle
|
|
733
|
+
# this application actually resolved. It is evidence that the gem is
|
|
734
|
+
# there, not proof that the legal pages render through it — which is why
|
|
735
|
+
# the generated setting also requires the application to be serving those
|
|
736
|
+
# exact pages, and why the worst case is a boot error naming the missing
|
|
737
|
+
# gem rather than a document published through the wrong pipeline.
|
|
738
|
+
def host_bundles_markdown_rails?
|
|
739
|
+
return @host_bundles_markdown_rails if defined?(@host_bundles_markdown_rails)
|
|
740
|
+
|
|
741
|
+
@host_bundles_markdown_rails =
|
|
742
|
+
begin
|
|
743
|
+
lockfile = File.expand_path("Gemfile.lock", destination_root)
|
|
744
|
+
File.exist?(lockfile) && File.read(lockfile).match?(/^\s+markdown-rails[\s(]/)
|
|
745
|
+
rescue StandardError
|
|
746
|
+
false
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Both halves have to be true for this to be the right default: the
|
|
751
|
+
# application renders its own legal pages through markdown-rails, and
|
|
752
|
+
# those same pages are the documents Clickwrap will publish. Then the
|
|
753
|
+
# snapshot people accept and the page they read come out of one renderer
|
|
754
|
+
# instead of two that have to be kept in agreement by hand.
|
|
755
|
+
def renders_documents_through_markdown_rails?
|
|
756
|
+
return false unless detected_legal_documents&.fetch(:rendered_by_the_application, false)
|
|
757
|
+
|
|
758
|
+
host_bundles_markdown_rails?
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def routes_file?
|
|
762
|
+
host_file?("config/routes.rb")
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def engine_already_mounted?
|
|
766
|
+
File.read(File.expand_path("config/routes.rb", destination_root)).include?("Clickwrap::Engine")
|
|
767
|
+
rescue StandardError
|
|
768
|
+
false
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def say_missing_routes_file
|
|
772
|
+
say "\n No config/routes.rb found, so nothing was mounted."
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def say_already_mounted
|
|
776
|
+
say "\n config/routes.rb already mounts Clickwrap::Engine — left untouched."
|
|
777
|
+
@mounted_engine = true
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Every other question this generator declines to ask non-interactively
|
|
781
|
+
# resolves to "collect nothing", because for personal data the quiet
|
|
782
|
+
# answer is the safe one. This question is the exception, and it is worth
|
|
783
|
+
# saying why out loud.
|
|
784
|
+
#
|
|
785
|
+
# Unmounted, the engine has no document route. Clickwrap refuses to sign a
|
|
786
|
+
# document link that resolves to nothing, so an unmounted install cannot
|
|
787
|
+
# present a policy at all — the failure is not "a screen is missing", it
|
|
788
|
+
# is "signup raises". Taking the [y/N] default here was politeness that
|
|
789
|
+
# produced a broken application, so a non-interactive run mounts and says
|
|
790
|
+
# so, and `--skip-routes` is the one flag that declines.
|
|
791
|
+
def mount_engine_without_asking
|
|
792
|
+
route "mount Clickwrap::Engine => \"/agreements\""
|
|
793
|
+
@mounted_engine = true
|
|
794
|
+
|
|
795
|
+
say "\n Mounted Clickwrap::Engine at \"/agreements\" in config/routes.rb (nothing was asked)."
|
|
796
|
+
say " Unmounted, there is no document route, and Clickwrap refuses to sign a document"
|
|
797
|
+
say " link that resolves to nothing — so an unmounted install cannot present a policy"
|
|
798
|
+
say " at all. Re-run with --skip-routes to decline, or delete the line if you route the"
|
|
799
|
+
say " documents yourself and bind that route with `document_version_path_with:`."
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def interactive?
|
|
803
|
+
return false if options[:skip_questions]
|
|
804
|
+
|
|
805
|
+
# No terminal, no questions: a piped or scripted run (CI, a provisioning
|
|
806
|
+
# script, an AI agent) would otherwise stream every prompt into a jumble
|
|
807
|
+
# and take the [y/N] defaults anyway. Skipping deliberately keeps the
|
|
808
|
+
# same collect-nothing outcome and says so once, instead of pretending
|
|
809
|
+
# a conversation happened.
|
|
810
|
+
unless $stdin.tty?
|
|
811
|
+
@announced_non_interactive ||= begin
|
|
812
|
+
say "Non-interactive run detected: skipping questions and writing the safe, " \
|
|
813
|
+
"collect-nothing defaults (same as --skip-questions).", :yellow
|
|
814
|
+
true
|
|
815
|
+
end
|
|
816
|
+
return false
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
true
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# --- The questions --------------------------------------------------------
|
|
823
|
+
|
|
824
|
+
def answers
|
|
825
|
+
@answers ||= {}
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def recipe
|
|
829
|
+
options[:request_evidence_recipe]
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def privacy_minimized_recipe?
|
|
833
|
+
recipe == "privacy-minimized"
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def request_evidence_option_keys
|
|
837
|
+
@request_evidence_option_keys ||= %i[
|
|
838
|
+
record_ip_addresses_by_default
|
|
839
|
+
record_browser_user_agents_by_default
|
|
840
|
+
record_ip_geolocation_fields
|
|
841
|
+
]
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# Privacy-minimized and --skip-questions are complete postures. Individual
|
|
845
|
+
# flags are not: in an interactive run, Clickwrap still asks about every
|
|
846
|
+
# category the operator did not explicitly decide.
|
|
847
|
+
def skip_request_evidence_questions?
|
|
848
|
+
!interactive? || privacy_minimized_recipe?
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def ask_request_evidence_questions
|
|
852
|
+
say "\nClickwrap records none of the following unless you say so here. Each one is a"
|
|
853
|
+
say "separate question because each one is a separate decision.\n"
|
|
854
|
+
|
|
855
|
+
ask_about_ip_addresses
|
|
856
|
+
ask_about_browser_user_agents
|
|
857
|
+
ask_about_ip_geolocation
|
|
858
|
+
ask_about_ip_geolocation_resolver
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
# Thor's prompt escapes newlines, so a multi-line question handed straight
|
|
862
|
+
# to `yes?` prints as one long line of literal \n. The question is said in
|
|
863
|
+
# full first, and only its last line — the one carrying [y/N] — becomes the
|
|
864
|
+
# prompt, which keeps the wording exactly as written.
|
|
865
|
+
def ask_question(text)
|
|
866
|
+
lines = text.strip.lines.map(&:chomp)
|
|
867
|
+
|
|
868
|
+
say ""
|
|
869
|
+
lines[0..-2].each { |line| say line }
|
|
870
|
+
yes?(lines.last)
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def ask_about_ip_addresses
|
|
874
|
+
if options[:record_ip_addresses_by_default].nil?
|
|
875
|
+
answers[:ip_address] = ask_question(<<~QUESTION)
|
|
876
|
+
Should Clickwrap record IP addresses by default?
|
|
877
|
+
IP addresses can help investigate disputes, but they are personal data and
|
|
878
|
+
need a documented purpose, access policy, and deletion schedule. [y/N]
|
|
879
|
+
QUESTION
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
return unless record_ip_addresses?
|
|
883
|
+
|
|
884
|
+
if options[:reason_for_recording_ip_addresses_by_default].nil?
|
|
885
|
+
answers[:ip_address_purpose] = ask_purpose("recording IP addresses")
|
|
886
|
+
end
|
|
887
|
+
return unless options[:delete_recorded_ip_addresses_after_days].nil?
|
|
888
|
+
|
|
889
|
+
answers[:ip_address_days] = ask_retention_days("recorded IP addresses")
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
def ask_about_browser_user_agents
|
|
893
|
+
if options[:record_browser_user_agents_by_default].nil?
|
|
894
|
+
answers[:browser_user_agent] = ask_question(<<~QUESTION)
|
|
895
|
+
Should Clickwrap record browser User-Agent headers by default?
|
|
896
|
+
The value is supplied by the browser, may be spoofed, and is not a unique
|
|
897
|
+
device identity. [y/N]
|
|
898
|
+
QUESTION
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
return unless record_browser_user_agents?
|
|
902
|
+
|
|
903
|
+
if options[:reason_for_recording_browser_user_agents_by_default].nil?
|
|
904
|
+
answers[:browser_user_agent_purpose] = ask_purpose("recording browser User-Agent strings")
|
|
905
|
+
end
|
|
906
|
+
return unless options[:delete_recorded_browser_user_agents_after_days].nil?
|
|
907
|
+
|
|
908
|
+
answers[:browser_user_agent_days] = ask_retention_days("recorded browser User-Agent strings")
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def ask_about_ip_geolocation
|
|
912
|
+
# Supplying any geolocation field on the command line makes that list an
|
|
913
|
+
# explicit allowlist. Asking about the remaining fields would make a
|
|
914
|
+
# non-interactive-looking command unexpectedly collect more data.
|
|
915
|
+
return if ip_geolocation_options_supplied?
|
|
916
|
+
|
|
917
|
+
answers["country"] = ask_question(<<~QUESTION)
|
|
918
|
+
Should Clickwrap estimate and record a country from each IP address?
|
|
919
|
+
This is an estimate for the IP address, not the person's physical location.
|
|
920
|
+
It requires an IP geolocation resolver such as trackdown. [y/N]
|
|
921
|
+
QUESTION
|
|
922
|
+
|
|
923
|
+
say <<~FRAMING
|
|
924
|
+
|
|
925
|
+
Should Clickwrap estimate and record region, city, or postal-code fields?
|
|
926
|
+
Each field is provider-estimated from the IP address, may be inaccurate, and
|
|
927
|
+
must have a documented purpose and retention rule. Select fields individually.
|
|
928
|
+
FRAMING
|
|
929
|
+
|
|
930
|
+
answers["region"] = yes?(" Record the estimated region? [y/N]")
|
|
931
|
+
answers["city"] = yes?(" Record the estimated city? [y/N]")
|
|
932
|
+
answers["postal_code"] = yes?(" Record the estimated postal code? [y/N]")
|
|
933
|
+
|
|
934
|
+
answers["latitude_and_longitude"] = ask_question(<<~QUESTION)
|
|
935
|
+
Should Clickwrap record provider-estimated latitude and longitude?
|
|
936
|
+
These coordinates describe an approximate IP-network location, not GPS or the
|
|
937
|
+
person's physical position. Accuracy radius is stored when available. [y/N]
|
|
938
|
+
QUESTION
|
|
939
|
+
|
|
940
|
+
ask_about_remaining_ip_geolocation_fields
|
|
941
|
+
ask_about_ip_geolocation_purpose
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# The remaining fields get the same treatment as the ones above: named
|
|
945
|
+
# individually, estimated, and off unless asked for.
|
|
946
|
+
def ask_about_remaining_ip_geolocation_fields
|
|
947
|
+
say <<~FRAMING
|
|
948
|
+
|
|
949
|
+
Clickwrap can also record four smaller provider-estimated fields. Each is an
|
|
950
|
+
estimate about the IP address and needs the same purpose and retention rule
|
|
951
|
+
as the ones above.
|
|
952
|
+
FRAMING
|
|
953
|
+
|
|
954
|
+
answers["timezone"] = yes?(" Record the estimated timezone? [y/N]")
|
|
955
|
+
answers["continent"] = yes?(" Record the estimated continent? [y/N]")
|
|
956
|
+
answers["metro_code"] = yes?(" Record the estimated metro code? [y/N]")
|
|
957
|
+
answers["accuracy_radius_in_kilometers"] = ask_question(<<~QUESTION)
|
|
958
|
+
Record the accuracy radius in kilometers? Keeping it is how a later reader
|
|
959
|
+
can tell how uncertain the estimate above actually was. [y/N]
|
|
960
|
+
QUESTION
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# One purpose and one retention rule cover the IP-geolocation category:
|
|
964
|
+
# the fields are separate decisions about what to keep, but they are all
|
|
965
|
+
# resolved from the same address by the same provider at the same moment.
|
|
966
|
+
def ask_about_ip_geolocation_purpose
|
|
967
|
+
return unless any_ip_geolocation_field?
|
|
968
|
+
|
|
969
|
+
if options[:reason_for_recording_ip_geolocation_by_default].nil?
|
|
970
|
+
answers[:ip_geolocation_purpose] = ask_purpose("recording IP geolocation")
|
|
971
|
+
end
|
|
972
|
+
return unless options[:delete_recorded_ip_geolocation_after_days].nil?
|
|
973
|
+
|
|
974
|
+
answers[:ip_geolocation_days] = ask_retention_days("recorded IP geolocation")
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def ask_purpose(label)
|
|
978
|
+
say "\n Why does the application need #{label}? One plain sentence, in your own"
|
|
979
|
+
say " words — it goes into the initializer and the privacy inventory. A blank"
|
|
980
|
+
say " or scaffolding answer stops generation before Clickwrap writes any files."
|
|
981
|
+
ask(" Purpose:").to_s.strip
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def ask_retention_days(label)
|
|
985
|
+
say "\n After how many days should Clickwrap delete #{label}?"
|
|
986
|
+
say " Clickwrap does not invent a period. Enter the positive number your application"
|
|
987
|
+
say " has reviewed; a blank or zero answer stops generation before files are written."
|
|
988
|
+
ask(" Days:").to_s.strip.to_i
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def ask_about_ip_geolocation_resolver
|
|
992
|
+
return unless any_ip_geolocation_field?
|
|
993
|
+
return unless options[:ip_geolocation_resolver_class_name].nil?
|
|
994
|
+
|
|
995
|
+
say "\n Which resolver class should Clickwrap instantiate for IP geolocation?"
|
|
996
|
+
say " Example: Clickwrap::IpGeolocation::TrackdownResolver (requires `trackdown`)."
|
|
997
|
+
answers[:ip_geolocation_resolver_class_name] = ask(" Resolver class:").to_s.strip
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
# --- Resolved answers (flag > question > privacy-minimized/off) -----------
|
|
1001
|
+
|
|
1002
|
+
def record_ip_addresses?
|
|
1003
|
+
resolve_record_choice(:record_ip_addresses_by_default, :ip_address)
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def record_browser_user_agents?
|
|
1007
|
+
resolve_record_choice(:record_browser_user_agents_by_default, :browser_user_agent)
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def record_ip_geolocation_field?(field)
|
|
1011
|
+
requested = requested_ip_geolocation_fields
|
|
1012
|
+
return requested.include?(field.to_s) if requested
|
|
1013
|
+
return false if privacy_minimized_recipe?
|
|
1014
|
+
|
|
1015
|
+
answers.fetch(field, false) == true
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# The allowlist from `--record-ip-geolocation-fields`, or nil when the
|
|
1019
|
+
# operator did not use the flag at all. Accepts either shell convention —
|
|
1020
|
+
# `--record-ip-geolocation-fields city country` and
|
|
1021
|
+
# `--record-ip-geolocation-fields=city,country` — because getting that
|
|
1022
|
+
# wrong would silently record a field named "city,country", which is to
|
|
1023
|
+
# say none.
|
|
1024
|
+
#
|
|
1025
|
+
# A misspelled field stops the generator before it writes anything. The
|
|
1026
|
+
# alternative is an install that quietly records less than the operator
|
|
1027
|
+
# asked for, discovered years later by the person who needed the evidence.
|
|
1028
|
+
def requested_ip_geolocation_fields
|
|
1029
|
+
return @requested_ip_geolocation_fields if defined?(@requested_ip_geolocation_fields)
|
|
1030
|
+
|
|
1031
|
+
raw = options[:record_ip_geolocation_fields]
|
|
1032
|
+
return @requested_ip_geolocation_fields = nil if raw.nil?
|
|
1033
|
+
|
|
1034
|
+
fields = Array(raw).flat_map { |value| value.to_s.split(",") }
|
|
1035
|
+
.map { |value| value.strip.downcase }.reject(&:empty?).uniq
|
|
1036
|
+
unknown = fields - IP_GEOLOCATION_FIELDS.map(&:to_s)
|
|
1037
|
+
|
|
1038
|
+
unless unknown.empty?
|
|
1039
|
+
raise Thor::Error,
|
|
1040
|
+
"--record-ip-geolocation-fields does not know " \
|
|
1041
|
+
"#{unknown.map(&:inspect).join(", ")}. The fields Clickwrap can record are: " \
|
|
1042
|
+
"#{IP_GEOLOCATION_FIELDS.join(", ")}. Nothing was written."
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
@requested_ip_geolocation_fields = fields
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def resolve_record_choice(option_key, answer_key)
|
|
1049
|
+
return options[option_key] unless options[option_key].nil?
|
|
1050
|
+
return false if privacy_minimized_recipe?
|
|
1051
|
+
|
|
1052
|
+
answers.fetch(answer_key, false) == true
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def ip_geolocation_fields
|
|
1056
|
+
Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def enabled_ip_geolocation_fields
|
|
1060
|
+
ip_geolocation_fields.select { |field| record_ip_geolocation_field?(field) }
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def any_ip_geolocation_field?
|
|
1064
|
+
enabled_ip_geolocation_fields.any?
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def records_any_request_evidence?
|
|
1068
|
+
record_ip_addresses? || record_browser_user_agents? || any_ip_geolocation_field?
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def records_ip_derived_request_evidence?
|
|
1072
|
+
record_ip_addresses? || any_ip_geolocation_field?
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def ip_geolocation_options_supplied?
|
|
1076
|
+
!requested_ip_geolocation_fields.nil?
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def delete_recorded_ip_addresses_after_days
|
|
1080
|
+
retention_days(:delete_recorded_ip_addresses_after_days, :ip_address_days)
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def delete_recorded_browser_user_agents_after_days
|
|
1084
|
+
retention_days(:delete_recorded_browser_user_agents_after_days, :browser_user_agent_days)
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def delete_recorded_ip_geolocation_after_days
|
|
1088
|
+
retention_days(:delete_recorded_ip_geolocation_after_days, :ip_geolocation_days)
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
def retention_days(option_key, answer_key)
|
|
1092
|
+
(options[option_key] || answers[answer_key]).to_i
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def purpose_for(option_key, answer_key)
|
|
1096
|
+
(options[option_key] || answers[answer_key]).to_s.strip
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def reason_for_recording_ip_addresses
|
|
1100
|
+
purpose_for(:reason_for_recording_ip_addresses_by_default, :ip_address_purpose)
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def reason_for_recording_browser_user_agents
|
|
1104
|
+
purpose_for(:reason_for_recording_browser_user_agents_by_default, :browser_user_agent_purpose)
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
def reason_for_recording_ip_geolocation
|
|
1108
|
+
purpose_for(:reason_for_recording_ip_geolocation_by_default, :ip_geolocation_purpose)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
def trusted_proxy_configuration_digest
|
|
1112
|
+
(options[:trusted_proxy_configuration_digest] ||
|
|
1113
|
+
answers[:trusted_proxy_configuration_digest]).to_s.strip.presence
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def ip_geolocation_resolver_class_name
|
|
1117
|
+
(options[:ip_geolocation_resolver_class_name] ||
|
|
1118
|
+
answers[:ip_geolocation_resolver_class_name]).to_s.strip.presence
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
# Every enabled personal-data category must be complete before Thor moves
|
|
1122
|
+
# on to its first file-writing task. This makes the command atomic from the
|
|
1123
|
+
# host developer's point of view: either it writes a bootable initializer
|
|
1124
|
+
# containing their decisions, or it writes nothing.
|
|
1125
|
+
def validate_request_evidence_choices!
|
|
1126
|
+
# First, because a misspelled field name has to stop the run before any
|
|
1127
|
+
# of the checks below reason about a list that is missing an entry.
|
|
1128
|
+
requested_ip_geolocation_fields
|
|
1129
|
+
|
|
1130
|
+
validate_enabled_category!(
|
|
1131
|
+
"IP addresses",
|
|
1132
|
+
enabled: record_ip_addresses?,
|
|
1133
|
+
because: reason_for_recording_ip_addresses,
|
|
1134
|
+
delete_after_days: delete_recorded_ip_addresses_after_days,
|
|
1135
|
+
reason_option: "--reason-for-recording-ip-addresses-by-default",
|
|
1136
|
+
retention_option: "--delete-recorded-ip-addresses-after-days"
|
|
1137
|
+
)
|
|
1138
|
+
validate_enabled_category!(
|
|
1139
|
+
"browser User-Agent strings",
|
|
1140
|
+
enabled: record_browser_user_agents?,
|
|
1141
|
+
because: reason_for_recording_browser_user_agents,
|
|
1142
|
+
delete_after_days: delete_recorded_browser_user_agents_after_days,
|
|
1143
|
+
reason_option: "--reason-for-recording-browser-user-agents-by-default",
|
|
1144
|
+
retention_option: "--delete-recorded-browser-user-agents-after-days"
|
|
1145
|
+
)
|
|
1146
|
+
validate_enabled_category!(
|
|
1147
|
+
"IP geolocation",
|
|
1148
|
+
enabled: any_ip_geolocation_field?,
|
|
1149
|
+
because: reason_for_recording_ip_geolocation,
|
|
1150
|
+
delete_after_days: delete_recorded_ip_geolocation_after_days,
|
|
1151
|
+
reason_option: "--reason-for-recording-ip-geolocation-by-default",
|
|
1152
|
+
retention_option: "--delete-recorded-ip-geolocation-after-days"
|
|
1153
|
+
)
|
|
1154
|
+
validate_ip_geolocation_coordinates!
|
|
1155
|
+
validate_trusted_proxy_configuration!
|
|
1156
|
+
validate_ip_geolocation_resolver_class_name!
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
def validate_enabled_category!(label, enabled:, because:, delete_after_days:,
|
|
1160
|
+
reason_option:, retention_option:)
|
|
1161
|
+
return unless enabled
|
|
1162
|
+
|
|
1163
|
+
unless Clickwrap::ReviewedText.present_and_reviewed?(because)
|
|
1164
|
+
raise Thor::Error,
|
|
1165
|
+
"Clickwrap cannot enable #{label} with a blank or scaffolding reason. " \
|
|
1166
|
+
"Give the application's reviewed, present-tense reason with " \
|
|
1167
|
+
"#{reason_option}=\"...\", or turn that category off. No files were written."
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
return if delete_after_days.positive?
|
|
1171
|
+
|
|
1172
|
+
raise Thor::Error,
|
|
1173
|
+
"Clickwrap cannot enable #{label} without a positive deletion period. " \
|
|
1174
|
+
"Set #{retention_option}=DAYS to the period your application reviewed, or " \
|
|
1175
|
+
"turn that category off. No files were written."
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
def validate_ip_geolocation_coordinates!
|
|
1179
|
+
return unless record_ip_geolocation_field?("latitude_and_longitude")
|
|
1180
|
+
return if record_ip_geolocation_field?("accuracy_radius_in_kilometers")
|
|
1181
|
+
|
|
1182
|
+
raise Thor::Error,
|
|
1183
|
+
"Clickwrap cannot record provider-estimated latitude and longitude without " \
|
|
1184
|
+
"their accuracy radius. Add " \
|
|
1185
|
+
"--record-ip-geolocation-accuracy-radius-in-kilometers-by-default, or turn " \
|
|
1186
|
+
"coordinates off. No files were written."
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def validate_trusted_proxy_configuration!
|
|
1190
|
+
digest = trusted_proxy_configuration_digest
|
|
1191
|
+
return if digest.nil?
|
|
1192
|
+
return if Clickwrap::Digest.well_formed?(digest)
|
|
1193
|
+
|
|
1194
|
+
raise Thor::Error,
|
|
1195
|
+
"--trusted-proxy-configuration-digest must be a complete prefixed SHA-2 digest " \
|
|
1196
|
+
"(for example sha256: followed by 64 lowercase hexadecimal characters). " \
|
|
1197
|
+
"Omit it to derive provenance from Rails' effective trusted-proxy rules, or " \
|
|
1198
|
+
"supply the reviewed digest and try again. No files were written."
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def validate_ip_geolocation_resolver_class_name!
|
|
1202
|
+
class_name = ip_geolocation_resolver_class_name
|
|
1203
|
+
return if class_name.nil? && !any_ip_geolocation_field?
|
|
1204
|
+
return if class_name&.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
|
|
1205
|
+
|
|
1206
|
+
raise Thor::Error,
|
|
1207
|
+
"Recording IP geolocation requires --ip-geolocation-resolver-class-name with " \
|
|
1208
|
+
"a Ruby class name such as Clickwrap::IpGeolocation::TrackdownResolver. " \
|
|
1209
|
+
"Clickwrap will not choose a provider or dependency for the application. " \
|
|
1210
|
+
"No files were written."
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
# A date one year out, so an enabled field gets looked at again by someone
|
|
1214
|
+
# rather than quietly outliving the reason it was turned on.
|
|
1215
|
+
def review_request_evidence_configuration_on
|
|
1216
|
+
Date.today.next_year
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
def review_date_literal
|
|
1220
|
+
date = review_request_evidence_configuration_on
|
|
1221
|
+
"Date.new(#{date.year}, #{date.month}, #{date.day})"
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# --- The pre-write summary ------------------------------------------------
|
|
1225
|
+
|
|
1226
|
+
def summarize_request_evidence_choices
|
|
1227
|
+
say "\nRequest evidence Clickwrap will record BY DEFAULT for every policy:"
|
|
1228
|
+
|
|
1229
|
+
unless records_any_request_evidence?
|
|
1230
|
+
say " • nothing."
|
|
1231
|
+
say " That is the safe default and it stays true until you change it. An"
|
|
1232
|
+
say " individual policy can still enable a field it genuinely needs."
|
|
1233
|
+
return
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
summarize_category("IP address", record_ip_addresses?, reason_for_recording_ip_addresses,
|
|
1237
|
+
delete_recorded_ip_addresses_after_days)
|
|
1238
|
+
summarize_category("browser User-Agent", record_browser_user_agents?,
|
|
1239
|
+
reason_for_recording_browser_user_agents,
|
|
1240
|
+
delete_recorded_browser_user_agents_after_days)
|
|
1241
|
+
|
|
1242
|
+
if any_ip_geolocation_field?
|
|
1243
|
+
summarize_category("IP geolocation (#{enabled_ip_geolocation_fields.join(", ")})", true,
|
|
1244
|
+
reason_for_recording_ip_geolocation, delete_recorded_ip_geolocation_after_days)
|
|
1245
|
+
say " source: provider-estimated from the IP address — not GPS, not a street"
|
|
1246
|
+
say " address, and not proof of where anyone was. Provider name,"
|
|
1247
|
+
say " source, estimated state, and accuracy provenance are stored"
|
|
1248
|
+
say " alongside every value."
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
say "\n encryption: on (config.encrypt_recorded_* = true)."
|
|
1252
|
+
say " access: unredacted values are DENIED until you write"
|
|
1253
|
+
say " `authorize_unredacted_request_evidence_access_with`; every export"
|
|
1254
|
+
say " needs a human-readable reason and appends an access event."
|
|
1255
|
+
say " review on: #{review_request_evidence_configuration_on.iso8601}"
|
|
1256
|
+
say "\n These are the explicit settings you supplied, not a legal verdict. Review"
|
|
1257
|
+
say " them in the generated initializer before relying on them.", :yellow
|
|
1258
|
+
say " The recipe flag, if you used one, stops here: it expanded into the individual", :yellow
|
|
1259
|
+
say " settings written into the initializer and does not exist at runtime.", :yellow
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
def summarize_category(label, enabled, purpose, days)
|
|
1263
|
+
return unless enabled
|
|
1264
|
+
|
|
1265
|
+
say " • #{label}"
|
|
1266
|
+
say " purpose: #{purpose}"
|
|
1267
|
+
say " delete: after #{days} days"
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
# --- Files ----------------------------------------------------------------
|
|
1271
|
+
|
|
1272
|
+
# A template rather than a copy, because the placeholder opens with front
|
|
1273
|
+
# matter whose `last_updated:` is the document's version label, and the
|
|
1274
|
+
# only honest value for it is the day the file was written. That is also
|
|
1275
|
+
# why the generated declaration in config/clickwrap.rb needs no `version:`
|
|
1276
|
+
# at all: the file carrying the words names its own version.
|
|
1277
|
+
def write_legal_placeholder(source, destination)
|
|
1278
|
+
if host_file?(destination)
|
|
1279
|
+
say_status :skip, "#{destination} (you already have a file there)", :yellow
|
|
1280
|
+
return
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
template source, destination
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
end
|
|
1287
|
+
end
|