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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Integrity
5
+ # Finds committed events for which a configured external integrity adapter
6
+ # never left an attestation row, then asks only for those missing records.
7
+ # This closes the recoverable part of the after-commit crash window: the
8
+ # required evidence remains committed, and an operator can safely discover
9
+ # and retry the optional external work later.
10
+ #
11
+ # No local database can prove whether a process died immediately before or
12
+ # immediately after a provider accepted a request. Adapters should therefore
13
+ # use the event digest (timestamps) or exact chain snapshot (anchors) as their
14
+ # idempotency key. A retry may otherwise create a second valid provider
15
+ # record; Clickwrap preserves both and never calls that exactly-once delivery.
16
+ class AttestationReconciler
17
+ Outcome = Data.define(:event_id, :kind, :state, :attestation_id) do
18
+ def recorded? = attestation_id.present?
19
+
20
+ def to_h
21
+ {
22
+ "event_id" => event_id,
23
+ "kind" => kind,
24
+ "state" => state,
25
+ "attestation_id" => attestation_id
26
+ }.compact
27
+ end
28
+ end
29
+
30
+ Result = Data.define(:outcomes) do
31
+ def attempted = outcomes.length
32
+ def recorded = outcomes.count(&:recorded?)
33
+ def not_recorded = attempted - recorded
34
+
35
+ def counts
36
+ { "attempted" => attempted, "recorded" => recorded, "not_recorded" => not_recorded }
37
+ end
38
+
39
+ def clean? = not_recorded.zero?
40
+ def to_h = { "counts" => counts, "outcomes" => outcomes.map(&:to_h) }
41
+ end
42
+
43
+ class << self
44
+ def missing_counts(scope: Event.all)
45
+ configured_kinds.to_h do |kind|
46
+ eligible = eligible_scope(scope, kind)
47
+ recorded = IntegrityAttestation.where(kind: kind).select(:event_id)
48
+ [kind, eligible.where.not(id: recorded).count]
49
+ end
50
+ end
51
+
52
+ def configured_kinds
53
+ kinds = []
54
+ kinds << "third_party_timestamp" if Clickwrap.config.timestamp_receipts_with
55
+ kinds << "event_anchor" if Clickwrap.config.anchor_event_history_with
56
+ kinds
57
+ end
58
+
59
+ def eligible_scope(scope, kind)
60
+ kind == "event_anchor" ? scope.where.not(chain_scope: nil) : scope
61
+ end
62
+ end
63
+
64
+ def initialize(scope: Event.all, retry_failed_attestations: false)
65
+ @scope = scope
66
+ @retry_failed_attestations = retry_failed_attestations
67
+ end
68
+
69
+ attr_reader :scope, :retry_failed_attestations
70
+
71
+ def call
72
+ outcomes = []
73
+
74
+ each_event do |event|
75
+ configured_kinds_for(event).each do |kind|
76
+ next unless attempt_needed?(event, kind)
77
+
78
+ attestation = attest(event, kind)
79
+ outcomes << Outcome.new(
80
+ event_id: event.id,
81
+ kind: kind,
82
+ state: attestation&.state || "not_recorded",
83
+ attestation_id: attestation&.id
84
+ )
85
+ end
86
+ end
87
+
88
+ Result.new(outcomes: outcomes.freeze)
89
+ end
90
+
91
+ private
92
+
93
+ def each_event(&)
94
+ scope.respond_to?(:find_each) ? scope.find_each(&) : scope.each(&)
95
+ end
96
+
97
+ def configured_kinds_for(event)
98
+ self.class.configured_kinds.reject do |kind|
99
+ kind == "event_anchor" && event.chain_scope.blank?
100
+ end
101
+ end
102
+
103
+ def attempt_needed?(event, kind)
104
+ latest = IntegrityAttestation.where(event_id: event.id, kind: kind).order(:attempted_at, :id).last
105
+ latest.nil? || (retry_failed_attestations && latest.state == "failed")
106
+ end
107
+
108
+ def attest(event, kind)
109
+ attestor = Attestor.new(event)
110
+ kind == "event_anchor" ? attestor.anchor_event : attestor.timestamp_event
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Integrity
5
+ # Invokes optional external adapters only after the required event commit.
6
+ # An outage can never roll back the protected action; every ordinary result
7
+ # is persisted, and an exception is reported through the host's existing
8
+ # after-commit failure hook.
9
+ class Attestor
10
+ ChainSnapshot = Data.define(:chain_scope, :sequence, :last_event_id, :last_event_digest) do
11
+ def checkpoint_digest = last_event_digest
12
+ end
13
+
14
+ def self.attest_after_commit(event) = new(event).attest_after_commit
15
+
16
+ def initialize(event)
17
+ @event = event
18
+ end
19
+
20
+ attr_reader :event
21
+
22
+ def attest_after_commit
23
+ timestamp_event if Clickwrap.config.timestamp_receipts_with
24
+ anchor_event if Clickwrap.config.anchor_event_history_with && event.chain_scope.present?
25
+ end
26
+
27
+ def timestamp_event
28
+ adapter = Clickwrap.config.timestamp_receipts_with
29
+ attempted_at = Clickwrap.now
30
+ token = adapter.timestamp(event.event_digest)
31
+ token_body = normalized_result(token)
32
+ token_digest = token_body["digest"]
33
+
34
+ unless token_digest.to_s == event.event_digest.to_s
35
+ return record!(
36
+ kind: "third_party_timestamp", state: "failed", adapter: adapter,
37
+ attempted_at: attempted_at, provider_result: token_body,
38
+ verification: { "checked" => false, "verified" => false,
39
+ "detail" => "The provider result named a different digest." }
40
+ )
41
+ end
42
+
43
+ verification = if token_body["issued"] == true
44
+ normalized_result(adapter.verify(token_body["token"], event.event_digest))
45
+ else
46
+ { "checked" => false, "verified" => false,
47
+ "detail" => token_body["detail"] }
48
+ end
49
+
50
+ record!(
51
+ kind: "third_party_timestamp",
52
+ state: attestation_state(token_body["issued"], verification),
53
+ adapter: adapter,
54
+ attempted_at: attempted_at,
55
+ provider_result: token_body,
56
+ verification: verification,
57
+ provider_reference: token_body["token_reference"],
58
+ provider_reported_at: parse_time(token_body["provider_reported_time"])
59
+ )
60
+ rescue StandardError => error
61
+ record_exception(kind: "third_party_timestamp", adapter: adapter,
62
+ attempted_at: attempted_at, error: error)
63
+ end
64
+
65
+ def anchor_event
66
+ adapter = Clickwrap.config.anchor_event_history_with
67
+ attempted_at = Clickwrap.now
68
+ snapshot = ChainSnapshot.new(
69
+ chain_scope: event.chain_scope,
70
+ sequence: event.chain_sequence,
71
+ last_event_id: event.id,
72
+ last_event_digest: event.event_digest
73
+ )
74
+ publication = normalized_result(adapter.anchor(snapshot))
75
+ verification = if publication["anchored"] == true
76
+ # Verification receives the exact publication result
77
+ # as well as the expected chain head. An adapter that
78
+ # merely re-reads "some current head" cannot prove the
79
+ # reference stored beside this attestation is the one
80
+ # it actually checked.
81
+ normalized_result(adapter.verify(publication, snapshot))
82
+ else
83
+ { "checked" => false, "verified" => false,
84
+ "detail" => publication["detail"] }
85
+ end
86
+
87
+ record!(
88
+ kind: "event_anchor",
89
+ state: attestation_state(publication["anchored"], verification),
90
+ adapter: adapter,
91
+ attempted_at: attempted_at,
92
+ provider_result: publication,
93
+ verification: verification,
94
+ provider_reference: publication["reference"],
95
+ provider_reported_at: parse_time(publication["published_at"]),
96
+ chain_scope: event.chain_scope,
97
+ chain_sequence: event.chain_sequence
98
+ )
99
+ rescue StandardError => error
100
+ record_exception(kind: "event_anchor", adapter: adapter,
101
+ attempted_at: attempted_at, error: error,
102
+ chain_scope: event.chain_scope, chain_sequence: event.chain_sequence)
103
+ end
104
+
105
+ private
106
+
107
+ def record!(kind:, state:, adapter:, attempted_at:, provider_result:, verification:,
108
+ provider_reference: nil, provider_reported_at: nil, chain_scope: nil, chain_sequence: nil)
109
+ IntegrityAttestation.create!(
110
+ event_id: event.id,
111
+ kind: kind,
112
+ state: state,
113
+ provider_name: provider_name(adapter, provider_result),
114
+ subject_digest: event.event_digest,
115
+ chain_scope: chain_scope,
116
+ chain_sequence: chain_sequence,
117
+ provider_reference: provider_reference,
118
+ provider_result: provider_result,
119
+ verification: verification,
120
+ adapter_capabilities: safe_capabilities(adapter),
121
+ attempted_at: attempted_at,
122
+ provider_reported_at: provider_reported_at,
123
+ created_at: Clickwrap.now
124
+ )
125
+ end
126
+
127
+ def attestation_state(issued, verification)
128
+ return "unavailable" unless issued == true
129
+ return "verified" if verification["checked"] == true && verification["verified"] == true
130
+
131
+ "issued_unverified"
132
+ end
133
+
134
+ def provider_name(adapter, body)
135
+ body["provider_name"].presence ||
136
+ (adapter.respond_to?(:provider_name) ? adapter.provider_name.to_s : adapter.class.name)
137
+ end
138
+
139
+ def normalized_result(value)
140
+ body = value.respond_to?(:to_h) ? value.to_h : value
141
+ unless body.respond_to?(:to_h)
142
+ raise ConfigurationError,
143
+ "An integrity adapter must return a Hash-like result, got #{value.class}."
144
+ end
145
+
146
+ body.to_h.each_with_object({}) do |(key, nested), result|
147
+ result[key.to_s] = normalize_value(nested)
148
+ end
149
+ end
150
+
151
+ def normalize_value(value)
152
+ case value
153
+ when Hash then value.to_h { |key, nested| [key.to_s, normalize_value(nested)] }
154
+ when Array then value.map { |nested| normalize_value(nested) }
155
+ when Time, ActiveSupport::TimeWithZone then Receipt.format_time(value)
156
+ else value
157
+ end
158
+ end
159
+
160
+ def parse_time(value)
161
+ return value if value.is_a?(Time)
162
+ return nil if value.blank?
163
+
164
+ Time.parse(value.to_s).utc
165
+ rescue ArgumentError
166
+ nil
167
+ end
168
+
169
+ def safe_capabilities(adapter)
170
+ normalized_result(adapter.capabilities)
171
+ rescue StandardError => error
172
+ {
173
+ "unavailable" => true,
174
+ "error_class" => error.class.name,
175
+ "detail" => "The adapter's capabilities could not be read."
176
+ }
177
+ end
178
+
179
+ def record_exception(kind:, adapter:, attempted_at:, error:, chain_scope: nil, chain_sequence: nil)
180
+ adapter_error = error
181
+ IntegrityAttestation.create!(
182
+ event_id: event.id,
183
+ kind: kind,
184
+ state: "failed",
185
+ provider_name: safe_provider_name(adapter),
186
+ subject_digest: event.event_digest,
187
+ chain_scope: chain_scope,
188
+ chain_sequence: chain_sequence,
189
+ provider_result: {
190
+ "error_class" => adapter_error.class.name,
191
+ "detail" => adapter_error.message.to_s.slice(0, 1_000)
192
+ },
193
+ verification: {
194
+ "checked" => false,
195
+ "verified" => false,
196
+ "detail" => "The adapter raised before this attestation could be verified."
197
+ },
198
+ adapter_capabilities: safe_capabilities(adapter),
199
+ attempted_at: attempted_at,
200
+ created_at: Clickwrap.now
201
+ )
202
+ rescue StandardError => error
203
+ report_failure(error)
204
+ ensure
205
+ report_failure(adapter_error)
206
+ end
207
+
208
+ def safe_provider_name(adapter)
209
+ value = adapter.provider_name if adapter.respond_to?(:provider_name)
210
+ value.to_s.presence || adapter.class.name.presence || "unknown_integrity_adapter"
211
+ rescue StandardError
212
+ adapter.class.name.presence || "unknown_integrity_adapter"
213
+ end
214
+
215
+ def report_failure(error)
216
+ Clickwrap.report_after_commit_failure(error, event)
217
+ nil
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ module Integrity
5
+ # Walks the optional event chains and reports what still links up.
6
+ #
7
+ # result = Clickwrap::Integrity::Chain.verify
8
+ # result.success? # => true
9
+ # result.counts # => {"checked" => 41_882, "verified" => 41_882, "breaks" => 0}
10
+ # result.first_break # => nil
11
+ #
12
+ # ============================================================================
13
+ # WHAT A CHAIN DETECTS, EXACTLY. Each event carries the digest of the one
14
+ # before it, so an event that is later rewritten or removed stops linking up
15
+ # with its successors, and this walk finds the first place that happens. That
16
+ # is a real and useful property: ordinary corruption, a well-meant
17
+ # `update_column`, a restored partial backup, and a row edited by hand all
18
+ # show up here.
19
+ #
20
+ # What it does NOT do is stop, or detect, a privileged actor who rewrites an
21
+ # event AND every digest that follows it. Whoever can write the events table
22
+ # can usually write this table too, and a chain whose head lives in the same
23
+ # database as the chain cannot say otherwise. That is precisely the gap the
24
+ # optional independent anchor adapter addresses, and even then the claim is
25
+ # only ever as strong as the anchor. The chain makes rewriting history
26
+ # detectable for as long as the head remains trustworthy — no more than that,
27
+ # and this class never says more than that.
28
+ # ============================================================================
29
+ class Chain
30
+ BATCH_SIZE = 1_000
31
+
32
+ # Every way a walk can stop lining up, as a stable symbol so a monitor can
33
+ # branch on it without matching English.
34
+ REASONS = %i[
35
+ digest_does_not_match
36
+ previous_digest_does_not_link
37
+ sequence_gap
38
+ earlier_events_missing
39
+ chain_head_missing
40
+ chain_tail_missing
41
+ chain_head_event_mismatch
42
+ chain_head_digest_mismatch
43
+ ].freeze
44
+
45
+ Break = Data.define(:chain_scope, :chain_sequence, :event_id, :reason, :detail) do
46
+ def to_h
47
+ {
48
+ "chain_scope" => chain_scope,
49
+ "chain_sequence" => chain_sequence,
50
+ "event_id" => event_id,
51
+ "reason" => reason.to_s,
52
+ "detail" => detail
53
+ }.compact
54
+ end
55
+
56
+ def to_s = "#{chain_scope}##{chain_sequence} #{event_id}: #{detail}"
57
+ end
58
+
59
+ Result = Data.define(
60
+ :chaining_enabled,
61
+ :checked,
62
+ :verified,
63
+ :documented_dispositions,
64
+ :scopes,
65
+ :breaks,
66
+ :started_mid_chain
67
+ ) do
68
+ def success? = breaks.empty?
69
+ def first_break = breaks.first
70
+
71
+ def counts
72
+ {
73
+ "checked" => checked,
74
+ "verified" => verified,
75
+ "documented_dispositions" => documented_dispositions,
76
+ "breaks" => breaks.length,
77
+ "scopes" => scopes.length
78
+ }
79
+ end
80
+
81
+ def to_h
82
+ {
83
+ "chaining_enabled" => chaining_enabled,
84
+ "counts" => counts,
85
+ "scopes" => scopes,
86
+ "started_mid_chain" => started_mid_chain,
87
+ "first_break" => first_break&.to_h,
88
+ "breaks" => breaks.map(&:to_h),
89
+ "detects" => "An event rewritten or removed after it was written, for as long as the " \
90
+ "chain head remains trustworthy. Not a rewrite of the events and their " \
91
+ "digests together by a privileged actor."
92
+ }
93
+ end
94
+ end
95
+
96
+ # `from:` and `to:` accept either a chain sequence number or a time. A
97
+ # sequence is the natural way to re-check one span of a chain; a time is
98
+ # the natural way to run "everything since last night" from cron, and
99
+ # refusing one of them would just make an operator convert by hand.
100
+ def self.verify(scope: nil, from: nil, to: nil) = new(scope: scope, from: from, to: to).verify
101
+
102
+ def initialize(scope: nil, from: nil, to: nil)
103
+ @scope = scope&.to_s
104
+ @from = from
105
+ @to = to
106
+ @breaks = []
107
+ @checked = 0
108
+ @verified = 0
109
+ @documented_dispositions = 0
110
+ @started_mid_chain = []
111
+ end
112
+
113
+ attr_reader :scope, :from, :to
114
+
115
+ def verify
116
+ scopes = chain_scopes
117
+ scopes.each { |chain_scope| walk(chain_scope) }
118
+
119
+ Result.new(
120
+ chaining_enabled: !Clickwrap.config.chain_event_history_with.nil?,
121
+ checked: @checked,
122
+ verified: @verified,
123
+ documented_dispositions: @documented_dispositions,
124
+ scopes: scopes,
125
+ breaks: @breaks,
126
+ started_mid_chain: @started_mid_chain
127
+ )
128
+ end
129
+
130
+ private
131
+
132
+ def chain_scopes
133
+ return [scope] if scope
134
+
135
+ event_scopes = Event.where.not(chain_scope: nil).distinct.pluck(:chain_scope)
136
+ head_scopes = ChainHead.distinct.pluck(:chain_scope)
137
+ (event_scopes + head_scopes).compact.uniq.sort
138
+ end
139
+
140
+ def walk(chain_scope)
141
+ previous = nil
142
+
143
+ each_event(chain_scope) do |event|
144
+ @checked += 1
145
+
146
+ if previous.nil?
147
+ check_first(chain_scope, event)
148
+ else
149
+ check_link(chain_scope, event, previous)
150
+ end
151
+
152
+ check_digest(chain_scope, event)
153
+ previous = event
154
+ end
155
+
156
+ check_durable_head(chain_scope, previous) if full_chain_walk?
157
+ end
158
+
159
+ def check_durable_head(chain_scope, last_event)
160
+ head = ChainHead.find_by(chain_scope: chain_scope)
161
+
162
+ unless head
163
+ add_head_break(
164
+ chain_scope,
165
+ last_event,
166
+ :chain_head_missing,
167
+ "Events name this chain scope, but its durable chain-head row is missing. The walk " \
168
+ "cannot establish whether the newest recorded events are still present."
169
+ )
170
+ return
171
+ end
172
+
173
+ if last_event.nil?
174
+ return if head.chain_sequence.to_i.zero? && head.last_event_id.blank? && head.last_event_digest.blank?
175
+
176
+ add_head_break(
177
+ chain_scope,
178
+ nil,
179
+ :chain_tail_missing,
180
+ "The durable head records sequence #{head.chain_sequence} and event #{head.last_event_id}, " \
181
+ "but this scope has no event rows. Its recorded tail is missing."
182
+ )
183
+ return
184
+ end
185
+
186
+ if last_event.chain_sequence != head.chain_sequence
187
+ add_head_break(
188
+ chain_scope,
189
+ last_event,
190
+ :chain_tail_missing,
191
+ "The newest event row is sequence #{last_event.chain_sequence}, while the durable " \
192
+ "head records sequence #{head.chain_sequence}. One or more newest events are missing."
193
+ )
194
+ end
195
+
196
+ if last_event.id.to_s != head.last_event_id.to_s
197
+ add_head_break(
198
+ chain_scope,
199
+ last_event,
200
+ :chain_head_event_mismatch,
201
+ "The newest event row is #{last_event.id}, while the durable head names " \
202
+ "#{head.last_event_id}. The head and chain tail do not describe the same event."
203
+ )
204
+ end
205
+
206
+ return if Digest.secure_compare?(last_event.event_digest.to_s, head.last_event_digest.to_s)
207
+
208
+ add_head_break(
209
+ chain_scope,
210
+ last_event,
211
+ :chain_head_digest_mismatch,
212
+ "The newest event digest does not match the digest retained by the durable chain head."
213
+ )
214
+ end
215
+
216
+ # Keyset pagination on the (chain_scope, chain_sequence) index. `find_each`
217
+ # would order by primary key, and the primary key here is a ULID: close to
218
+ # chain order, but "close to" is not the property a chain walk can rely on.
219
+ def each_event(chain_scope, &block)
220
+ cursor = nil
221
+
222
+ loop do
223
+ relation = bounded(Event.where(chain_scope: chain_scope)).order(:chain_sequence).limit(BATCH_SIZE)
224
+ relation = relation.where(chain_sequence: (cursor + 1)..) if cursor
225
+ batch = relation.to_a
226
+ break if batch.empty?
227
+
228
+ batch.each(&block)
229
+ cursor = batch.last.chain_sequence
230
+ break if cursor.nil?
231
+ end
232
+ end
233
+
234
+ def bounded(relation)
235
+ relation = apply_bound(relation, from, :from)
236
+ apply_bound(relation, to, :to)
237
+ end
238
+
239
+ def apply_bound(relation, value, side)
240
+ return relation if value.nil?
241
+
242
+ column = value.is_a?(Integer) ? :chain_sequence : :recorded_at_by_server
243
+ range = side == :from ? (value..) : (..value)
244
+
245
+ relation.where(column => range)
246
+ end
247
+
248
+ # The first event of a walk is the one link that cannot be checked against
249
+ # a predecessor this walk has seen. Sequence 1 with no previous digest is a
250
+ # genuine chain start; anything else is either a deliberately bounded run
251
+ # (which is reported, not hidden) or a chain missing its beginning.
252
+ def check_first(chain_scope, event)
253
+ return if event.chain_sequence == 1 && event.previous_event_digest.blank?
254
+
255
+ if bounded_run?
256
+ @started_mid_chain << { "chain_scope" => chain_scope, "chain_sequence" => event.chain_sequence }
257
+ return
258
+ end
259
+
260
+ add_break(chain_scope, event, :earlier_events_missing,
261
+ "The walk starts at sequence #{event.chain_sequence}, so the events before it are " \
262
+ "no longer in the table and nothing can check the link into this one.")
263
+ end
264
+
265
+ def check_link(chain_scope, event, previous)
266
+ unless event.chain_sequence == previous.chain_sequence + 1
267
+ add_break(chain_scope, event, :sequence_gap,
268
+ "Sequence jumps from #{previous.chain_sequence} to #{event.chain_sequence}, so " \
269
+ "at least one event between them is gone.")
270
+ end
271
+
272
+ return if Digest.secure_compare?(event.previous_event_digest.to_s, previous.event_digest.to_s)
273
+
274
+ add_break(chain_scope, event, :previous_digest_does_not_link,
275
+ "This event records a different predecessor digest than event #{previous.id} " \
276
+ "actually has, so the two no longer form a chain.")
277
+ end
278
+
279
+ def check_digest(chain_scope, event)
280
+ case event.digest_integrity_status
281
+ when :verified
282
+ @verified += 1
283
+ when :documented_core_disposition
284
+ @documented_dispositions += 1
285
+ else
286
+ add_break(chain_scope, event, :digest_does_not_match,
287
+ "Recomputing the canonical body of this event produces a different digest than " \
288
+ "the one stored with it, and no valid disposition event accounts for the missing " \
289
+ "payload. Its meaningful bytes changed after it was written or its disposition " \
290
+ "marker is unexplained. That does not, on its own, say who changed it or when.")
291
+ end
292
+ end
293
+
294
+ def add_break(chain_scope, event, reason, detail)
295
+ @breaks << Break.new(chain_scope: chain_scope, chain_sequence: event.chain_sequence,
296
+ event_id: event.id, reason: reason, detail: detail)
297
+ end
298
+
299
+ def add_head_break(chain_scope, event, reason, detail)
300
+ @breaks << Break.new(
301
+ chain_scope: chain_scope,
302
+ chain_sequence: event&.chain_sequence,
303
+ event_id: event&.id,
304
+ reason: reason,
305
+ detail: detail
306
+ )
307
+ end
308
+
309
+ def bounded_run? = !from.nil?
310
+ def full_chain_walk? = from.nil? && to.nil?
311
+ end
312
+ end
313
+ end