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,418 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# `bin/rails clickwrap:doctor` — a read-only diagnosis you can act on at 03:00.
|
|
5
|
+
#
|
|
6
|
+
# ✓ 6 policies compiled
|
|
7
|
+
# ✓ all referenced documents are published and digest-verified
|
|
8
|
+
# ✓ every required gate has a remediation route
|
|
9
|
+
# ✓ request-derived personal data is off by default
|
|
10
|
+
# ! withdrawal_authorization records IP geolocation city without a review date
|
|
11
|
+
# ! request-source trust is unverified
|
|
12
|
+
# ✓ no overdue disposition
|
|
13
|
+
# ✓ all checked event digests verify
|
|
14
|
+
#
|
|
15
|
+
# ============================================================================
|
|
16
|
+
# EVERY FINDING HERE IS AN OBJECTIVE FACT ABOUT CONFIGURATION OR DATA. This
|
|
17
|
+
# class must never print, return, or imply "compliant", "court-proof", "audit
|
|
18
|
+
# guaranteed", "approved", or "certified", and there is no overall verdict
|
|
19
|
+
# line: a run with no warnings means the specific things listed below were
|
|
20
|
+
# checked and nothing objectionable was found in them. It does not mean the
|
|
21
|
+
# application is lawful, that its retention periods are right, or that its
|
|
22
|
+
# evidence would persuade anyone. Those are not questions a library can
|
|
23
|
+
# answer, and a green tick that implied otherwise would be worse than no tool
|
|
24
|
+
# at all. PHRASES_THIS_REPORT_NEVER_PRINTS exists so a test can assert it.
|
|
25
|
+
# ============================================================================
|
|
26
|
+
class Doctor
|
|
27
|
+
Finding = Data.define(:status, :message) do
|
|
28
|
+
def ok? = status == :ok
|
|
29
|
+
def warning? = status == :warning
|
|
30
|
+
def problem? = status == :problem
|
|
31
|
+
|
|
32
|
+
def to_s = "#{SYMBOLS.fetch(status, "?")} #{message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
SYMBOLS = { ok: "✓", warning: "!", problem: "✗" }.freeze
|
|
36
|
+
|
|
37
|
+
# Greped by the release test against every message this class produces.
|
|
38
|
+
PHRASES_THIS_REPORT_NEVER_PRINTS = (
|
|
39
|
+
Vocabulary::PROHIBITED_CLAIM_PHRASES + [
|
|
40
|
+
"compliant",
|
|
41
|
+
"compliance",
|
|
42
|
+
"court proof",
|
|
43
|
+
"audit guaranteed",
|
|
44
|
+
"audit approved",
|
|
45
|
+
"certified",
|
|
46
|
+
"approved",
|
|
47
|
+
"enforceable",
|
|
48
|
+
"legally valid"
|
|
49
|
+
]
|
|
50
|
+
).freeze
|
|
51
|
+
|
|
52
|
+
# How many recent events get their digests recomputed. A doctor run is
|
|
53
|
+
# something an operator does interactively, so it samples rather than
|
|
54
|
+
# verifying the whole table; `clickwrap:verify` is the exhaustive one.
|
|
55
|
+
DIGEST_SAMPLE_SIZE = 100
|
|
56
|
+
|
|
57
|
+
def report
|
|
58
|
+
findings = []
|
|
59
|
+
findings.concat(policy_findings)
|
|
60
|
+
findings.concat(document_findings)
|
|
61
|
+
findings.concat(optional_table_findings)
|
|
62
|
+
findings.concat(engine_mount_findings)
|
|
63
|
+
findings.concat(gate_findings)
|
|
64
|
+
findings.concat(request_evidence_findings)
|
|
65
|
+
findings.concat(review_date_findings)
|
|
66
|
+
findings.concat(resolver_findings)
|
|
67
|
+
findings.concat(source_trust_findings)
|
|
68
|
+
findings.concat(with_database("disposition") { disposition_findings })
|
|
69
|
+
findings.concat(with_database("legal holds") { legal_hold_findings })
|
|
70
|
+
findings.concat(with_database("event digests") { digest_findings })
|
|
71
|
+
findings.concat(with_database("integrity attestations") { integrity_attestation_findings })
|
|
72
|
+
findings.concat(with_database("external actions") { external_action_findings })
|
|
73
|
+
findings
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The README's rendering: one line per finding, in the order they were
|
|
77
|
+
# produced, with nothing summarizing them into a verdict.
|
|
78
|
+
def to_s = report.join("\n")
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# --- Configuration --------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def policy_findings
|
|
85
|
+
count = Clickwrap.policies.size
|
|
86
|
+
|
|
87
|
+
if count.zero?
|
|
88
|
+
return [warning("no policies are compiled. Declare them with `Clickwrap.policy :key do ... end`, " \
|
|
89
|
+
"conventionally in config/clickwrap.rb.")]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[ok("#{count} #{pluralize(count, "policy", "policies")} compiled")]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Every document a policy references has to exist, be published, and still
|
|
96
|
+
# hash to what was recorded when it was published. A policy that presents a
|
|
97
|
+
# document nobody published is a runtime failure waiting for the first
|
|
98
|
+
# person who tries to sign up.
|
|
99
|
+
def document_findings
|
|
100
|
+
referenced = Clickwrap.policies.values.flat_map { |policy| policy.statements.flat_map(&:document_keys) }.uniq
|
|
101
|
+
return [ok("no policy references a document")] if referenced.empty?
|
|
102
|
+
|
|
103
|
+
problems = with_database("documents") { document_problems(referenced) }
|
|
104
|
+
return problems if problems.any?
|
|
105
|
+
|
|
106
|
+
[ok("all referenced documents are published and digest-verified " \
|
|
107
|
+
"(#{referenced.length} #{pluralize(referenced.length, "document", "documents")})")]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def document_problems(referenced)
|
|
111
|
+
referenced.flat_map do |key|
|
|
112
|
+
definitions = Clickwrap.documents.values.select { |definition| definition.key == key }
|
|
113
|
+
|
|
114
|
+
if definitions.empty?
|
|
115
|
+
next [problem("document :#{key} is referenced by a policy but never declared with " \
|
|
116
|
+
"`Clickwrap.document :#{key}, version: \"...\", from: ...`")]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
definitions.filter_map { |definition| document_problem(definition) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def document_problem(definition)
|
|
124
|
+
document = Document.find_by(document_key: definition.key, tenant_key: definition.tenant_key)
|
|
125
|
+
version = document&.versions&.find_by(version_label: definition.version_label, locale: definition.locale)
|
|
126
|
+
|
|
127
|
+
if version.nil? || !version.published?
|
|
128
|
+
return problem("document #{definition} is declared but not published. Run " \
|
|
129
|
+
"`bin/rails clickwrap:publish`.")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return nil if version.verify_content_digest
|
|
133
|
+
|
|
134
|
+
problem("the stored bytes for document #{definition} no longer match the digest recorded " \
|
|
135
|
+
"when it was published, so evidence citing it cannot be reproduced")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A default install emits only the tables it can put a row in; each further
|
|
139
|
+
# capability brings its own migration. So this reports two different facts,
|
|
140
|
+
# and never conflates them: a capability that is ON with its tables missing
|
|
141
|
+
# is a problem with a command attached, and a capability that is simply not
|
|
142
|
+
# installed is a fact about this installation, stated once so an operator
|
|
143
|
+
# reading at 03:00 does not go looking for a table that was never meant to
|
|
144
|
+
# be there.
|
|
145
|
+
def optional_table_findings
|
|
146
|
+
SchemaRequirements.reset!
|
|
147
|
+
missing = SchemaRequirements.missing_for_configuration
|
|
148
|
+
|
|
149
|
+
SchemaRequirements::FEATURES.map do |feature|
|
|
150
|
+
installed = SchemaRequirements.installed?(feature)
|
|
151
|
+
|
|
152
|
+
if installed.nil?
|
|
153
|
+
warning("could not check whether the #{feature.flag} tables exist (no database)")
|
|
154
|
+
elsif installed
|
|
155
|
+
ok("the #{feature.flag} tables are installed")
|
|
156
|
+
elsif missing.include?(feature)
|
|
157
|
+
problem(feature.explanation)
|
|
158
|
+
else
|
|
159
|
+
ok("the #{feature.flag} tables are not installed, and nothing configured needs them")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The mount is not only about gates. Every presentation links each document
|
|
165
|
+
# at its immutable engine route, and that link is signed into the manifest
|
|
166
|
+
# and kept as the exact document the person was offered. Unmounted, there
|
|
167
|
+
# is no such route: presenting refuses rather than signing a dead link, so
|
|
168
|
+
# a compiled policy on an unmounted application is a broken signup screen,
|
|
169
|
+
# not a stylistic choice.
|
|
170
|
+
def engine_mount_findings
|
|
171
|
+
return [] if Clickwrap.policies.empty?
|
|
172
|
+
return [ok("Clickwrap::Engine is mounted, so every document link a presentation signs resolves")] \
|
|
173
|
+
if engine_mounted?
|
|
174
|
+
|
|
175
|
+
[problem("Clickwrap::Engine is not mounted, so presenting any of the " \
|
|
176
|
+
"#{Clickwrap.policies.size} compiled #{pluralize(Clickwrap.policies.size, "policy",
|
|
177
|
+
"policies")} refuses rather than " \
|
|
178
|
+
"sign a document link that resolves to nothing. Mount it with " \
|
|
179
|
+
"`mount Clickwrap::Engine => \"/agreements\"`, or bind your own document route into " \
|
|
180
|
+
"every presentation with `document_version_path_with:`.")]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A `requires_clickwrap` gate refuses to compile without somewhere to send
|
|
184
|
+
# the person it stops, so the objective question left for the doctor is
|
|
185
|
+
# whether the engine — the default destination — is actually mounted.
|
|
186
|
+
def gate_findings
|
|
187
|
+
return [ok("every required gate has a remediation route (Clickwrap::Engine is mounted)")] if engine_mounted?
|
|
188
|
+
|
|
189
|
+
[warning("Clickwrap::Engine is not mounted, so every `requires_clickwrap` gate needs its own " \
|
|
190
|
+
"`remediation_path:`. Mount it with `mount Clickwrap::Engine => \"/agreements\"` or " \
|
|
191
|
+
"point each gate at a page you own.")]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def engine_mounted?
|
|
195
|
+
ControllerHelpers.engine_is_mounted?
|
|
196
|
+
rescue StandardError
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def request_evidence_findings
|
|
201
|
+
config = Clickwrap.config
|
|
202
|
+
|
|
203
|
+
if config.records_any_request_evidence_by_default?
|
|
204
|
+
return [warning("request-derived personal data is recorded by default for every policy " \
|
|
205
|
+
"(#{default_categories.join(", ")}). That is a decision worth re-reading: " \
|
|
206
|
+
"a policy that does not need an IP address still gets one.")]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
[ok("request-derived personal data is off by default")]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def default_categories
|
|
213
|
+
config = Clickwrap.config
|
|
214
|
+
categories = []
|
|
215
|
+
categories << "ip_address" if config.record_ip_address_by_default
|
|
216
|
+
categories << "browser_user_agent" if config.record_browser_user_agent_by_default
|
|
217
|
+
geolocation = config.enabled_default_ip_geolocation_fields
|
|
218
|
+
categories << "ip_geolocation #{geolocation.join("/")}" if geolocation.any?
|
|
219
|
+
categories
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A policy that keeps a person's IP address or estimated city with no date
|
|
223
|
+
# on which somebody looks at that decision again is how a temporary
|
|
224
|
+
# measure becomes permanent. The date is the host's; the doctor only
|
|
225
|
+
# notices its absence — for EVERY collected category, not only
|
|
226
|
+
# geolocation: an IP address past its review date is exactly as overdue
|
|
227
|
+
# as a city would be.
|
|
228
|
+
def review_date_findings
|
|
229
|
+
Clickwrap.policies.values.filter_map do |policy|
|
|
230
|
+
request_evidence = policy.request_evidence
|
|
231
|
+
collected = collected_request_evidence_categories(request_evidence)
|
|
232
|
+
next if collected.empty?
|
|
233
|
+
|
|
234
|
+
review_on = request_evidence.review_configuration_on
|
|
235
|
+
|
|
236
|
+
if review_on.nil?
|
|
237
|
+
warning("#{policy.key} records #{collected.join(", ")} without a review date. Add " \
|
|
238
|
+
"`review_request_evidence_configuration_on` so this decision gets looked at again.")
|
|
239
|
+
elsif past?(review_on)
|
|
240
|
+
warning("#{policy.key} was due to have its request-evidence configuration " \
|
|
241
|
+
"(#{collected.join(", ")}) reviewed on #{review_on}, and that date has passed")
|
|
242
|
+
end
|
|
243
|
+
end + default_review_date_findings
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def collected_request_evidence_categories(request_evidence)
|
|
247
|
+
categories = []
|
|
248
|
+
categories << "ip_address" if request_evidence.records_ip_address?
|
|
249
|
+
categories << "browser_user_agent" if request_evidence.records_browser_user_agent?
|
|
250
|
+
if request_evidence.records_ip_geolocation?
|
|
251
|
+
categories << "ip_geolocation #{request_evidence.enabled_ip_geolocation_fields.join("/")}"
|
|
252
|
+
end
|
|
253
|
+
categories
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def default_review_date_findings
|
|
257
|
+
config = Clickwrap.config
|
|
258
|
+
return [] unless config.records_any_request_evidence_by_default?
|
|
259
|
+
|
|
260
|
+
review_on = config.review_default_request_evidence_configuration_on
|
|
261
|
+
|
|
262
|
+
if review_on.nil?
|
|
263
|
+
[warning("request evidence is recorded by default with no " \
|
|
264
|
+
"`review_default_request_evidence_configuration_on` date")]
|
|
265
|
+
elsif past?(review_on)
|
|
266
|
+
[warning("the default request-evidence configuration was due for review on #{review_on}, " \
|
|
267
|
+
"and that date has passed")]
|
|
268
|
+
else
|
|
269
|
+
[]
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def resolver_findings
|
|
274
|
+
configured = Clickwrap.config.ip_geolocation_resolver
|
|
275
|
+
wanted = Clickwrap.policies.values.select { |policy| policy.request_evidence.records_ip_geolocation? }
|
|
276
|
+
|
|
277
|
+
if configured.nil?
|
|
278
|
+
return [ok("no IP-geolocation resolver is configured, and no policy asks for one")] if wanted.empty?
|
|
279
|
+
|
|
280
|
+
# A warning rather than a problem: capture still succeeds and the field
|
|
281
|
+
# is recorded as unavailable with a reason, which is the honest outcome.
|
|
282
|
+
# What it is not is what the policy asked for, so somebody should know.
|
|
283
|
+
return [warning("#{wanted.map(&:key).join(", ")} records IP geolocation but no " \
|
|
284
|
+
"`ip_geolocation_resolver` is configured, so every capture will record it " \
|
|
285
|
+
"as unavailable")]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
[ok("an IP-geolocation resolver is configured (#{configured.class.name})")]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# An IP address read from a forwarded header is only as good as the proxy
|
|
292
|
+
# configuration in front of it. Clickwrap cannot check a host's Cloudflare,
|
|
293
|
+
# load balancer, or `config.hosts` setup; what it can check is whether the
|
|
294
|
+
# host ever recorded that they verified one.
|
|
295
|
+
def source_trust_findings
|
|
296
|
+
records_ip_address = Clickwrap.config.record_ip_address_by_default ||
|
|
297
|
+
Clickwrap.policies.values.any? { |policy| policy.request_evidence.records_ip_address? }
|
|
298
|
+
return [] unless records_ip_address
|
|
299
|
+
|
|
300
|
+
if Clickwrap.config.trusted_proxy_configuration_digest.blank?
|
|
301
|
+
return [warning("request-source trust is unverified: IP addresses are recorded, but no " \
|
|
302
|
+
"`trusted_proxy_configuration_digest` is set, so nothing records which " \
|
|
303
|
+
"proxy configuration (Cloudflare, a load balancer, a CDN) the addresses " \
|
|
304
|
+
"were read through")]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
[ok("a reviewed trusted-proxy configuration digest is recorded with each recorded IP address")]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# --- Data -----------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def disposition_findings
|
|
313
|
+
now = Clickwrap.now
|
|
314
|
+
overdue = Event.due_for_core_disposition(now).count +
|
|
315
|
+
RequestEvidence.with_ip_address_due(now).count +
|
|
316
|
+
RequestEvidence.with_browser_user_agent_due(now).count +
|
|
317
|
+
RequestEvidence.with_ip_geolocation_due(now).count
|
|
318
|
+
|
|
319
|
+
return [ok("no overdue disposition")] if overdue.zero?
|
|
320
|
+
|
|
321
|
+
[warning("#{overdue} #{pluralize(overdue, "record is", "records are")} past a retention rule " \
|
|
322
|
+
"and still here. Run `bin/rails clickwrap:retention:plan`, review it, then " \
|
|
323
|
+
"`bin/rails clickwrap:retention:apply PLAN=...`.")]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def legal_hold_findings
|
|
327
|
+
in_effect = LegalHold.in_effect.count
|
|
328
|
+
due = LegalHold.due_for_review(Clickwrap.now).count
|
|
329
|
+
|
|
330
|
+
return [ok("no legal holds are in effect")] if in_effect.zero?
|
|
331
|
+
|
|
332
|
+
if due.zero?
|
|
333
|
+
return [ok("#{in_effect} legal #{pluralize(in_effect, "hold", "holds")} in effect, none past review")]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
[warning("#{due} legal #{pluralize(due, "hold is", "holds are")} past the review date they " \
|
|
337
|
+
"were placed with. A hold nobody revisits is how everything gets kept forever.")]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def digest_findings
|
|
341
|
+
events = Event.order(id: :desc).limit(DIGEST_SAMPLE_SIZE).to_a
|
|
342
|
+
return [ok("no events recorded yet")] if events.empty?
|
|
343
|
+
|
|
344
|
+
by_status = events.group_by(&:digest_integrity_status)
|
|
345
|
+
failed = by_status.fetch(:unaccounted_mismatch, [])
|
|
346
|
+
|
|
347
|
+
if failed.empty?
|
|
348
|
+
dispositions = by_status.fetch(:documented_core_disposition, []).length
|
|
349
|
+
verifying = by_status.fetch(:verified, []).length
|
|
350
|
+
|
|
351
|
+
return [ok("all #{events.length} checked event digests verify")] if dispositions.zero?
|
|
352
|
+
|
|
353
|
+
return [ok("#{verifying} checked event digests verify and #{dispositions} " \
|
|
354
|
+
"#{pluralize(dispositions, "core disposition is", "core dispositions are")} " \
|
|
355
|
+
"documented by valid linked events; no mismatch is unexplained")]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
[problem("#{failed.length} of #{events.length} checked event digests do not match the bytes " \
|
|
359
|
+
"they cover and have no valid linked disposition event (first: #{failed.first.id}). " \
|
|
360
|
+
"That means those rows changed after they were written or carry an unexplained " \
|
|
361
|
+
"disposition marker; it does not on its own say who changed them.")]
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def external_action_findings
|
|
365
|
+
unresolved = ExternalAction.unresolved.count
|
|
366
|
+
return [ok("no unresolved external actions")] if unresolved.zero?
|
|
367
|
+
|
|
368
|
+
stale = ExternalAction.needing_reconciliation.count
|
|
369
|
+
|
|
370
|
+
[warning("#{unresolved} external #{pluralize(unresolved, "action is", "actions are")} still " \
|
|
371
|
+
"pending or unknown (#{stale} older than 15 minutes). Run " \
|
|
372
|
+
"`bin/rails clickwrap:reconcile_external_actions` to list them.")]
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def integrity_attestation_findings
|
|
376
|
+
counts = Integrity::AttestationReconciler.missing_counts
|
|
377
|
+
return [ok("no external integrity attestation adapters are configured")] if counts.empty?
|
|
378
|
+
|
|
379
|
+
missing = counts.sum { |_, count| count }
|
|
380
|
+
return [ok("every eligible event has an attestation attempt from each configured adapter")] if missing.zero?
|
|
381
|
+
|
|
382
|
+
details = counts.filter_map do |kind, count|
|
|
383
|
+
"#{count} #{kind.tr("_", " ")}" unless count.zero?
|
|
384
|
+
end.join(", ")
|
|
385
|
+
|
|
386
|
+
[warning("#{missing} configured integrity #{pluralize(missing, "attestation attempt is",
|
|
387
|
+
"attestation attempts are")} " \
|
|
388
|
+
"missing (#{details}). Run `bin/rails clickwrap:integrity:attest_missing`; this can " \
|
|
389
|
+
"call external providers and does not change the committed evidence event.")]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# --- Plumbing -------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
# Half of what the doctor checks needs the database, and the reason someone
|
|
395
|
+
# is running it at 03:00 may well be that the database is unhappy. A failed
|
|
396
|
+
# check reports what it could not read instead of taking the whole report
|
|
397
|
+
# down with it.
|
|
398
|
+
def with_database(what)
|
|
399
|
+
yield
|
|
400
|
+
rescue StandardError => error
|
|
401
|
+
[warning("could not check #{what}: #{error.class}. #{error.message}")]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def past?(date)
|
|
405
|
+
return false if date.nil?
|
|
406
|
+
|
|
407
|
+
date.to_time <= Clickwrap.now
|
|
408
|
+
rescue StandardError
|
|
409
|
+
false
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def pluralize(count, singular, plural) = count == 1 ? singular : plural
|
|
413
|
+
|
|
414
|
+
def ok(message) = Finding.new(status: :ok, message: message)
|
|
415
|
+
def warning(message) = Finding.new(status: :warning, message: message)
|
|
416
|
+
def problem(message) = Finding.new(status: :problem, message: message)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A declared document version, before it is published.
|
|
5
|
+
#
|
|
6
|
+
# Clickwrap.document :terms,
|
|
7
|
+
# from: Rails.root.join("app/content/legal/terms.md")
|
|
8
|
+
#
|
|
9
|
+
# Clickwrap.document :terms,
|
|
10
|
+
# version: "2026-08-15",
|
|
11
|
+
# locale: :en,
|
|
12
|
+
# effective_at: Time.utc(2026, 8, 15),
|
|
13
|
+
# from: Rails.root.join("app/content/legal/terms.en.md")
|
|
14
|
+
#
|
|
15
|
+
# Clickwrap.document :terms,
|
|
16
|
+
# from: Rails.root.join("app/content/legal/terms.md"),
|
|
17
|
+
# link: "/legal/terms"
|
|
18
|
+
#
|
|
19
|
+
# `link:` is where a PERSON reads this document — the host's own formatted
|
|
20
|
+
# page, with its typography, its navigation, and its language switcher —
|
|
21
|
+
# rather than the engine's plain rendering of the published bytes. It is the
|
|
22
|
+
# path Clickwrap presents beside the control AND the path it signs into the
|
|
23
|
+
# presentation manifest, so the evidence never cites a different target from
|
|
24
|
+
# the link somebody could actually press.
|
|
25
|
+
#
|
|
26
|
+
# The trade is explicit and belongs to the host: a host page shows whatever
|
|
27
|
+
# is current, so the signed path is a stable address rather than an immutable
|
|
28
|
+
# snapshot. The bytes are still frozen, digested, and recorded — what changes
|
|
29
|
+
# is which URL the receipt says was offered. A host that wants the immutable
|
|
30
|
+
# rendering in the evidence simply leaves `link:` off and gets the engine's
|
|
31
|
+
# per-version route, as before.
|
|
32
|
+
#
|
|
33
|
+
# The declaration says which bytes to publish. `bin/rails clickwrap:publish`
|
|
34
|
+
# reads them once, digests them, and freezes a database snapshot. From then
|
|
35
|
+
# on the snapshot is the evidence; the file on disk is only where it came
|
|
36
|
+
# from. Changing the file does not change published evidence, and reusing a
|
|
37
|
+
# version label for different bytes is refused rather than silently accepted.
|
|
38
|
+
#
|
|
39
|
+
# `version:` is optional exactly when the source can name its own: a file or
|
|
40
|
+
# inline content whose leading YAML front matter carries `clickwrap_version:`
|
|
41
|
+
# (or `last_updated:`) IS the single source of truth for its label, so
|
|
42
|
+
# bumping a legal text is one edit in one file. A source with no front-matter
|
|
43
|
+
# version and no explicit `version:` is refused at boot with a sentence —
|
|
44
|
+
# Clickwrap never invents a label, because a policy that requires a current
|
|
45
|
+
# version cannot be satisfied by a guess.
|
|
46
|
+
class DocumentDefinition
|
|
47
|
+
SOURCE_KINDS = %i[file inline resolver].freeze
|
|
48
|
+
|
|
49
|
+
MEDIA_TYPES_BY_EXTENSION = {
|
|
50
|
+
".md" => "text/markdown",
|
|
51
|
+
".markdown" => "text/markdown",
|
|
52
|
+
".html" => "text/html",
|
|
53
|
+
".htm" => "text/html",
|
|
54
|
+
".txt" => "text/plain",
|
|
55
|
+
".json" => "application/json",
|
|
56
|
+
".pdf" => "application/pdf"
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
REFUSED_VERSION_LABELS = %w[unversioned current latest head none default].freeze
|
|
60
|
+
|
|
61
|
+
# A `link:` is rendered as an href and signed into evidence, so it has to be
|
|
62
|
+
# somewhere a browser can navigate. Anything else — `javascript:`, `data:`,
|
|
63
|
+
# a bare word — would be a scheme the gem itself painted into a page.
|
|
64
|
+
LINK_SCHEMES = %r{\A(?:/[^/]|/\z|https://|http://)}
|
|
65
|
+
|
|
66
|
+
attr_reader :key, :version_label, :locale, :media_type, :effective_at,
|
|
67
|
+
:tenant_key, :source_kind, :source_reference, :inline_content,
|
|
68
|
+
:resolver, :renderer, :link
|
|
69
|
+
|
|
70
|
+
def initialize(key:, version: nil, locale: :en, media_type: nil, effective_at: nil,
|
|
71
|
+
tenant: nil, from: nil, content: nil, resolver: nil, renderer: nil,
|
|
72
|
+
link: nil)
|
|
73
|
+
@key = normalize_key(key)
|
|
74
|
+
@locale = locale.to_s
|
|
75
|
+
@effective_at = effective_at
|
|
76
|
+
@tenant_key = tenant&.to_s
|
|
77
|
+
@renderer = renderer
|
|
78
|
+
@link = normalize_link(link)
|
|
79
|
+
|
|
80
|
+
assign_source(from:, content:, resolver:)
|
|
81
|
+
@version_label = normalize_version(version || version_label_from_front_matter)
|
|
82
|
+
@media_type = (media_type || infer_media_type).to_s
|
|
83
|
+
|
|
84
|
+
validate!
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reads the exact bytes this definition points at. Called at publish time,
|
|
89
|
+
# never at export time: an export that re-read a mutable source and called
|
|
90
|
+
# the result historical evidence would be a lie.
|
|
91
|
+
def read_bytes
|
|
92
|
+
case source_kind
|
|
93
|
+
when :inline then inline_content.to_s.dup
|
|
94
|
+
when :file then read_file_bytes
|
|
95
|
+
when :resolver then read_resolver_bytes
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def identity = [tenant_key, key, version_label, locale]
|
|
100
|
+
|
|
101
|
+
def to_s
|
|
102
|
+
"#{key} #{version_label} (#{locale})"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def assign_source(from:, content:, resolver:)
|
|
108
|
+
provided = { from:, content:, resolver: }.compact
|
|
109
|
+
|
|
110
|
+
if provided.length > 1
|
|
111
|
+
raise DefinitionError,
|
|
112
|
+
"Document #{key} #{version_label} declares #{provided.keys.join(" and ")}. " \
|
|
113
|
+
"Give it exactly one source."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if from
|
|
117
|
+
@source_kind = :file
|
|
118
|
+
@source_reference = from.to_s
|
|
119
|
+
elsif content
|
|
120
|
+
@source_kind = :inline
|
|
121
|
+
@inline_content = content.to_s
|
|
122
|
+
@source_reference = "inline"
|
|
123
|
+
elsif resolver
|
|
124
|
+
@source_kind = :resolver
|
|
125
|
+
@resolver = resolver
|
|
126
|
+
@source_reference = "resolver"
|
|
127
|
+
else
|
|
128
|
+
raise DefinitionError,
|
|
129
|
+
"Document #{key} #{version_label} has no source. Use `from:` with a path, " \
|
|
130
|
+
"`content:` with a string, or `resolver:` with something that returns bytes."
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def read_file_bytes
|
|
135
|
+
unless File.exist?(source_reference)
|
|
136
|
+
raise DocumentNotPublishedError,
|
|
137
|
+
"Document #{self} points at #{source_reference}, which does not exist."
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
File.binread(source_reference)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# No explicit `version:` was given, so the source itself must name one
|
|
144
|
+
# through its leading YAML front matter. Every refusal here is a boot
|
|
145
|
+
# failure with the fix in the sentence, because a silently invented label
|
|
146
|
+
# would let "unversioned" evidence masquerade as versioned.
|
|
147
|
+
def version_label_from_front_matter
|
|
148
|
+
case source_kind
|
|
149
|
+
when :file then file_front_matter_version_label
|
|
150
|
+
when :inline then inline_front_matter_version_label
|
|
151
|
+
else
|
|
152
|
+
raise DefinitionError,
|
|
153
|
+
"Document #{key} has no `version:`, and a `resolver:` source cannot name its own " \
|
|
154
|
+
"version at boot because its bytes are only read at publish time. Pass `version:` " \
|
|
155
|
+
"explicitly, for example version: \"2026-08-15\"."
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def file_front_matter_version_label
|
|
160
|
+
unless File.exist?(source_reference)
|
|
161
|
+
raise DefinitionError,
|
|
162
|
+
"Document #{key} has no `version:` and its file #{source_reference} does not " \
|
|
163
|
+
"exist yet, so there is no front matter to read one from. Create the file with a " \
|
|
164
|
+
"`clickwrap_version:` or `last_updated:` front-matter key, or pass `version:` " \
|
|
165
|
+
"explicitly."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
FrontMatter.version_label_in(File.read(source_reference)) ||
|
|
169
|
+
raise(DefinitionError,
|
|
170
|
+
"Document #{key} has no `version:` and #{source_reference} has no " \
|
|
171
|
+
"`clickwrap_version:` or `last_updated:` front-matter key. The version label has " \
|
|
172
|
+
"no other source of truth — add one of those keys to the file's front matter " \
|
|
173
|
+
"(`clickwrap_version:` wins when both are present), or pass `version:` explicitly.")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def inline_front_matter_version_label
|
|
177
|
+
FrontMatter.version_label_in(inline_content) ||
|
|
178
|
+
raise(DefinitionError,
|
|
179
|
+
"Document #{key} has no `version:` and its inline `content:` has no " \
|
|
180
|
+
"`clickwrap_version:` or `last_updated:` front-matter key. Add one to the " \
|
|
181
|
+
"content's leading front matter, or pass `version:` explicitly.")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read_resolver_bytes
|
|
185
|
+
bytes = resolver.respond_to?(:call) ? resolver.call(self) : resolver.read(self)
|
|
186
|
+
|
|
187
|
+
if bytes.nil? || bytes.to_s.empty?
|
|
188
|
+
raise DocumentNotPublishedError,
|
|
189
|
+
"The resolver for document #{self} returned no bytes."
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
bytes.to_s
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def infer_media_type
|
|
196
|
+
return "text/plain" unless source_kind == :file
|
|
197
|
+
|
|
198
|
+
MEDIA_TYPES_BY_EXTENSION.fetch(File.extname(source_reference).downcase, "text/plain")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def normalize_key(value)
|
|
202
|
+
normalized = value.to_s
|
|
203
|
+
raise DefinitionError, "A document needs a key, for example :terms" if normalized.empty?
|
|
204
|
+
|
|
205
|
+
normalized
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A version label is the application's own name for a frozen set of bytes.
|
|
209
|
+
# Clickwrap does not interpret it — a date, a semantic version, and a git
|
|
210
|
+
# SHA are all fine — but it refuses the placeholder labels applications
|
|
211
|
+
# reach for when they have not really versioned anything, because a policy
|
|
212
|
+
# that requires a current version cannot be satisfied by "unversioned".
|
|
213
|
+
def normalize_version(value)
|
|
214
|
+
normalized = value.to_s.strip
|
|
215
|
+
|
|
216
|
+
if normalized.empty?
|
|
217
|
+
raise DefinitionError,
|
|
218
|
+
"Document #{key} needs a `version:` label, for example \"2026-08-15\"."
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if REFUSED_VERSION_LABELS.include?(normalized.downcase)
|
|
222
|
+
raise DefinitionError,
|
|
223
|
+
"Document #{key} cannot use the version label #{normalized.inspect}: it names a " \
|
|
224
|
+
"moving target rather than a frozen set of bytes. Use a date, a release tag, or " \
|
|
225
|
+
"any label you will never reuse for different content."
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
normalized
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def normalize_link(value)
|
|
232
|
+
return nil if value.nil?
|
|
233
|
+
|
|
234
|
+
normalized = value.to_s.strip
|
|
235
|
+
return nil if normalized.empty?
|
|
236
|
+
|
|
237
|
+
unless normalized.match?(LINK_SCHEMES)
|
|
238
|
+
raise DefinitionError,
|
|
239
|
+
"Document #{key} has a `link:` of #{value.inspect}. A link is the page a person " \
|
|
240
|
+
"reads this document on, so it must be a root-relative path (\"/legal/terms\") or " \
|
|
241
|
+
"an absolute http(s) URL. Clickwrap renders it as an href and signs it into the " \
|
|
242
|
+
"presentation manifest, and it will not sign a scheme it cannot navigate to."
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
normalized.freeze
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def validate!
|
|
249
|
+
return unless effective_at && !effective_at.respond_to?(:to_time)
|
|
250
|
+
|
|
251
|
+
raise DefinitionError,
|
|
252
|
+
"Document #{self} has an `effective_at:` that is not a time."
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|