clickwrap 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +154 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +187 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +729 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Import
|
|
5
|
+
# `Clickwrap.import_external_receipt!` — record that another system owned
|
|
6
|
+
# the presentation, and say so.
|
|
7
|
+
#
|
|
8
|
+
# ===========================================================================
|
|
9
|
+
# When Stripe or DocuSign owned the presentation, DO NOT PRETEND YOUR
|
|
10
|
+
# APPLICATION CAPTURED IT.
|
|
11
|
+
#
|
|
12
|
+
# Someone else rendered the page. Someone else chose the wording, the
|
|
13
|
+
# ordering, the call to action, and whether the control started unselected.
|
|
14
|
+
# Someone else observed the click, if a click is even what happened. Your
|
|
15
|
+
# application learned about it afterwards, through an API response or a
|
|
16
|
+
# webhook, and what you hold is their account of it.
|
|
17
|
+
#
|
|
18
|
+
# An event written here is therefore built so that it CANNOT be mistaken for
|
|
19
|
+
# a local capture, by a person or by a query:
|
|
20
|
+
#
|
|
21
|
+
# * `event_type` is `external_receipt`, not `capture`;
|
|
22
|
+
# * `capture_channel` is `imported_provider`;
|
|
23
|
+
# * `attribution_method` is `imported_provider`;
|
|
24
|
+
# * there is NO presentation manifest and no manifest digest, because
|
|
25
|
+
# there is no offer of ours to reproduce;
|
|
26
|
+
# * `Event#human_action?` is false, so it never satisfies a predicate that
|
|
27
|
+
# asks whether a person acted through a Clickwrap presentation; and
|
|
28
|
+
# * the receipt carries the provider's name, their event id, their raw
|
|
29
|
+
# receipt, and the validation state of whatever check we ran on it.
|
|
30
|
+
#
|
|
31
|
+
# It participates in host verification — this is real evidence and refusing
|
|
32
|
+
# to record it would only push it somewhere worse — but it participates as
|
|
33
|
+
# what it is. A provider receipt is upgraded into no guarantee the provider
|
|
34
|
+
# did not make.
|
|
35
|
+
# ===========================================================================
|
|
36
|
+
class ExternalReceipt
|
|
37
|
+
def initialize(policy:, actor:, provider_name:, provider_event_id:, provider_receipt: nil,
|
|
38
|
+
verified_with: nil, verified_at: nil, occurred_at: nil, subject: nil,
|
|
39
|
+
tenant: nil, because: nil, statements: nil)
|
|
40
|
+
@policy = policy
|
|
41
|
+
@actor = actor
|
|
42
|
+
@provider_name = provider_name.to_s
|
|
43
|
+
@provider_event_id = provider_event_id.to_s
|
|
44
|
+
@provider_receipt = provider_receipt
|
|
45
|
+
@verified_with = verified_with&.to_s
|
|
46
|
+
@verified_at = coerce_time(verified_at)
|
|
47
|
+
@occurred_at = coerce_time(occurred_at)
|
|
48
|
+
@subject = subject
|
|
49
|
+
@tenant = tenant
|
|
50
|
+
@because = because
|
|
51
|
+
@statement_keys = statements&.map(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :policy, :actor, :provider_name, :provider_event_id, :provider_receipt,
|
|
55
|
+
:verified_with, :verified_at, :occurred_at, :subject, :tenant, :because
|
|
56
|
+
|
|
57
|
+
# Returns the `Clickwrap::Event`, not a Receipt. Callers of an importer
|
|
58
|
+
# almost always want to look straight at the labelling — `event_type`,
|
|
59
|
+
# `capture_channel`, `provider_name`, and the deliberately absent
|
|
60
|
+
# `presentation_manifest_digest` — because that labelling is the point.
|
|
61
|
+
# `Clickwrap.receipt(event.id)` is one call away when a receipt is wanted.
|
|
62
|
+
def import!
|
|
63
|
+
validate!
|
|
64
|
+
|
|
65
|
+
existing = Event.find_by(policy_key: policy.key, idempotency_key: idempotency_key)
|
|
66
|
+
return existing if existing
|
|
67
|
+
|
|
68
|
+
now = Clickwrap.now
|
|
69
|
+
revision = PolicyRevision.freeze_for(policy)
|
|
70
|
+
event = nil
|
|
71
|
+
|
|
72
|
+
::ActiveRecord::Base.transaction do
|
|
73
|
+
event = build_event(now, revision)
|
|
74
|
+
build_statements(event, now)
|
|
75
|
+
|
|
76
|
+
event.save!
|
|
77
|
+
event.finalize_integrity!
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
event
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
alias call import!
|
|
84
|
+
|
|
85
|
+
# One provider event is one Clickwrap event, forever. A webhook delivered
|
|
86
|
+
# three times, a reconciliation sweep, and a backfill script all land on
|
|
87
|
+
# the same key, so the same provider receipt cannot become two pieces of
|
|
88
|
+
# evidence that an auditor would have to reconcile by hand.
|
|
89
|
+
def idempotency_key
|
|
90
|
+
"external_receipt:#{provider_name}:#{provider_event_id}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def validate!
|
|
96
|
+
if provider_name.strip.empty?
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"An imported receipt needs `provider_name:`. \"Some provider said so\" is not " \
|
|
99
|
+
"provenance, and the name is what tells a reader years from now whose account " \
|
|
100
|
+
"of events this row contains."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
return unless provider_event_id.strip.empty?
|
|
104
|
+
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"An imported receipt needs `provider_event_id:` — the provider's own identifier " \
|
|
107
|
+
"for this act. It is how the same webhook delivered twice becomes one event " \
|
|
108
|
+
"instead of two, and how anyone can go back and ask the provider about it."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def statements_to_import
|
|
112
|
+
return @statement_keys.map { |key| policy.statement!(key) } if @statement_keys
|
|
113
|
+
|
|
114
|
+
# Optional statements are excluded for the same reason they are excluded
|
|
115
|
+
# from a legacy import: a provider receipt that says the account holder
|
|
116
|
+
# accepted a service agreement says nothing about an optional purpose it
|
|
117
|
+
# never presented.
|
|
118
|
+
policy.required_statements
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_event(now, revision)
|
|
122
|
+
Event.new(
|
|
123
|
+
event_type: "external_receipt",
|
|
124
|
+
policy_key: policy.key,
|
|
125
|
+
policy_revision: revision,
|
|
126
|
+
actor: actor.is_a?(::ActiveRecord::Base) ? actor : nil,
|
|
127
|
+
actor_reference: actor_reference,
|
|
128
|
+
tenant_key: tenant_key.presence,
|
|
129
|
+
subject: subject.is_a?(::ActiveRecord::Base) ? subject : nil,
|
|
130
|
+
subject_key: subject_key,
|
|
131
|
+
capture_channel: "imported_provider",
|
|
132
|
+
attribution_method: "imported_provider",
|
|
133
|
+
occurred_at: occurred_at,
|
|
134
|
+
recorded_at_by_server: now,
|
|
135
|
+
idempotency_key: idempotency_key,
|
|
136
|
+
provider_name: provider_name,
|
|
137
|
+
provider_event_id: provider_event_id,
|
|
138
|
+
provider_receipt: normalized_provider_receipt,
|
|
139
|
+
provider_verification: verification_record,
|
|
140
|
+
reason: because,
|
|
141
|
+
retention_class_key: policy.retention_class_key,
|
|
142
|
+
canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
143
|
+
gem_version: Clickwrap::VERSION,
|
|
144
|
+
application_version: Clickwrap.config.resolved_application_version,
|
|
145
|
+
created_at: now
|
|
146
|
+
# No presentation_manifest. Deliberately, permanently. The provider
|
|
147
|
+
# rendered whatever was rendered; there is no offer of ours to sign,
|
|
148
|
+
# and a synthesized one would make this row look like a capture.
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What we did to check the provider's account of events, stated at the
|
|
153
|
+
# strength it actually has. `checked_against_provider` means we asked them
|
|
154
|
+
# and they confirmed. `not_checked` means the value arrived and we took
|
|
155
|
+
# it at face value — which is a perfectly ordinary thing to do and a
|
|
156
|
+
# completely different claim.
|
|
157
|
+
def verification_record
|
|
158
|
+
{
|
|
159
|
+
"verified_with" => verified_with,
|
|
160
|
+
"verified_at" => Receipt.format_time(verified_at),
|
|
161
|
+
"state" => verified_with.present? ? "checked_against_provider" : "not_checked",
|
|
162
|
+
"means" => "Provenance and validation state of one provider's receipt as recorded by " \
|
|
163
|
+
"this application. It preserves exactly the assurance that provider " \
|
|
164
|
+
"supplied, and adds none. Clickwrap did not present this content and did " \
|
|
165
|
+
"not observe this action."
|
|
166
|
+
}.compact
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalized_provider_receipt
|
|
170
|
+
case provider_receipt
|
|
171
|
+
when nil then nil
|
|
172
|
+
when Hash then provider_receipt.deep_stringify_keys
|
|
173
|
+
when String then { "value" => provider_receipt }
|
|
174
|
+
else
|
|
175
|
+
return { "value" => provider_receipt.to_s } unless provider_receipt.respond_to?(:to_h)
|
|
176
|
+
|
|
177
|
+
provider_receipt.to_h.deep_stringify_keys
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_statements(event, now)
|
|
182
|
+
statements_to_import.each_with_index do |statement, index|
|
|
183
|
+
event.statements.build(
|
|
184
|
+
ordinal: index,
|
|
185
|
+
statement_key: statement.key,
|
|
186
|
+
kind: statement.kind,
|
|
187
|
+
action: statement.initial_action,
|
|
188
|
+
assertion_text: assertion_text_for(statement),
|
|
189
|
+
assertion_locale: "en",
|
|
190
|
+
required: statement.required?,
|
|
191
|
+
optional: statement.optional?,
|
|
192
|
+
answer: nil,
|
|
193
|
+
answered: false,
|
|
194
|
+
purpose_key: statement.purpose_key,
|
|
195
|
+
withdrawal_path: statement.withdrawal_path,
|
|
196
|
+
valid_from: occurred_at || now,
|
|
197
|
+
expires_at: statement.expires_after(occurred_at || now),
|
|
198
|
+
one_time: statement.one_time?,
|
|
199
|
+
requires: statement.requires,
|
|
200
|
+
created_at: now
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def assertion_text_for(statement)
|
|
206
|
+
"Recorded from #{provider_name}'s receipt #{provider_event_id}: it states that this " \
|
|
207
|
+
"actor #{Vocabulary.initial_action_for(statement.kind)} #{statement.key}. " \
|
|
208
|
+
"#{provider_name} owned the presentation, so the exact wording, controls, and call to " \
|
|
209
|
+
"action belong to that provider and were not recorded here."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def actor_reference
|
|
213
|
+
@actor_reference ||= Reference.actor(actor)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Provider timestamps arrive as whatever the API client handed over — a
|
|
217
|
+
# Time, a TimeWithZone, or an ISO 8601 String straight off the wire. All of
|
|
218
|
+
# them become UTC here, and an unparseable one becomes nil rather than
|
|
219
|
+
# crashing a webhook handler over a field that is provenance, not evidence.
|
|
220
|
+
def coerce_time(value)
|
|
221
|
+
return nil if value.nil?
|
|
222
|
+
return value.utc if value.respond_to?(:utc)
|
|
223
|
+
|
|
224
|
+
parsed = value.respond_to?(:to_time) ? value.to_time : Time.parse(value.to_s)
|
|
225
|
+
parsed&.utc
|
|
226
|
+
rescue ArgumentError, TypeError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def subject_key = StatementState.subject_key_for(subject)
|
|
231
|
+
|
|
232
|
+
def tenant_key
|
|
233
|
+
return "" if tenant.nil?
|
|
234
|
+
return tenant.to_s if tenant.is_a?(String) || tenant.is_a?(Symbol)
|
|
235
|
+
return tenant.to_gid.to_s if tenant.respond_to?(:to_gid)
|
|
236
|
+
|
|
237
|
+
"#{tenant.class.name}/#{tenant.id}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Import
|
|
5
|
+
# `bin/rails clickwrap:import:fine_print[:plan]` — read FinePrint's tables
|
|
6
|
+
# and turn its signatures into explicit `imported_legacy` events.
|
|
7
|
+
#
|
|
8
|
+
# ===========================================================================
|
|
9
|
+
# FinePrint is established Rails prior art for versioned contracts and
|
|
10
|
+
# signature gates, and this importer exists because applications outgrow a
|
|
11
|
+
# question, not because they chose badly. FinePrint answers "did user U sign
|
|
12
|
+
# version N of contract X?", and it answers it well. What it does not record
|
|
13
|
+
# — because it was never trying to — is the presentation, the exact wording
|
|
14
|
+
# beside the control, the call to action, the request context, or the domain
|
|
15
|
+
# action the signature authorized.
|
|
16
|
+
#
|
|
17
|
+
# Those fields therefore come across as `unknown` / `not_collected`. This
|
|
18
|
+
# importer synthesizes none of them. A migration that filled in today's
|
|
19
|
+
# Terms digest for a 2019 signature would turn a modest, honest record into
|
|
20
|
+
# a confident false one, and the person reading the receipt in a dispute
|
|
21
|
+
# would have no way to tell.
|
|
22
|
+
#
|
|
23
|
+
# Read against FinePrint's signature model at the audited commit:
|
|
24
|
+
# https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33
|
|
25
|
+
#
|
|
26
|
+
# NOTE ON COUPLING: this class deliberately does NOT depend on the
|
|
27
|
+
# `fine_print` gem, require any of its files, or reference any of its
|
|
28
|
+
# constants. It reads two tables through the host's own connection, if they
|
|
29
|
+
# are there, and columns are discovered rather than assumed. A migration
|
|
30
|
+
# tool that forces you to keep the gem you are migrating away from installed
|
|
31
|
+
# is a migration tool with a hostage.
|
|
32
|
+
# ===========================================================================
|
|
33
|
+
class FinePrint
|
|
34
|
+
CONTRACTS_TABLE = "fine_print_contracts"
|
|
35
|
+
SIGNATURES_TABLE = "fine_print_signatures"
|
|
36
|
+
|
|
37
|
+
# What FinePrint's schema does not contain, recorded on every imported
|
|
38
|
+
# event so the gap is stated rather than inferred from silence.
|
|
39
|
+
UNKNOWN_FIELDS = %w[
|
|
40
|
+
exact_document_bytes
|
|
41
|
+
presentation_manifest
|
|
42
|
+
assertion
|
|
43
|
+
submit_button_text
|
|
44
|
+
protected_action
|
|
45
|
+
request_evidence
|
|
46
|
+
ip_address
|
|
47
|
+
browser_user_agent
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# A plan or an import, described the same way either way.
|
|
51
|
+
Report = Data.define(:status, :policy_key, :tables_present, :contracts, :signatures,
|
|
52
|
+
:results, :message) do
|
|
53
|
+
def possible? = status != :tables_absent
|
|
54
|
+
def imported = results.select(&:imported?)
|
|
55
|
+
def already_imported = results.select(&:already_imported?)
|
|
56
|
+
def planned = results.select(&:planned?)
|
|
57
|
+
def to_s = message
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One FinePrint contract version, and whether this application has a
|
|
61
|
+
# published Clickwrap document version that corresponds to it.
|
|
62
|
+
ContractMapping = Data.define(:name, :version, :title, :document_key, :version_label,
|
|
63
|
+
:published_version_id, :signature_count) do
|
|
64
|
+
def published? = !published_version_id.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# Reads everything, writes nothing.
|
|
69
|
+
def plan(**) = new(dry_run: true, **).call
|
|
70
|
+
|
|
71
|
+
def import!(**) = new(**).call
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `map_contract_with` receives a contract row (a plain Hash of column name
|
|
75
|
+
# to value) and returns the Clickwrap document key that contract
|
|
76
|
+
# corresponds to. `find_actor_with` receives (user_type, user_id) and
|
|
77
|
+
# returns the actor record, or a stable actor reference string, or nil to
|
|
78
|
+
# skip that signature.
|
|
79
|
+
def initialize(policy_key:, find_actor_with:, map_contract_with: nil, contract_names: nil,
|
|
80
|
+
because: nil, limit: nil, dry_run: false)
|
|
81
|
+
@policy_key = policy_key.to_s
|
|
82
|
+
@find_actor_with = find_actor_with
|
|
83
|
+
@map_contract_with = map_contract_with
|
|
84
|
+
@contract_names = contract_names&.map(&:to_s)
|
|
85
|
+
@because = because
|
|
86
|
+
@limit = limit
|
|
87
|
+
@dry_run = dry_run
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
attr_reader :policy_key, :find_actor_with, :map_contract_with, :contract_names,
|
|
91
|
+
:because, :limit, :dry_run
|
|
92
|
+
|
|
93
|
+
def call
|
|
94
|
+
return tables_absent unless tables_present?
|
|
95
|
+
|
|
96
|
+
contracts = load_contracts
|
|
97
|
+
mappings = map_contracts(contracts)
|
|
98
|
+
results = import_signatures(contracts)
|
|
99
|
+
|
|
100
|
+
Report.new(
|
|
101
|
+
status: dry_run ? :planned : :imported,
|
|
102
|
+
policy_key: policy_key,
|
|
103
|
+
tables_present: true,
|
|
104
|
+
contracts: mappings,
|
|
105
|
+
signatures: results.length,
|
|
106
|
+
results: results,
|
|
107
|
+
message: summary(mappings, results)
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def connection = ::ActiveRecord::Base.connection
|
|
114
|
+
|
|
115
|
+
def tables_present?
|
|
116
|
+
connection.data_source_exists?(CONTRACTS_TABLE) &&
|
|
117
|
+
connection.data_source_exists?(SIGNATURES_TABLE)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Nothing to do, said plainly. An importer that raised here would make
|
|
121
|
+
# `clickwrap:doctor` and a migration checklist harder to run on an
|
|
122
|
+
# application that simply never used FinePrint.
|
|
123
|
+
def tables_absent
|
|
124
|
+
missing = [CONTRACTS_TABLE, SIGNATURES_TABLE].reject { |table| connection.data_source_exists?(table) }
|
|
125
|
+
|
|
126
|
+
Report.new(
|
|
127
|
+
status: :tables_absent, policy_key: policy_key, tables_present: false,
|
|
128
|
+
contracts: [], signatures: 0, results: [],
|
|
129
|
+
message: "FinePrint's tables are not in this database (missing: #{missing.join(", ")}), " \
|
|
130
|
+
"so there is nothing to import and nothing was written. If you are migrating " \
|
|
131
|
+
"from FinePrint, run this against the database that still has its tables."
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Columns are read, not assumed. FinePrint's schema is stable at the
|
|
136
|
+
# audited commit, but a host may have added to it, and an importer that
|
|
137
|
+
# hard-codes a SELECT list breaks on a database it could have read fine.
|
|
138
|
+
def contract_columns = @contract_columns ||= connection.columns(CONTRACTS_TABLE).map(&:name)
|
|
139
|
+
def signature_columns = @signature_columns ||= connection.columns(SIGNATURES_TABLE).map(&:name)
|
|
140
|
+
|
|
141
|
+
def load_contracts
|
|
142
|
+
rows = connection.select_all("SELECT * FROM #{quoted(CONTRACTS_TABLE)}").to_a
|
|
143
|
+
return rows if contract_names.nil?
|
|
144
|
+
|
|
145
|
+
rows.select { |row| contract_names.include?(row["name"].to_s) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def load_signatures
|
|
149
|
+
sql = "SELECT * FROM #{quoted(SIGNATURES_TABLE)}"
|
|
150
|
+
sql << " ORDER BY #{quoted_column("id")}" if signature_columns.include?("id")
|
|
151
|
+
sql << " LIMIT #{limit.to_i}" if limit
|
|
152
|
+
|
|
153
|
+
connection.select_all(sql).to_a
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# FinePrint contract versions become Clickwrap documents. The mapping is
|
|
157
|
+
# host-owned: only the application knows that its FinePrint contract named
|
|
158
|
+
# "terms_of_use" is the document this gem calls `:terms`. What this
|
|
159
|
+
# reports is whether the corresponding version is actually published here,
|
|
160
|
+
# because that is what decides whether an imported event can carry real
|
|
161
|
+
# document bytes or must record the label alone.
|
|
162
|
+
def map_contracts(contracts)
|
|
163
|
+
counts = signature_counts_by_contract_id
|
|
164
|
+
|
|
165
|
+
contracts.map do |row|
|
|
166
|
+
document_key = document_key_for(row)
|
|
167
|
+
label = row["version"].to_s
|
|
168
|
+
version = document_key && published_version(document_key, label)
|
|
169
|
+
|
|
170
|
+
ContractMapping.new(
|
|
171
|
+
name: row["name"].to_s,
|
|
172
|
+
version: label,
|
|
173
|
+
title: row["title"].to_s.presence,
|
|
174
|
+
document_key: document_key,
|
|
175
|
+
version_label: label,
|
|
176
|
+
published_version_id: version&.id,
|
|
177
|
+
signature_count: counts[row["id"]].to_i
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def signature_counts_by_contract_id
|
|
183
|
+
return {} unless signature_columns.include?("contract_id")
|
|
184
|
+
|
|
185
|
+
connection
|
|
186
|
+
.select_all("SELECT contract_id, COUNT(*) AS signature_count FROM " \
|
|
187
|
+
"#{quoted(SIGNATURES_TABLE)} GROUP BY contract_id")
|
|
188
|
+
.to_a
|
|
189
|
+
.to_h { |row| [row["contract_id"], row["signature_count"]] }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def import_signatures(contracts)
|
|
193
|
+
by_id = contracts.index_by { |row| row["id"] }
|
|
194
|
+
|
|
195
|
+
load_signatures.filter_map do |signature|
|
|
196
|
+
contract = by_id[signature["contract_id"]]
|
|
197
|
+
next if contract.nil?
|
|
198
|
+
|
|
199
|
+
actor = resolve_actor(signature)
|
|
200
|
+
next if actor.nil?
|
|
201
|
+
|
|
202
|
+
import_one(signature, contract, actor)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def import_one(signature, contract, actor)
|
|
207
|
+
Legacy.new(
|
|
208
|
+
policy: Clickwrap.policy!(policy_key),
|
|
209
|
+
actor: actor,
|
|
210
|
+
occurred_at: occurred_at_for(signature),
|
|
211
|
+
known: known_for(signature, contract),
|
|
212
|
+
unknown: UNKNOWN_FIELDS,
|
|
213
|
+
because: because || default_reason(signature, contract),
|
|
214
|
+
source: "fine_print",
|
|
215
|
+
capture_channel: "imported_provider",
|
|
216
|
+
dry_run: dry_run
|
|
217
|
+
).import!
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# FinePrint records when the signature row was created. That is the best
|
|
221
|
+
# time available and it is recorded as `occurred_at` — separate, as
|
|
222
|
+
# always, from when this import wrote the Clickwrap event down.
|
|
223
|
+
def occurred_at_for(signature)
|
|
224
|
+
signature["created_at"] || signature["updated_at"]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def known_for(signature, contract)
|
|
228
|
+
{
|
|
229
|
+
"source_system" => "fine_print",
|
|
230
|
+
"fine_print_signature_id" => signature["id"],
|
|
231
|
+
"fine_print_contract_id" => contract["id"],
|
|
232
|
+
"contract_name" => contract["name"],
|
|
233
|
+
"contract_title" => contract["title"],
|
|
234
|
+
# Deliberately NOT written as `document_version`: that key makes the
|
|
235
|
+
# legacy importer link published bytes, and a FinePrint version number
|
|
236
|
+
# is a label in another system's numbering, not a claim about ours.
|
|
237
|
+
"fine_print_contract_version" => contract["version"],
|
|
238
|
+
"signed_by_type" => signature["user_type"] || signature["signer_type"],
|
|
239
|
+
"signed_by_id" => signature["user_id"] || signature["signer_id"]
|
|
240
|
+
}.compact
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def default_reason(signature, contract)
|
|
244
|
+
"Imported from FinePrint signature #{signature["id"]} for contract " \
|
|
245
|
+
"#{contract["name"]} version #{contract["version"]}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def resolve_actor(signature)
|
|
249
|
+
find_actor_with.call(
|
|
250
|
+
signature["user_type"] || signature["signer_type"],
|
|
251
|
+
signature["user_id"] || signature["signer_id"]
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def document_key_for(contract)
|
|
256
|
+
return map_contract_with.call(contract)&.to_s if map_contract_with
|
|
257
|
+
|
|
258
|
+
contract["name"].to_s.presence
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def published_version(document_key, label)
|
|
262
|
+
document = ::Clickwrap::Document.find_by(document_key: document_key, tenant_key: nil)
|
|
263
|
+
return nil unless document
|
|
264
|
+
|
|
265
|
+
document.versions.find_by(version_label: label)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def summary(mappings, results)
|
|
269
|
+
unpublished = mappings.reject(&:published?).map { |mapping| "#{mapping.name}@#{mapping.version}" }
|
|
270
|
+
|
|
271
|
+
[
|
|
272
|
+
dry_run ? "Would import" : "Imported",
|
|
273
|
+
"#{results.length} FinePrint signature(s) into #{policy_key} as imported_legacy events.",
|
|
274
|
+
"Presentation manifest, IP address, call-to-action text, and protected action were not " \
|
|
275
|
+
"recorded by FinePrint and are marked unknown rather than filled in.",
|
|
276
|
+
if unpublished.empty?
|
|
277
|
+
nil
|
|
278
|
+
else
|
|
279
|
+
"No published Clickwrap document version matches: " \
|
|
280
|
+
"#{unpublished.join(", ")}. Those imports record the label " \
|
|
281
|
+
"only, not document bytes."
|
|
282
|
+
end
|
|
283
|
+
].compact.join(" ")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def quoted(table) = connection.quote_table_name(table)
|
|
287
|
+
def quoted_column(column) = connection.quote_column_name(column)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|