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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. 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