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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Import
5
+ # `Clickwrap.import_legacy!` — bring an `accepted_terms_at` column, a
6
+ # bespoke audit table, or any other pre-Clickwrap record into the event log
7
+ # without inventing anything that was never recorded.
8
+ #
9
+ # ===========================================================================
10
+ # The governing rule: HISTORICAL WEAKNESS STAYS VISIBLE RATHER THAN BEING
11
+ # LAUNDERED INTO MODERN CERTAINTY.
12
+ #
13
+ # An import is the single easiest place in this gem to manufacture evidence
14
+ # by accident. Every field a modern capture fills in is sitting right there
15
+ # with an obvious plausible value: the current Terms text, today's document
16
+ # digest, the submit-button label from the current view, the assertion
17
+ # sentence from the current policy, an IP address from the user's last
18
+ # session. Writing any of them here would produce a row that is
19
+ # indistinguishable from a real capture and is, in the parts that matter, a
20
+ # fabrication.
21
+ #
22
+ # So this importer NEVER synthesizes:
23
+ #
24
+ # * a presentation manifest — nobody signed one, and there is no offer to
25
+ # reproduce;
26
+ # * an assertion — we do not know the sentence the old system offered;
27
+ # * submit-button text — we do not know what the control said;
28
+ # * an IP address or browser user-agent — these were not observed by us,
29
+ # and a later session's address is a different fact about a different
30
+ # request;
31
+ # * document bytes or a digest — unless the caller can point at a version
32
+ # that is actually published here.
33
+ #
34
+ # Every key the caller lists in `unknown:` is recorded explicitly as unknown
35
+ # in a structured field on the event, AND said in plain words in the
36
+ # assertion text of each statement — which is inside the digested canonical
37
+ # body, so the admission travels with the evidence rather than beside it.
38
+ #
39
+ # The event also keeps `occurred_at` (when it happened, according to the old
40
+ # record) strictly separate from `recorded_at_by_server` (when we wrote it
41
+ # down). That gap is a fact about the evidence and it stays visible.
42
+ # ===========================================================================
43
+ class Legacy
44
+ # What an import did, or would do. Returned by both the dry run and the
45
+ # real thing so a migration script reads the same either way.
46
+ Result = Data.define(:status, :policy_key, :actor_reference, :occurred_at,
47
+ :recorded_at_by_server, :known, :unknown, :statement_keys,
48
+ :counts_as_current, :idempotency_key, :event, :message) do
49
+ def imported? = status == :imported
50
+ def already_imported? = status == :already_imported
51
+ def planned? = status == :planned
52
+ def written? = !event.nil?
53
+ def receipt = event && Receipt.new(event)
54
+ def event_id = event&.id
55
+ def to_s = message
56
+ end
57
+
58
+ # The capture channels an import may claim. `imported_provider` means the
59
+ # record came from another system; `system` means this application wrote
60
+ # it without a human at a keyboard. Neither is `web_browser`, because no
61
+ # browser was involved and a receipt that said otherwise would be wrong.
62
+ PERMITTED_CHANNELS = %w[imported_provider system].freeze
63
+
64
+ # Naming an unknown as unknown is the whole point, so the vocabulary is
65
+ # open: a host may list any key it wants. These are the ones the README
66
+ # and the FinePrint importer use, kept here so a typo in a migration
67
+ # script is at least visibly a typo next to its neighbours.
68
+ CONVENTIONAL_UNKNOWN_KEYS = %w[
69
+ exact_document_bytes
70
+ document_version
71
+ presentation
72
+ presentation_manifest
73
+ assertion
74
+ submit_button_text
75
+ protected_action
76
+ request_evidence
77
+ ip_address
78
+ browser_user_agent
79
+ capture_channel
80
+ authentication_context
81
+ ].freeze
82
+
83
+ def initialize(policy:, actor:, occurred_at:, because:, known: {}, unknown: [],
84
+ dry_run: false, subject: nil, tenant: nil, statements: nil,
85
+ capture_channel: "imported_provider", source: nil,
86
+ counts_as_current: true)
87
+ @policy = policy
88
+ @actor = actor
89
+ @occurred_at = coerce_time(occurred_at)
90
+ @because = because.to_s
91
+ @known = normalize_known(known)
92
+ @unknown = normalize_unknown(unknown)
93
+ @dry_run = dry_run
94
+ @subject = subject
95
+ @tenant = tenant
96
+ @statement_keys = statements&.map(&:to_s)
97
+ @capture_channel = capture_channel.to_s
98
+ @source = source&.to_s
99
+ @counts_as_current = counts_as_current == true
100
+ end
101
+
102
+ attr_reader :policy, :actor, :occurred_at, :because, :known, :unknown, :dry_run,
103
+ :subject, :tenant, :capture_channel, :source, :counts_as_current
104
+
105
+ def import!
106
+ validate!
107
+
108
+ existing = Event.find_by(policy_key: policy.key, idempotency_key: idempotency_key)
109
+ return already_imported(existing) if existing
110
+
111
+ return planned if dry_run
112
+
113
+ write!
114
+ end
115
+
116
+ alias call import!
117
+
118
+ # The derived key. Two runs of the same migration script over the same
119
+ # legacy row produce the same key, so re-running an import is a no-op
120
+ # rather than a second history for the same person.
121
+ #
122
+ # It covers what the legacy record actually said: who, which policy, which
123
+ # subject and tenant, when it happened, and every `known:` value. Change
124
+ # any of those and it is a different import, which is correct — a
125
+ # different claim deserves a different event rather than silently
126
+ # colliding with the first one.
127
+ def idempotency_key
128
+ @idempotency_key ||= "imported_legacy:#{Digest.hex(CanonicalJson.generate(identity_body))}"
129
+ end
130
+
131
+ private
132
+
133
+ def identity_body
134
+ {
135
+ "policy" => policy.key,
136
+ "actor" => actor_reference,
137
+ "subject" => subject_key,
138
+ "tenant" => tenant_key,
139
+ "occurred_at" => Receipt.format_time(occurred_at),
140
+ "statements" => statements_to_import.map(&:key),
141
+ "known" => known,
142
+ "unknown" => unknown,
143
+ "source" => source,
144
+ "capture_channel" => capture_channel,
145
+ "counts_as_current" => counts_as_current,
146
+ "because" => because
147
+ }
148
+ end
149
+
150
+ def validate!
151
+ policy.validate_tenant!(tenant)
152
+
153
+ if because.strip.empty?
154
+ raise ArgumentError,
155
+ "Importing legacy evidence needs a `because:` in plain English saying where the " \
156
+ "record came from — \"Imported from users.accepted_terms_at\" is exactly right. " \
157
+ "It is stored on the event, and years from now it is the only thing that will " \
158
+ "explain to a reader why this row does not look like a normal capture."
159
+ end
160
+
161
+ if occurred_at.nil?
162
+ raise ArgumentError,
163
+ "Importing legacy evidence needs `occurred_at:` — the time the old record says " \
164
+ "this happened. Clickwrap will not substitute the time of the import, because " \
165
+ "the distance between when something happened and when it was written down is " \
166
+ "itself evidence. If the legacy row genuinely has no time, do not import it as " \
167
+ "an act; record an exemption with `Clickwrap.exempt!` instead."
168
+ end
169
+
170
+ return if PERMITTED_CHANNELS.include?(capture_channel)
171
+
172
+ raise ArgumentError,
173
+ "An import cannot claim capture channel #{capture_channel.inspect}. An imported " \
174
+ "record was not captured through a presentation this application rendered, so it " \
175
+ "is one of: #{PERMITTED_CHANNELS.join(", ")}."
176
+ end
177
+
178
+ def statements_to_import
179
+ if @statement_keys
180
+ @statement_keys.map { |key| policy.statement!(key) }
181
+ else
182
+ # Optional statements are deliberately excluded. A legacy boolean
183
+ # column recorded one decision; reading it as a grant of an optional
184
+ # consent purpose it never mentioned would invent the very thing an
185
+ # optional control exists to keep honest. Name them in `statements:`
186
+ # if the old record really did cover them.
187
+ policy.required_statements
188
+ end
189
+ end
190
+
191
+ def write!
192
+ now = Clickwrap.now
193
+ revision = PolicyRevision.freeze_legacy_import_for(
194
+ policy,
195
+ source: source,
196
+ statements: statements_to_import,
197
+ known: known,
198
+ unknown: unknown
199
+ )
200
+ event = nil
201
+
202
+ ::ActiveRecord::Base.transaction do
203
+ # Actor lock BEFORE the event insert: saving the event reserves the
204
+ # chain head, and every writer takes these two locks actor-first
205
+ # (the order capture uses) so concurrent paths cannot deadlock.
206
+ StatementIdentityLock.acquire_for_actor!(actor_reference)
207
+
208
+ event = build_event(now, revision)
209
+ build_statements(event, now)
210
+ build_documents(event)
211
+
212
+ event.save!
213
+ event.finalize_integrity!
214
+ # Project into current state, exactly as a capture would: the point
215
+ # of a migration is that `agreed_to?` keeps answering what the old
216
+ # system answered. The projection carries this event's id, so the
217
+ # imported provenance is one join away from every "yes".
218
+ CurrentState.apply!(event) if counts_as_current
219
+ end
220
+
221
+ Result.new(
222
+ status: :imported, policy_key: policy.key, actor_reference: actor_reference,
223
+ occurred_at: occurred_at, recorded_at_by_server: now, known: known, unknown: unknown,
224
+ statement_keys: statements_to_import.map(&:key), counts_as_current: counts_as_current,
225
+ idempotency_key: idempotency_key,
226
+ event: event,
227
+ message: "Imported #{policy.key} for #{actor_reference} as event #{event.id}. " \
228
+ "#{unknown_sentence}"
229
+ )
230
+ end
231
+
232
+ def build_event(now, revision)
233
+ Event.new(
234
+ event_type: "imported_legacy",
235
+ policy_key: policy.key,
236
+ policy_revision: revision,
237
+ actor: actor.is_a?(::ActiveRecord::Base) ? actor : nil,
238
+ actor_reference: actor_reference,
239
+ tenant_key: tenant_key.presence,
240
+ subject: subject.is_a?(::ActiveRecord::Base) ? subject : nil,
241
+ subject_key: subject_key,
242
+ capture_channel: capture_channel,
243
+ # Not `authenticated_session`, not `unknown`: this record reached us
244
+ # from somewhere else, and that is a different fact from "we do not
245
+ # know how they were attributed".
246
+ attribution_method: "imported_provider",
247
+ # The two times stay apart. `occurred_at` is what the old record says;
248
+ # `recorded_at_by_server` is when this row was written. Collapsing
249
+ # them would quietly upgrade a migration into a contemporaneous
250
+ # observation.
251
+ occurred_at: occurred_at,
252
+ recorded_at_by_server: now,
253
+ idempotency_key: idempotency_key,
254
+ provider_receipt: known.presence,
255
+ provider_verification: import_provenance(now),
256
+ reason: because,
257
+ retention_class_key: policy.retention_class_key,
258
+ canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
259
+ gem_version: Clickwrap::VERSION,
260
+ application_version: Clickwrap.config.resolved_application_version,
261
+ created_at: now
262
+ # presentation_manifest and presentation_manifest_digest are left
263
+ # unset on purpose. There was no manifest. A synthesized one would be
264
+ # a signed description of an offer nobody made.
265
+ )
266
+ end
267
+
268
+ # The structured record of what this import knew and what it did not. It
269
+ # lives on the event so a reader never has to reconstruct the migration
270
+ # script to find out which parts of a receipt are missing on purpose.
271
+ def import_provenance(now)
272
+ {
273
+ "import_method" => "legacy_record",
274
+ "source" => source,
275
+ "because" => because,
276
+ "imported_at" => Receipt.format_time(now),
277
+ "occurred_at_source" => Receipt.format_time(occurred_at),
278
+ "known" => known,
279
+ "unknown" => unknown,
280
+ "counts_as_current" => counts_as_current,
281
+ "not_collected" => %w[presentation_manifest ip_address browser_user_agent ip_geolocation],
282
+ "means" => "Recorded from a pre-existing record in this application or another system. " \
283
+ "Clickwrap did not present this content and did not observe this action. " \
284
+ "The fields listed under \"unknown\" were not recorded by whatever did."
285
+ }.compact
286
+ end
287
+
288
+ def build_statements(event, now)
289
+ statements_to_import.each_with_index do |statement, index|
290
+ event.statements.build(
291
+ ordinal: index,
292
+ statement_key: statement.key,
293
+ kind: statement.kind,
294
+ action: statement.initial_action,
295
+ assertion_text: assertion_text_for(statement),
296
+ assertion_locale: "en",
297
+ required: statement.required?,
298
+ optional: statement.optional?,
299
+ # No answer was captured by this application. The action records
300
+ # what the old system says happened; `answered` records whether we
301
+ # have the answer itself, and we do not.
302
+ answer: nil,
303
+ answered: false,
304
+ purpose_key: statement.purpose_key,
305
+ withdrawal_path: statement.withdrawal_path,
306
+ valid_from: occurred_at,
307
+ expires_at: statement.expires_after(occurred_at),
308
+ one_time: statement.one_time?,
309
+ requires: statement.requires,
310
+ created_at: now
311
+ )
312
+ end
313
+ end
314
+
315
+ # The sentence that goes in the receipt. It is deliberately not the
316
+ # policy's current assertion text: that sentence may have been written
317
+ # years after the act, and putting it here would claim the source system
318
+ # offered wording that may not have existed yet.
319
+ def assertion_text_for(statement)
320
+ [
321
+ "Imported from a pre-existing record: it states that this actor " \
322
+ "#{Vocabulary.initial_action_for(statement.kind)} #{statement.key} " \
323
+ "on #{Receipt.format_time(occurred_at)}.",
324
+ "The source system's original offer wording was not recorded, so this receipt does " \
325
+ "not reproduce it.",
326
+ unknown_sentence,
327
+ because
328
+ ].compact.reject(&:empty?).join(" ")
329
+ end
330
+
331
+ def unknown_sentence
332
+ return "" if unknown.empty?
333
+
334
+ "Not recorded by the source and therefore unknown: #{unknown.join(", ")}."
335
+ end
336
+
337
+ # Documents are linked only when the caller can point at bytes that are
338
+ # actually published here AND has not told us the bytes are unknown. A
339
+ # version label alone is a claim about a label, not about content, and an
340
+ # EventDocument row asserts a digest.
341
+ def build_documents(event)
342
+ return if unknown.intersect?(%w[exact_document_bytes document_version])
343
+
344
+ label = known["document_version"]
345
+ return if label.blank?
346
+
347
+ ordinal = 0
348
+
349
+ statements_to_import.each do |statement|
350
+ statement.document_keys.each do |document_key|
351
+ version = published_version(document_key, label)
352
+ next unless version
353
+
354
+ event.documents.build(
355
+ statement_key: statement.key,
356
+ document_key: document_key,
357
+ document_version_id: version.id,
358
+ version_label: version.version_label,
359
+ locale: version.locale,
360
+ source_media_type: version.media_type,
361
+ source_content_digest: version.content_digest,
362
+ rendered_media_type: version.rendered_media_type.presence || version.media_type,
363
+ rendered_content_digest: version.rendered_content_digest.presence || version.content_digest,
364
+ renderer_name: version.renderer_name,
365
+ renderer_version: version.renderer_version,
366
+ sanitizer_name: version.sanitizer_name,
367
+ sanitizer_version: version.sanitizer_version,
368
+ ordinal: ordinal,
369
+ created_at: event.recorded_at_by_server
370
+ )
371
+
372
+ ordinal += 1
373
+ end
374
+ end
375
+ end
376
+
377
+ def published_version(document_key, label)
378
+ document = ::Clickwrap::Document.find_by(
379
+ document_key: document_key,
380
+ tenant_key: tenant_key.presence
381
+ )
382
+ document ||= ::Clickwrap::Document.find_by(document_key: document_key, tenant_key: nil)
383
+ return nil unless document
384
+
385
+ document.versions.find_by(version_label: label.to_s)
386
+ end
387
+
388
+ def already_imported(event)
389
+ Result.new(
390
+ status: :already_imported, policy_key: policy.key, actor_reference: actor_reference,
391
+ occurred_at: occurred_at, recorded_at_by_server: event.recorded_at_by_server,
392
+ known: known, unknown: unknown, statement_keys: event.statements.map(&:statement_key),
393
+ counts_as_current: event.provider_verification.to_h.fetch("counts_as_current", true),
394
+ idempotency_key: idempotency_key, event: event,
395
+ message: "Already imported as event #{event.id}; nothing was written."
396
+ )
397
+ end
398
+
399
+ def planned
400
+ Result.new(
401
+ status: :planned, policy_key: policy.key, actor_reference: actor_reference,
402
+ occurred_at: occurred_at, recorded_at_by_server: nil, known: known, unknown: unknown,
403
+ statement_keys: statements_to_import.map(&:key), counts_as_current: counts_as_current,
404
+ idempotency_key: idempotency_key,
405
+ event: nil,
406
+ message: "Would import #{policy.key} for #{actor_reference} " \
407
+ "(#{statements_to_import.map(&:key).join(", ")}). #{unknown_sentence}".strip
408
+ )
409
+ end
410
+
411
+ def normalize_known(raw)
412
+ (raw || {}).to_h { |key, value| [key.to_s, value&.to_s] }.compact
413
+ end
414
+
415
+ def normalize_unknown(raw)
416
+ Array(raw).map(&:to_s).reject(&:empty?).uniq.sort
417
+ end
418
+
419
+ # Legacy times arrive in whatever shape the old system stored them: a Time,
420
+ # a Date, an ActiveSupport::TimeWithZone, or — when a migration reads
421
+ # another gem's table through a raw connection — a plain String. All of
422
+ # them are coerced to UTC here rather than at four call sites, and an
423
+ # unparseable value becomes nil so `validate!` refuses the import with a
424
+ # sentence instead of raising NoMethodError somewhere downstream.
425
+ def coerce_time(value)
426
+ return nil if value.nil?
427
+ return value.utc if value.respond_to?(:utc)
428
+
429
+ parsed = value.respond_to?(:to_time) ? value.to_time : Time.parse(value.to_s)
430
+ parsed&.utc
431
+ rescue ArgumentError, TypeError
432
+ nil
433
+ end
434
+
435
+ def actor_reference
436
+ @actor_reference ||= Reference.actor(actor)
437
+ end
438
+
439
+ def subject_key = StatementState.subject_key_for(subject)
440
+
441
+ def tenant_key
442
+ return "" if tenant.nil?
443
+ return tenant.to_s if tenant.is_a?(String) || tenant.is_a?(Symbol)
444
+ return tenant.to_gid.to_s if tenant.respond_to?(:to_gid)
445
+
446
+ "#{tenant.class.name}/#{tenant.id}"
447
+ end
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Integrations
5
+ # Optional adapter for https://github.com/rameerez/organizations.
6
+ #
7
+ # There is deliberately no runtime dependency on that gem. The adapter is
8
+ # selected only by a policy that says
9
+ # `permit_acting_for_organization`, and it talks to the public Membership
10
+ # association/API when capture occurs. A missing gem, wrong represented
11
+ # party type, missing membership, stale/insufficient role, or insufficient
12
+ # permission all deny authority.
13
+ class OrganizationsAuthority
14
+ SOURCE = "organizations.membership"
15
+ ADAPTER_VERSION = "1"
16
+
17
+ def verify(actor:, represented_party:, authority_rule:, tenant:,
18
+ authentication_context:)
19
+ return denied unless organizations_available?
20
+ return denied unless represented_party.is_a?(::Organizations::Organization)
21
+ return denied if represented_party.respond_to?(:persisted?) && !represented_party.persisted?
22
+ return denied unless actor.respond_to?(:id) && actor.id.present?
23
+ return denied if actor.respond_to?(:persisted?) && !actor.persisted?
24
+ return denied unless represented_party.respond_to?(:memberships)
25
+ return denied if tenant.present? && Reference.tenant(tenant) != Reference.tenant(represented_party)
26
+
27
+ # Capture calls authority adapters inside its transaction. Taking the
28
+ # membership row lock makes a concurrent removal or demotion serialize
29
+ # with the evidence write instead of authorizing from a stale role.
30
+ membership = represented_party.memberships.lock.find_by(user_id: actor.id)
31
+ return denied unless membership
32
+ return denied unless sufficient_role?(membership, authority_rule.minimum_role)
33
+ return denied unless sufficient_permission?(membership, authority_rule.required_permission)
34
+
35
+ AuthorityDecision.new(
36
+ authorized: true,
37
+ source: SOURCE,
38
+ role: membership_role(membership),
39
+ verified_at: Clickwrap.now,
40
+ details: {
41
+ "adapter_version" => ADAPTER_VERSION,
42
+ "membership_reference" => Reference.record(membership),
43
+ "represented_party_reference" => Reference.record(represented_party),
44
+ "minimum_role" => authority_rule.minimum_role,
45
+ "required_permission" => authority_rule.required_permission,
46
+ "required_permission_was_granted" => authority_rule.required_permission.present? || nil,
47
+ "authentication_method" => authentication_context.to_h[:method]&.to_s
48
+ }.compact
49
+ )
50
+ rescue NoMethodError
51
+ denied
52
+ end
53
+
54
+ private
55
+
56
+ def organizations_available?
57
+ defined?(::Organizations::Membership) && defined?(::Organizations::Roles)
58
+ end
59
+
60
+ def membership_role(membership)
61
+ membership.respond_to?(:role_sym) ? membership.role_sym.to_s : membership.role.to_s
62
+ end
63
+
64
+ def sufficient_role?(membership, minimum_role)
65
+ return true if minimum_role.blank?
66
+
67
+ membership.respond_to?(:is_at_least?) && membership.is_at_least?(minimum_role)
68
+ end
69
+
70
+ def sufficient_permission?(membership, required_permission)
71
+ return true if required_permission.blank?
72
+
73
+ membership.respond_to?(:has_permission_to?) && membership.has_permission_to?(required_permission)
74
+ end
75
+
76
+ def denied
77
+ AuthorityDecision.new(authorized: false, source: SOURCE)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Integrity
5
+ # The adapter contract for publishing exact event-chain snapshots somewhere
6
+ # outside the primary database, plus a reference base implementation that
7
+ # publishes nowhere and says so. Configuration itself defaults to nil.
8
+ #
9
+ # config.anchor_event_history_with = MyIndependentAnchor.new
10
+ #
11
+ # WHY THIS SEAM EXISTS. A chain makes a rewrite of history detectable for as
12
+ # long as a checkpoint remains trustworthy, and the ordinary checkpoint lives
13
+ # in the same database as the events. Whoever can rewrite one can usually
14
+ # rewrite the other. Publishing an exact snapshot somewhere the application cannot quietly edit —
15
+ # an append-only object store with a retention lock, a separate account, a
16
+ # notary, a transparency log, a printout in a safe — narrows that gap.
17
+ #
18
+ # WHAT AN ANCHOR CLAIMS. Exactly what the place it published to can support,
19
+ # and not one word more. Clickwrap records the reference and the receipt an
20
+ # adapter returns; it never upgrades either into a guarantee the anchoring
21
+ # service did not make. An anchor does not make evidence impossible to
22
+ # alter, does not establish identity, and does not supply time — a storage
23
+ # service writing "received at 14:02" is telling you when it received bytes,
24
+ # which is a different claim from a timestamp authority's, and different
25
+ # again from proof that something happened at 14:02.
26
+ #
27
+ # WRITING ONE. Implement `#publish(chain_snapshot)` and
28
+ # `#verify(publication, chain_snapshot)`,
29
+ # and report honestly from `#capabilities`. Subclassing this class is the
30
+ # easy path: `Configuration#anchor_event_history_with=` checks that the
31
+ # object responds to `#anchor`, and the base class provides that name as the
32
+ # entry point to your `#publish`. An adapter written from scratch must
33
+ # respond to `#anchor` as well.
34
+ #
35
+ # Everything here is optional. Configuration defaults to nil; assigning an
36
+ # instance of this base class is an explicit unavailable/no-publication
37
+ # adapter for testing or capability reporting.
38
+ class Anchor
39
+ # What an adapter returns from `#publish`. `anchored: false` is a normal
40
+ # outcome, not an error — a no-op adapter, a provider outage, or a queue
41
+ # that has not drained yet all mean "not anchored", and the honest record
42
+ # says so rather than leaving a caller to infer it.
43
+ Publication = Data.define(:anchored, :reference, :published_at, :provider_name, :detail) do
44
+ def initialize(anchored: false, reference: nil, published_at: nil, provider_name: nil, detail: nil)
45
+ super
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ "anchored" => anchored,
51
+ "reference" => reference,
52
+ "published_at" => published_at && Receipt.format_time(published_at),
53
+ "provider_name" => provider_name,
54
+ "detail" => detail
55
+ }.compact
56
+ end
57
+ end
58
+
59
+ # `verified: false` with a `detail` is likewise ordinary. "We could not
60
+ # check" and "we checked and it does not match" are different answers, and
61
+ # `checked` keeps them apart.
62
+ Verification = Data.define(:checked, :verified, :reference, :provider_name, :detail) do
63
+ def initialize(checked: false, verified: false, reference: nil, provider_name: nil, detail: nil)
64
+ super
65
+ end
66
+
67
+ def to_h
68
+ {
69
+ "checked" => checked,
70
+ "verified" => verified,
71
+ "reference" => reference,
72
+ "provider_name" => provider_name,
73
+ "detail" => detail
74
+ }.compact
75
+ end
76
+ end
77
+
78
+ # Publishes this exact chain snapshot outside the primary database. Called after the
79
+ # events it covers have committed, never inside their transaction: an
80
+ # anchoring service cannot join a database transaction, and pretending
81
+ # otherwise is how a network timeout becomes a rolled-back capture.
82
+ def publish(_chain_head)
83
+ Publication.new(
84
+ anchored: false,
85
+ provider_name: provider_name,
86
+ detail: "This anchor adapter publishes nowhere, so this chain snapshot was not published outside " \
87
+ "the primary database."
88
+ )
89
+ end
90
+
91
+ # The name `Configuration#anchor_event_history_with=` checks for. Kept as
92
+ # a thin alias so the contract can read as `publish`/`verify` while the
93
+ # configuration keeps the verb it already documents.
94
+ def anchor(chain_head) = publish(chain_head)
95
+
96
+ # Re-reads what was published and compares it with the exact snapshot passed
97
+ # for this event. This is the half that does the work: publishing bytes nobody ever
98
+ # checks establishes nothing.
99
+ def verify(_publication, _chain_head)
100
+ Verification.new(
101
+ checked: false,
102
+ verified: false,
103
+ provider_name: provider_name,
104
+ detail: "This anchor adapter publishes nowhere, so there is no outside publication to compare " \
105
+ "with this chain snapshot."
106
+ )
107
+ end
108
+
109
+ # What this adapter actually supplies, in the words of the thing that
110
+ # supplies it. `clickwrap:doctor` and the receipt's integrity fragment read
111
+ # this rather than assuming a configured adapter means a stronger claim.
112
+ def capabilities
113
+ {
114
+ "name" => provider_name,
115
+ "available" => available?,
116
+ "publishes_outside_primary_database" => false,
117
+ "independently_verifiable" => false,
118
+ "supplies" => "Nothing. This is the default placeholder that reports the absence of an " \
119
+ "anchor for explicit adapter-contract tests. Configuration normally remains nil."
120
+ }
121
+ end
122
+
123
+ def available? = false
124
+
125
+ def provider_name = "no_anchor"
126
+
127
+ def to_s = "#{self.class.name} (#{provider_name})"
128
+ end
129
+ end
130
+ end