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,569 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
# The operator surface: everything you might need to run at 03:00, in one
|
|
7
|
+
# namespace, each task printing what it found in full sentences.
|
|
8
|
+
#
|
|
9
|
+
# Two rules hold across every task here.
|
|
10
|
+
#
|
|
11
|
+
# 1. NOTHING PRINTS A VERDICT. These tasks report configuration and data facts.
|
|
12
|
+
# None of them says "compliant", "court-proof", or "audit guaranteed", and
|
|
13
|
+
# none of them ever will — a task that printed a green verdict would be
|
|
14
|
+
# making a legal determination on a host's behalf, which is the one thing
|
|
15
|
+
# this gem exists not to do.
|
|
16
|
+
#
|
|
17
|
+
# 2. DESTRUCTIVE WORK IS TWO STEPS. `retention:plan` writes a plan and deletes
|
|
18
|
+
# nothing. `retention:apply` requires that plan's id explicitly: there is no
|
|
19
|
+
# `--all`, no `--yes`, and no way to apply "whatever is due right now"
|
|
20
|
+
# without a human having looked at the list first.
|
|
21
|
+
module ClickwrapTasks
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def say(text = "") = $stdout.puts(text)
|
|
25
|
+
|
|
26
|
+
def heading(text)
|
|
27
|
+
say
|
|
28
|
+
say(text)
|
|
29
|
+
say("=" * text.length)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def env(name)
|
|
33
|
+
value = ENV.fetch(name, nil).to_s.strip
|
|
34
|
+
value.empty? ? nil : value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def env!(name, usage)
|
|
38
|
+
env(name) || abort("#{name} is required.\n\n #{usage}\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def flag?(name) = %w[1 true yes on].include?(env(name).to_s.downcase)
|
|
42
|
+
|
|
43
|
+
def json? = (env("FORMAT") || "").downcase == "json"
|
|
44
|
+
|
|
45
|
+
def dump(object) = say(JSON.pretty_generate(object))
|
|
46
|
+
|
|
47
|
+
def count(number, singular, plural = "#{singular}s")
|
|
48
|
+
"#{number} #{number == 1 ? singular : plural}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def list(entries)
|
|
52
|
+
entries.each { |entry| say(" #{entry}") }
|
|
53
|
+
say(" (none)") if entries.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def remediation_route
|
|
57
|
+
return "Clickwrap::Engine is mounted, so gated policies have a capture screen to redirect to" if
|
|
58
|
+
Clickwrap::ControllerHelpers.engine_is_mounted?
|
|
59
|
+
|
|
60
|
+
"Clickwrap::Engine is NOT mounted, so every requires_clickwrap gate needs its own remediation_path:"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Who would be asked to act again if a newer required version were published:
|
|
64
|
+
# the actors whose recorded evidence cites a document version that is no
|
|
65
|
+
# longer the current one.
|
|
66
|
+
#
|
|
67
|
+
# Read row by row rather than in SQL. The recorded version ids live in a JSON
|
|
68
|
+
# column, every adapter spells JSON containment differently, and an operator
|
|
69
|
+
# preview is not the place to find that out.
|
|
70
|
+
def reacceptance_report(policy, statement)
|
|
71
|
+
current = statement.document_keys.to_h do |document_key|
|
|
72
|
+
document = Clickwrap::Document.find_by(document_key: document_key)
|
|
73
|
+
version = document&.current_version
|
|
74
|
+
[document_key, version&.id&.to_s]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
affected = Set.new
|
|
78
|
+
total = 0
|
|
79
|
+
|
|
80
|
+
states_for(policy, statement).find_each do |state|
|
|
81
|
+
total += 1
|
|
82
|
+
recorded = Array(state.document_version_ids).map(&:to_s)
|
|
83
|
+
affected << state.actor_reference if current.values.compact.any? { |id| !recorded.include?(id) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
say(" #{statement.key} (#{statement.kind})")
|
|
87
|
+
current.each { |key, id| say(" current published version of #{key}: #{id || "(nothing published)"}") }
|
|
88
|
+
say(" actors with current evidence: #{total}")
|
|
89
|
+
say(" actors who would be asked again: #{affected.size}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def states_for(policy, statement)
|
|
93
|
+
Clickwrap::StatementState.for_policy(policy.key).for_statement(statement.key).active
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Recomputes event digests. Whole table by default; SINCE and LIMIT narrow it,
|
|
97
|
+
# because "verify everything" and "verify what happened since last night" are
|
|
98
|
+
# both things an operator legitimately wants at 03:00.
|
|
99
|
+
def digest_sweep
|
|
100
|
+
checked = 0
|
|
101
|
+
verified = []
|
|
102
|
+
documented_dispositions = []
|
|
103
|
+
failed = []
|
|
104
|
+
|
|
105
|
+
check = lambda do |event|
|
|
106
|
+
checked += 1
|
|
107
|
+
|
|
108
|
+
case event.digest_integrity_status
|
|
109
|
+
when :verified
|
|
110
|
+
verified << event.id
|
|
111
|
+
when :documented_core_disposition
|
|
112
|
+
documented_dispositions << event.id
|
|
113
|
+
else
|
|
114
|
+
failed << event.id
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
events = sweep_scope
|
|
119
|
+
events.respond_to?(:find_each) ? events.find_each(&check) : events.each(&check)
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
"checked" => checked,
|
|
123
|
+
"verified" => verified.length,
|
|
124
|
+
"documented_dispositions" => documented_dispositions,
|
|
125
|
+
"failed" => failed
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def sweep_scope
|
|
130
|
+
scope = Clickwrap::Event.all
|
|
131
|
+
since = env("SINCE")
|
|
132
|
+
scope = scope.where(recorded_at_by_server: Time.parse(since)..) if since
|
|
133
|
+
limit = env("LIMIT")
|
|
134
|
+
|
|
135
|
+
limit ? scope.order(id: :desc).limit(limit.to_i).to_a : scope
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
namespace :clickwrap do
|
|
140
|
+
desc "Report objective configuration and data facts about this installation"
|
|
141
|
+
task doctor: :environment do
|
|
142
|
+
findings = Clickwrap::Doctor.new.report
|
|
143
|
+
|
|
144
|
+
if ClickwrapTasks.json?
|
|
145
|
+
ClickwrapTasks.dump(findings.map { |f| { "status" => f.status.to_s, "message" => f.message } })
|
|
146
|
+
else
|
|
147
|
+
findings.each { |finding| ClickwrapTasks.say(finding.to_s) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# An objective failure — a document whose bytes no longer match, a digest
|
|
151
|
+
# that does not recompute — exits non-zero so a monitor notices. That is a
|
|
152
|
+
# statement about this installation's data, not about anybody's legal
|
|
153
|
+
# position.
|
|
154
|
+
exit(1) if findings.any?(&:problem?)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
desc "Publish every declared document version that has not been published yet"
|
|
158
|
+
task publish: :environment do
|
|
159
|
+
outcomes = Clickwrap.publish!
|
|
160
|
+
|
|
161
|
+
ClickwrapTasks.heading("Publishing documents")
|
|
162
|
+
outcomes.each { |outcome| ClickwrapTasks.say(" #{outcome.status}: #{outcome.definition} — #{outcome.message}") }
|
|
163
|
+
ClickwrapTasks.say
|
|
164
|
+
ClickwrapTasks.say("#{ClickwrapTasks.count(outcomes.count(&:published?), "document")} published, " \
|
|
165
|
+
"#{outcomes.count(&:unchanged?)} already published with identical bytes.")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
namespace :publish do
|
|
169
|
+
desc "Show what clickwrap:publish would do, without writing anything"
|
|
170
|
+
task plan: :environment do
|
|
171
|
+
outcomes = Clickwrap.publish!(dry_run: true)
|
|
172
|
+
|
|
173
|
+
ClickwrapTasks.heading("Publishing plan (nothing was written)")
|
|
174
|
+
outcomes.each do |outcome|
|
|
175
|
+
ClickwrapTasks.say(" #{outcome.status}: #{outcome.definition} — #{outcome.message}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
namespace :reacceptance do
|
|
181
|
+
desc "Preview who a newly published version would require to act again (POLICY=key)"
|
|
182
|
+
task plan: :environment do
|
|
183
|
+
key = ClickwrapTasks.env!("POLICY", "bin/rails clickwrap:reacceptance:plan POLICY=current_terms")
|
|
184
|
+
policy = Clickwrap.policy!(key)
|
|
185
|
+
statements = policy.statements.select(&:requires_current_version?)
|
|
186
|
+
|
|
187
|
+
ClickwrapTasks.heading("Reacceptance plan for #{policy.key}")
|
|
188
|
+
|
|
189
|
+
if statements.empty?
|
|
190
|
+
ClickwrapTasks.say(" No statement in this policy declares `require_current_version: true`, so")
|
|
191
|
+
ClickwrapTasks.say(" publishing a new document version does not require anyone to act again.")
|
|
192
|
+
else
|
|
193
|
+
statements.each { |statement| ClickwrapTasks.reacceptance_report(policy, statement) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
ClickwrapTasks.say
|
|
197
|
+
ClickwrapTasks.say("Remediation route: #{ClickwrapTasks.remediation_route}")
|
|
198
|
+
ClickwrapTasks.say("Nothing was emailed, nothing changed, and Clickwrap does not decide whether")
|
|
199
|
+
ClickwrapTasks.say("this change is material. That is the application's call.")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
desc "Verify recorded evidence: one event with EVENT_ID, or the whole chain and digests"
|
|
204
|
+
task verify: :environment do
|
|
205
|
+
event_id = ClickwrapTasks.env("EVENT_ID")
|
|
206
|
+
|
|
207
|
+
if event_id
|
|
208
|
+
event = Clickwrap::Event.find_by(id: event_id) || abort("No Clickwrap event with id #{event_id}.")
|
|
209
|
+
result = Clickwrap.verify(event.id)
|
|
210
|
+
report = {
|
|
211
|
+
"event_id" => event.id,
|
|
212
|
+
"event_type" => event.event_type,
|
|
213
|
+
"policy" => event.policy_key,
|
|
214
|
+
"digest_verifies" => event.digest_verified?,
|
|
215
|
+
"documented_core_disposition" => event.documented_core_disposition?,
|
|
216
|
+
"verification" => result.to_h
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if ClickwrapTasks.json?
|
|
220
|
+
ClickwrapTasks.dump(report)
|
|
221
|
+
else
|
|
222
|
+
ClickwrapTasks.heading("Event #{event.id}")
|
|
223
|
+
ClickwrapTasks.say(" policy: #{event.policy_key} (#{event.event_type})")
|
|
224
|
+
ClickwrapTasks.say(" recorded by server: #{event.recorded_at_by_server}")
|
|
225
|
+
ClickwrapTasks.say(" digest verifies: #{event.digest_verified?}")
|
|
226
|
+
ClickwrapTasks.say(" core disposition documented: #{event.documented_core_disposition?}")
|
|
227
|
+
ClickwrapTasks.say(" verification: #{result.success? ? "satisfied" : result.error}")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
exit(1) unless event.digest_integrity_accounted_for?
|
|
231
|
+
else
|
|
232
|
+
chain = Clickwrap::Integrity::Chain.verify
|
|
233
|
+
digests = ClickwrapTasks.digest_sweep
|
|
234
|
+
|
|
235
|
+
if ClickwrapTasks.json?
|
|
236
|
+
ClickwrapTasks.dump("chain" => chain.to_h, "digests" => digests)
|
|
237
|
+
else
|
|
238
|
+
ClickwrapTasks.heading("Chain verification")
|
|
239
|
+
ClickwrapTasks.say(" chaining enabled: #{chain.chaining_enabled}")
|
|
240
|
+
ClickwrapTasks.say(" scopes walked: #{chain.scopes.length}")
|
|
241
|
+
ClickwrapTasks.say(" events checked: #{chain.checked}")
|
|
242
|
+
ClickwrapTasks.say(" first break: #{chain.first_break || "none"}")
|
|
243
|
+
ClickwrapTasks.say
|
|
244
|
+
ClickwrapTasks.heading("Event digests")
|
|
245
|
+
ClickwrapTasks.say(" events checked: #{digests["checked"]}")
|
|
246
|
+
ClickwrapTasks.say(" digests verifying: #{digests["verified"]}")
|
|
247
|
+
ClickwrapTasks.say(" documented core dispositions: #{digests["documented_dispositions"].length}")
|
|
248
|
+
ClickwrapTasks.say(" unexplained mismatches: #{digests["failed"].length}")
|
|
249
|
+
ClickwrapTasks.list(digests["failed"].first(20))
|
|
250
|
+
ClickwrapTasks.say
|
|
251
|
+
ClickwrapTasks.say("A verifying digest detects accidental or ordinary modification of the bytes it")
|
|
252
|
+
ClickwrapTasks.say("covers. It does not establish who produced them or when.")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
exit(1) unless chain.success? && digests["failed"].empty?
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
namespace :integrity do
|
|
260
|
+
desc "Record configured timestamp/anchor attempts missing after committed events (may call providers)"
|
|
261
|
+
task attest_missing: :environment do
|
|
262
|
+
result = Clickwrap.reconcile_missing_integrity_attestations!(
|
|
263
|
+
scope: ClickwrapTasks.sweep_scope,
|
|
264
|
+
retry_failed_attestations: ClickwrapTasks.flag?("RETRY_FAILED_ATTESTATIONS")
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if ClickwrapTasks.json?
|
|
268
|
+
ClickwrapTasks.dump(result.to_h)
|
|
269
|
+
else
|
|
270
|
+
ClickwrapTasks.heading("Missing integrity attestations")
|
|
271
|
+
ClickwrapTasks.say(" attempts made: #{result.attempted}")
|
|
272
|
+
ClickwrapTasks.say(" results recorded: #{result.recorded}")
|
|
273
|
+
ClickwrapTasks.say(" results not recorded: #{result.not_recorded}")
|
|
274
|
+
ClickwrapTasks.say
|
|
275
|
+
ClickwrapTasks.say("This task may call external providers. After a crash, a provider may have accepted")
|
|
276
|
+
ClickwrapTasks.say("a request before its local result was recorded, so adapters should treat the event")
|
|
277
|
+
ClickwrapTasks.say("digest or exact chain snapshot as an idempotency key. This is not exactly-once delivery.")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
exit(1) unless result.clean?
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
desc "Print one receipt as canonical JSON (EVENT_ID=..., sensitive fields opt in by name)"
|
|
285
|
+
task export: :environment do
|
|
286
|
+
event_id = ClickwrapTasks.env!("EVENT_ID", "bin/rails clickwrap:export EVENT_ID=01K2Y8T5QY0N4V6N1H4G4CQY8J")
|
|
287
|
+
receipt = Clickwrap.receipt(event_id)
|
|
288
|
+
|
|
289
|
+
ClickwrapTasks.dump(
|
|
290
|
+
Clickwrap.export_receipt(
|
|
291
|
+
receipt,
|
|
292
|
+
requested_by: ClickwrapTasks.env("REQUESTED_BY"),
|
|
293
|
+
because: ClickwrapTasks.env("BECAUSE"),
|
|
294
|
+
include_ip_address: ClickwrapTasks.flag?("INCLUDE_IP_ADDRESS"),
|
|
295
|
+
include_browser_user_agent: ClickwrapTasks.flag?("INCLUDE_BROWSER_USER_AGENT"),
|
|
296
|
+
include_ip_geolocation: ClickwrapTasks.flag?("INCLUDE_IP_GEOLOCATION")
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
namespace :retention do
|
|
302
|
+
desc "Build a reviewable disposition plan. Deletes nothing."
|
|
303
|
+
task plan: :environment do
|
|
304
|
+
plan = Clickwrap::Retention::Planner.new(
|
|
305
|
+
policy_key: ClickwrapTasks.env("POLICY"),
|
|
306
|
+
actor_reference: ClickwrapTasks.env("ACTOR"),
|
|
307
|
+
created_by: ClickwrapTasks.env("BY"),
|
|
308
|
+
because: ClickwrapTasks.env("BECAUSE")
|
|
309
|
+
).call
|
|
310
|
+
|
|
311
|
+
summary = plan.summary.to_h
|
|
312
|
+
|
|
313
|
+
if ClickwrapTasks.json?
|
|
314
|
+
ClickwrapTasks.dump("plan_id" => plan.id, "expires_at" => plan.expires_at.to_s, "summary" => summary)
|
|
315
|
+
else
|
|
316
|
+
ClickwrapTasks.heading("Disposition plan #{plan.id}")
|
|
317
|
+
ClickwrapTasks.say(" due: #{summary["due"]}")
|
|
318
|
+
ClickwrapTasks.say(" held: #{summary["held"]} (a legal hold is pausing these)")
|
|
319
|
+
ClickwrapTasks.say(" unresolved: #{summary["unresolved"]} (a host event has not happened yet)")
|
|
320
|
+
ClickwrapTasks.say
|
|
321
|
+
ClickwrapTasks.say(" by part:")
|
|
322
|
+
summary["by_part"].each { |part, counts| ClickwrapTasks.say(" #{part}: #{counts.inspect}") }
|
|
323
|
+
ClickwrapTasks.say(" by policy:")
|
|
324
|
+
summary["by_policy"].each { |policy, counts| ClickwrapTasks.say(" #{policy}: #{counts.inspect}") }
|
|
325
|
+
ClickwrapTasks.say
|
|
326
|
+
ClickwrapTasks.say("Nothing has been deleted. Review the plan, then:")
|
|
327
|
+
ClickwrapTasks.say(" bin/rails clickwrap:retention:apply PLAN=#{plan.id}")
|
|
328
|
+
ClickwrapTasks.say("The plan expires at #{plan.expires_at}, and is re-checked item by item when applied.")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
desc "Apply one reviewed disposition plan (PLAN=id). Deletes evidence."
|
|
333
|
+
task apply: :environment do
|
|
334
|
+
plan_id = ClickwrapTasks.env!(
|
|
335
|
+
"PLAN",
|
|
336
|
+
"bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J (run clickwrap:retention:plan first)"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
plan = Clickwrap::DispositionPlan.find_by(id: plan_id) || abort("No disposition plan with id #{plan_id}.")
|
|
340
|
+
result = Clickwrap::Retention::Applier.new(plan, applied_by: ClickwrapTasks.env("BY")).call
|
|
341
|
+
|
|
342
|
+
if ClickwrapTasks.json?
|
|
343
|
+
ClickwrapTasks.dump(result.to_h)
|
|
344
|
+
else
|
|
345
|
+
ClickwrapTasks.heading("Applied disposition plan #{plan.id}")
|
|
346
|
+
result.counts.each { |name, number| ClickwrapTasks.say(" #{name.ljust(16)} #{number}") }
|
|
347
|
+
ClickwrapTasks.say
|
|
348
|
+
ClickwrapTasks.say(" skipped because a legal hold is in effect:")
|
|
349
|
+
ClickwrapTasks.list(result.skipped_held.first(20).map { |o| "#{o.part} #{o.event_id}: #{o.note}" })
|
|
350
|
+
ClickwrapTasks.say(" skipped because something changed since the plan was reviewed:")
|
|
351
|
+
ClickwrapTasks.list(result.skipped_changed.first(20).map { |o| "#{o.part} #{o.event_id}: #{o.note}" })
|
|
352
|
+
ClickwrapTasks.say(" errors:")
|
|
353
|
+
ClickwrapTasks.list(result.errors.first(20).map { |o| "#{o.part} #{o.event_id}: #{o.note}" })
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
exit(1) unless result.clean?
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
namespace :holds do
|
|
361
|
+
desc "List legal holds in effect and the ones past their review date"
|
|
362
|
+
task review: :environment do
|
|
363
|
+
now = Clickwrap.now
|
|
364
|
+
in_effect = Clickwrap::LegalHold.in_effect.order(:review_at).to_a
|
|
365
|
+
due = in_effect.select { |hold| hold.review_at <= now }
|
|
366
|
+
|
|
367
|
+
ClickwrapTasks.heading("Legal holds")
|
|
368
|
+
ClickwrapTasks.say(" in effect: #{in_effect.length}")
|
|
369
|
+
ClickwrapTasks.say(" past review date: #{due.length}")
|
|
370
|
+
ClickwrapTasks.say
|
|
371
|
+
ClickwrapTasks.say(" past their review date:")
|
|
372
|
+
ClickwrapTasks.list(
|
|
373
|
+
due.map do |hold|
|
|
374
|
+
"#{hold.hold_scope} #{hold.event_id || hold.actor_reference || hold.policy_key} — " \
|
|
375
|
+
"review due #{hold.review_at}, placed #{hold.placed_at} by #{hold.placed_by_reference}: #{hold.reason}"
|
|
376
|
+
end
|
|
377
|
+
)
|
|
378
|
+
ClickwrapTasks.say
|
|
379
|
+
ClickwrapTasks.say("A hold pauses scheduled disposition. One nobody revisits is how everything")
|
|
380
|
+
ClickwrapTasks.say("ends up kept forever, which is why every hold carries a review date and an owner.")
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
namespace :privacy do
|
|
385
|
+
desc "Describe what this application is configured to record, and why it says it does"
|
|
386
|
+
task inventory: :environment do
|
|
387
|
+
inventory = Clickwrap::Privacy.inventory
|
|
388
|
+
|
|
389
|
+
if ClickwrapTasks.json?
|
|
390
|
+
ClickwrapTasks.dump(inventory)
|
|
391
|
+
else
|
|
392
|
+
ClickwrapTasks.heading("Request-evidence inventory")
|
|
393
|
+
ClickwrapTasks.say(" records anything by default: " \
|
|
394
|
+
"#{inventory["defaults"]["records_any_request_evidence_by_default"]}")
|
|
395
|
+
ClickwrapTasks.say(" unresolved host events: " \
|
|
396
|
+
"#{inventory["unresolved_host_events"].join(", ").presence || "(none)"}")
|
|
397
|
+
|
|
398
|
+
recording, quiet = inventory["policies"].partition { |policy| policy["records_any_request_evidence"] }
|
|
399
|
+
|
|
400
|
+
ClickwrapTasks.say(" policies recording no request evidence: " \
|
|
401
|
+
"#{quiet.map { |policy| policy["policy"] }.join(", ").presence || "(none)"}")
|
|
402
|
+
|
|
403
|
+
recording.each do |policy|
|
|
404
|
+
ClickwrapTasks.say
|
|
405
|
+
ClickwrapTasks.say(" #{policy["policy"]} (retention class #{policy["retention_class"]})")
|
|
406
|
+
policy["fields"].each do |field, details|
|
|
407
|
+
next unless details["recorded"]
|
|
408
|
+
|
|
409
|
+
ClickwrapTasks.say(" #{field}: #{details["because"]}")
|
|
410
|
+
ClickwrapTasks.say(" legal basis reference: " \
|
|
411
|
+
"#{details["legal_basis_reference"] || "(none supplied)"}")
|
|
412
|
+
ClickwrapTasks.say(" encrypted: #{details["encrypted"]}, " \
|
|
413
|
+
"delete after: #{details["delete_after_seconds"] || details["retain_until_rule"]}")
|
|
414
|
+
ClickwrapTasks.say(" fields: #{details["fields"].join(", ")}") if details["fields"]
|
|
415
|
+
end
|
|
416
|
+
ClickwrapTasks.say(" review on: " \
|
|
417
|
+
"#{policy["review_request_evidence_configuration_on"] || "(no date set)"}")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
ClickwrapTasks.say
|
|
421
|
+
ClickwrapTasks.say("This describes a configuration. Describing a configuration is not the same as")
|
|
422
|
+
ClickwrapTasks.say("the configuration being lawful, necessary, or proportionate.")
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
desc "Export every receipt recorded for one actor (ACTOR=gid://my-app/User/123)"
|
|
427
|
+
task export: :environment do
|
|
428
|
+
actor = ClickwrapTasks.env!("ACTOR", "bin/rails clickwrap:privacy:export ACTOR=gid://my-app/User/123")
|
|
429
|
+
|
|
430
|
+
ClickwrapTasks.dump(
|
|
431
|
+
Clickwrap::Privacy.export_for(
|
|
432
|
+
actor,
|
|
433
|
+
requested_by: ClickwrapTasks.env("REQUESTED_BY"),
|
|
434
|
+
because: ClickwrapTasks.env("BECAUSE"),
|
|
435
|
+
include_ip_address: ClickwrapTasks.flag?("INCLUDE_IP_ADDRESS"),
|
|
436
|
+
include_browser_user_agent: ClickwrapTasks.flag?("INCLUDE_BROWSER_USER_AGENT"),
|
|
437
|
+
include_ip_geolocation: ClickwrapTasks.flag?("INCLUDE_IP_GEOLOCATION")
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
namespace :disposition do
|
|
443
|
+
desc "Build a reviewable plan for one actor's evidence (ACTOR=gid://...). Deletes nothing."
|
|
444
|
+
task plan: :environment do
|
|
445
|
+
actor = ClickwrapTasks.env!(
|
|
446
|
+
"ACTOR",
|
|
447
|
+
"bin/rails clickwrap:privacy:disposition:plan ACTOR=gid://my-app/User/123 BECAUSE=\"DSAR-2026-41\""
|
|
448
|
+
)
|
|
449
|
+
because = ClickwrapTasks.env!("BECAUSE", "BECAUSE=\"Verified erasure request DSAR-2026-41\"")
|
|
450
|
+
|
|
451
|
+
plan = Clickwrap::Privacy.plan_disposition_for(
|
|
452
|
+
actor,
|
|
453
|
+
requested_by: ClickwrapTasks.env("BY"),
|
|
454
|
+
because: because
|
|
455
|
+
)
|
|
456
|
+
summary = plan.summary.to_h
|
|
457
|
+
|
|
458
|
+
ClickwrapTasks.heading("Actor disposition plan #{plan.id}")
|
|
459
|
+
ClickwrapTasks.say(" items: #{summary["due"]}")
|
|
460
|
+
ClickwrapTasks.say(" held by a legal hold: #{summary["held"]}")
|
|
461
|
+
ClickwrapTasks.say(" still within retention period: #{summary["still_within_retention_period"]}")
|
|
462
|
+
ClickwrapTasks.say(" by part: #{summary["by_part"].inspect}")
|
|
463
|
+
ClickwrapTasks.say
|
|
464
|
+
ClickwrapTasks.say("Nothing has been deleted. This plan does not decide whether an erasure request")
|
|
465
|
+
ClickwrapTasks.say("overrides a retention duty, a legal claim, or a hold — it shows what exists so")
|
|
466
|
+
ClickwrapTasks.say("the person who makes that decision can see it.")
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
desc "List external actions that are still pending or unknown"
|
|
472
|
+
task reconcile_external_actions: :environment do
|
|
473
|
+
unresolved = Clickwrap::ExternalAction.unresolved.order(:requested_at).to_a
|
|
474
|
+
stale = Clickwrap::ExternalAction.needing_reconciliation.count
|
|
475
|
+
|
|
476
|
+
ClickwrapTasks.heading("Unresolved external actions")
|
|
477
|
+
ClickwrapTasks.say(" pending or unknown: #{unresolved.length}")
|
|
478
|
+
ClickwrapTasks.say(" older than 15 minutes: #{stale}")
|
|
479
|
+
ClickwrapTasks.say
|
|
480
|
+
ClickwrapTasks.list(
|
|
481
|
+
unresolved.first(50).map do |action|
|
|
482
|
+
"#{action.state} #{action.policy_key} #{action.idempotency_key} " \
|
|
483
|
+
"(requested #{action.requested_at}, #{action.attempt_count} attempts) provider=#{action.provider_name}"
|
|
484
|
+
end
|
|
485
|
+
)
|
|
486
|
+
ClickwrapTasks.say
|
|
487
|
+
ClickwrapTasks.say("Clickwrap will not guess an outcome for any of these. `unknown` means the request")
|
|
488
|
+
ClickwrapTasks.say("may or may not have been carried out, and treating that as a failure is how a")
|
|
489
|
+
ClickwrapTasks.say("second debit happens. Ask the provider, then record what it says:")
|
|
490
|
+
ClickwrapTasks.say(" action.record_provider_success_and_consume!(receipt)")
|
|
491
|
+
ClickwrapTasks.say(" action.record_provider_failure!(reason: \"...\")")
|
|
492
|
+
ClickwrapTasks.say(" action.record_provider_outcome_unknown!(reason: \"...\")")
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
namespace :clickwrap do
|
|
497
|
+
namespace :import do
|
|
498
|
+
namespace :fine_print do
|
|
499
|
+
desc "Preview what a FinePrint import would record, without writing anything"
|
|
500
|
+
task plan: :environment do
|
|
501
|
+
report = Clickwrap::Import::FinePrint.plan
|
|
502
|
+
|
|
503
|
+
ClickwrapTasks.heading("FinePrint import plan")
|
|
504
|
+
ClickwrapTasks.say(report.respond_to?(:message) ? report.message : report.to_s)
|
|
505
|
+
ClickwrapTasks.say
|
|
506
|
+
ClickwrapTasks.say("Nothing has been written. Fields FinePrint never recorded — the presentation")
|
|
507
|
+
ClickwrapTasks.say("manifest, the IP address, the call to action, the protected action — stay")
|
|
508
|
+
ClickwrapTasks.say("unknown in the imported events. Historical weakness stays visible rather than")
|
|
509
|
+
ClickwrapTasks.say("being laundered into modern certainty.")
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
desc "Import FinePrint contract versions and signatures as imported_legacy events"
|
|
514
|
+
task fine_print: :environment do
|
|
515
|
+
report = Clickwrap::Import::FinePrint.import!
|
|
516
|
+
|
|
517
|
+
ClickwrapTasks.heading("FinePrint import")
|
|
518
|
+
ClickwrapTasks.say(report.respond_to?(:message) ? report.message : report.to_s)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# --- Publishing rides `db:prepare` --------------------------------------------
|
|
524
|
+
#
|
|
525
|
+
# Deploys that migrate but never publish are the most common way a Clickwrap
|
|
526
|
+
# installation breaks: signup presentations refuse unpublished documents (the
|
|
527
|
+
# safe failure), so a forgotten `clickwrap:publish` becomes "nobody can sign
|
|
528
|
+
# up" some time after the deploy looked green. Enhancing `db:prepare` removes
|
|
529
|
+
# the step instead of documenting it — by the time the server takes traffic,
|
|
530
|
+
# every declared document version has an immutable snapshot.
|
|
531
|
+
#
|
|
532
|
+
# The guards, in order: the host can opt out
|
|
533
|
+
# (`config.publish_documents_after_database_preparation = false`); an
|
|
534
|
+
# installation with no declared documents stays silent; and an installation
|
|
535
|
+
# whose clickwrap tables do not exist yet (the generator ran, the migration
|
|
536
|
+
# has not) is skipped with a sentence rather than crashed. A real publish
|
|
537
|
+
# refusal — a reused version label over changed bytes — still fails the
|
|
538
|
+
# deploy loudly, which is strictly better than signups failing quietly later.
|
|
539
|
+
# No `desc` on purpose: this is db:prepare's follow-through, not a task
|
|
540
|
+
# anyone runs by hand — `clickwrap:publish` is the human-facing spelling.
|
|
541
|
+
# The `:environment` prerequisite matters: `db:prepare` itself runs on
|
|
542
|
+
# `:load_config` alone, and the declared documents and any host opt-out only
|
|
543
|
+
# exist once the application has actually booted.
|
|
544
|
+
namespace :clickwrap do
|
|
545
|
+
task publish_after_database_preparation: :environment do
|
|
546
|
+
if Clickwrap.config.publish_documents_after_database_preparation && Clickwrap.documents.any?
|
|
547
|
+
tables_ready =
|
|
548
|
+
begin
|
|
549
|
+
Clickwrap::Document.table_exists?
|
|
550
|
+
rescue StandardError
|
|
551
|
+
false
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
if tables_ready
|
|
555
|
+
Rake::Task["clickwrap:publish"].invoke
|
|
556
|
+
else
|
|
557
|
+
ClickwrapTasks.say("Clickwrap documents were not published: the clickwrap tables do " \
|
|
558
|
+
"not exist in this database yet. Run the clickwrap migration, then " \
|
|
559
|
+
"`bin/rails clickwrap:publish`.")
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
if Rake::Task.task_defined?("db:prepare")
|
|
566
|
+
Rake::Task["db:prepare"].enhance do
|
|
567
|
+
Rake::Task["clickwrap:publish_after_database_preparation"].invoke
|
|
568
|
+
end
|
|
569
|
+
end
|