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,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Answers "does this actor currently satisfy this policy?" and, when the
5
+ # answer is no, exactly why.
6
+ #
7
+ # The convention across the whole gem is consistent: predicates answer
8
+ # booleans, `verify` returns a Result, and bang methods raise a typed error
9
+ # carrying that same Result. An application should never have to parse an
10
+ # English message to make an authorization decision, so the reason is always
11
+ # one of the stable symbols in Clickwrap::Vocabulary::VERIFICATION_ERRORS and
12
+ # the human sentence is localized separately.
13
+ module Verification
14
+ UNSPECIFIED = Object.new.freeze
15
+
16
+ # The structured answer. `details` carries stable machine-readable facts —
17
+ # never a surprise field of personal data, because a verification result
18
+ # routinely ends up in a log line or an API response.
19
+ class Result
20
+ attr_reader :error, :policy_key, :statement_key, :event_id, :details
21
+
22
+ def initialize(success:, error: nil, policy_key: nil, statement_key: nil,
23
+ event_id: nil, details: {})
24
+ @success = success
25
+ @error = error
26
+ @policy_key = policy_key
27
+ @statement_key = statement_key
28
+ @event_id = event_id
29
+ @details = details.freeze
30
+ freeze
31
+ end
32
+
33
+ def self.success(policy_key:, event_id: nil, details: {})
34
+ new(success: true, policy_key: policy_key, event_id: event_id, details: details)
35
+ end
36
+
37
+ # Whether this result's evidence was durably sequenced after another's.
38
+ # Events written after the recording-order migration reserve a
39
+ # database-assigned sequence, so this remains true across actors, app
40
+ # processes, and same-microsecond writes; ULID lexical order is
41
+ # deliberately not used as chronology. It returns false for either event
42
+ # predating that migration because an upgrade cannot invent honest order.
43
+ #
44
+ # declaration.recorded_after?(acknowledgments) # => true or false
45
+ #
46
+ # Accepts another verification result or a bare event id. False whenever
47
+ # either side has no event, which composes with `success?` the way a
48
+ # guard should: nothing about a missing act is "after" anything.
49
+ def recorded_after?(other)
50
+ other_event_id = other.respond_to?(:event_id) ? other.event_id : other
51
+ return false unless event_id.present? && other_event_id.present?
52
+
53
+ current, previous = Event.where(id: [event_id, other_event_id]).index_by(&:id).values_at(
54
+ event_id.to_s, other_event_id.to_s
55
+ )
56
+ return false unless current && previous
57
+ return false if current.recording_sequence.blank? || previous.recording_sequence.blank?
58
+
59
+ current.recording_sequence > previous.recording_sequence
60
+ end
61
+
62
+ # One predicate per stable error symbol, generated from the vocabulary so
63
+ # the two can never drift: `result.subject_fingerprint_mismatch?`,
64
+ # `result.no_evidence?`, `result.consent_withdrawn?`, … Branching on a
65
+ # predicate reads aloud and survives typos (a misspelled predicate is
66
+ # NoMethodError; a misspelled symbol comparison is silently false).
67
+ Vocabulary::VERIFICATION_ERRORS.each do |error_symbol|
68
+ define_method("#{error_symbol}?") { error == error_symbol }
69
+ end
70
+
71
+ def self.failure(error, policy_key: nil, statement_key: nil, event_id: nil, details: {})
72
+ unless Vocabulary::VERIFICATION_ERRORS.include?(error)
73
+ raise ArgumentError,
74
+ "#{error.inspect} is not a stable verification error. Add it to " \
75
+ "Clickwrap::Vocabulary::VERIFICATION_ERRORS so applications can branch on it."
76
+ end
77
+
78
+ new(success: false, error: error, policy_key: policy_key, statement_key: statement_key,
79
+ event_id: event_id, details: details)
80
+ end
81
+
82
+ def success? = @success
83
+ def failure? = !@success
84
+
85
+ def message
86
+ return I18n.t("clickwrap.verification.success", default: "Verified.") if success?
87
+
88
+ I18n.t(
89
+ "clickwrap.verification.errors.#{error}",
90
+ policy: policy_key,
91
+ statement: statement_key,
92
+ default: default_message
93
+ )
94
+ end
95
+
96
+ def to_h
97
+ {
98
+ "success" => success?,
99
+ "policy" => policy_key,
100
+ "statement" => statement_key,
101
+ "error" => error&.to_s,
102
+ "event_id" => event_id,
103
+ "details" => details
104
+ }.compact
105
+ end
106
+
107
+ def as_json(*) = to_h
108
+
109
+ def inspect
110
+ success? ? "#<Clickwrap::Verification::Result success>" : "#<Clickwrap::Verification::Result #{error}>"
111
+ end
112
+
113
+ private
114
+
115
+ def default_message
116
+ error.to_s.tr("_", " ").capitalize
117
+ end
118
+ end
119
+
120
+ class << self
121
+ # Verifies a policy for an actor, or re-verifies one specific recorded
122
+ # event. Both are the same question asked from different ends: the first
123
+ # is "is there current evidence", the second is "is this evidence still
124
+ # good for this exact operation".
125
+ #
126
+ # "The same question" is a promise, so both ends answer every part of it.
127
+ # `subject:` re-derives the subject fingerprint from the live record, and
128
+ # `require_current_revision: true` re-asks whether the act was made under
129
+ # the wording that is current now — on an event id exactly as on a policy
130
+ # key. That is the whole reason a host never has to reach into
131
+ # Clickwrap::PolicyRevision or Clickwrap::SubjectFingerprint to ask
132
+ # "is this old evidence still good?":
133
+ #
134
+ # Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)
135
+ def verify(policy_or_event, actor: nil, subject: nil, tenant: nil, acting_for: UNSPECIFIED,
136
+ policy: nil, at: nil, require_current_revision: false)
137
+ at ||= Clickwrap.now
138
+
139
+ if policy_or_event.is_a?(String) && Identifier.valid?(policy_or_event)
140
+ verify_event(policy_or_event, policy: policy, subject: subject,
141
+ acting_for: acting_for, at: at,
142
+ require_current_revision: require_current_revision)
143
+ else
144
+ acting_for = nil if acting_for.equal?(UNSPECIFIED)
145
+ verify_policy(policy_or_event, actor: actor, subject: subject, tenant: tenant,
146
+ acting_for: acting_for, at: at,
147
+ require_current_revision: require_current_revision)
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def verify_policy(policy_key, actor:, subject:, tenant:, acting_for:, at:,
154
+ require_current_revision: false)
155
+ policy = Clickwrap.policies[policy_key.to_s]
156
+
157
+ return Result.failure(:unknown_policy, policy_key: policy_key.to_s) unless policy
158
+
159
+ policy.validate_tenant!(tenant)
160
+
161
+ actor_reference = reference_for(actor)
162
+
163
+ return Result.failure(:wrong_actor, policy_key: policy.key) unless actor_reference
164
+
165
+ states = load_states(policy, actor_reference, tenant, subject, acting_for)
166
+ # Resolved once, only when asked for: "was this act made under the
167
+ # wording that is current NOW?" A bumped statement compiles a new
168
+ # revision, so evidence recorded under a superseded one re-asks — the
169
+ # verify-time counterpart of the capture-time :stale_policy_revision.
170
+ current_revision_id = (PolicyRevision.freeze_for(policy).id if require_current_revision)
171
+
172
+ policy.required_statements.each do |statement|
173
+ state = states[statement.key]
174
+
175
+ return Result.failure(:no_evidence, policy_key: policy.key, statement_key: statement.key) unless state
176
+
177
+ source_failure = check_state_source(policy, statement, state)
178
+ return source_failure if source_failure
179
+
180
+ failure = check_statement(policy, statement, state, subject, at)
181
+ return failure if failure
182
+
183
+ if current_revision_id && state.policy_revision_id != current_revision_id
184
+ return Result.failure(:stale_policy_revision, policy_key: policy.key,
185
+ statement_key: statement.key,
186
+ event_id: state.current_event_id)
187
+ end
188
+ end
189
+
190
+ newest = states.values.max_by(&:effective_at)
191
+ Result.success(policy_key: policy.key, event_id: newest&.current_event_id,
192
+ details: { "statements" => states.keys.sort })
193
+ end
194
+
195
+ def check_statement(policy, statement, state, subject, at)
196
+ unless state.satisfies?(at)
197
+ return Result.failure(state.failure_reason(at),
198
+ policy_key: policy.key,
199
+ statement_key: statement.key,
200
+ event_id: state.current_event_id)
201
+ end
202
+
203
+ if statement.subject_bound?
204
+ expected = subject_fingerprint_for(statement, subject)
205
+
206
+ if expected.nil?
207
+ return Result.failure(:wrong_subject, policy_key: policy.key,
208
+ statement_key: statement.key,
209
+ event_id: state.current_event_id)
210
+ end
211
+
212
+ unless Digest.secure_compare?(expected, state.subject_fingerprint.to_s)
213
+ return Result.failure(:subject_fingerprint_mismatch,
214
+ policy_key: policy.key,
215
+ statement_key: statement.key,
216
+ event_id: state.current_event_id)
217
+ end
218
+ end
219
+
220
+ if statement.requires_current_version?
221
+ stale = stale_document?(statement, state)
222
+
223
+ if stale
224
+ return Result.failure(:unseen_document_version,
225
+ policy_key: policy.key,
226
+ statement_key: statement.key,
227
+ event_id: state.current_event_id,
228
+ details: { "document" => stale })
229
+ end
230
+ end
231
+
232
+ # An authorization's prerequisites must have been made in the same
233
+ # submission, and before it. A fresh acknowledgment paired with a
234
+ # year-old declaration is not the evidence the policy asked for.
235
+ statement.requires.each do |prerequisite_key|
236
+ prerequisite = Event.where(id: state.root_event_id)
237
+ .joins(:statements)
238
+ .where(clickwrap_event_statements: { statement_key: prerequisite_key })
239
+ .exists?
240
+
241
+ next if prerequisite
242
+
243
+ return Result.failure(:predecessor_missing, policy_key: policy.key,
244
+ statement_key: statement.key, event_id: state.current_event_id,
245
+ details: { "requires" => prerequisite_key })
246
+ end
247
+
248
+ nil
249
+ end
250
+
251
+ def check_state_source(policy, statement, state)
252
+ event = Event.includes(:statements, documents: :document_version).find_by(id: state.current_event_id)
253
+ unless event
254
+ return Result.failure(:no_evidence, policy_key: policy.key,
255
+ statement_key: statement.key)
256
+ end
257
+
258
+ if event.disposed?
259
+ if event.documented_core_disposition?
260
+ return Result.failure(:core_event_disposed, policy_key: policy.key,
261
+ statement_key: statement.key,
262
+ event_id: event.id)
263
+ end
264
+
265
+ return Result.failure(:integrity_check_failed, policy_key: policy.key,
266
+ statement_key: statement.key,
267
+ event_id: event.id,
268
+ details: { "disposition" => "not_documented" })
269
+ end
270
+
271
+ unless event.evidence_integrity_verified?
272
+ return Result.failure(:integrity_check_failed, policy_key: policy.key,
273
+ statement_key: statement.key,
274
+ event_id: event.id,
275
+ details: {
276
+ "request_evidence_binding" =>
277
+ event.request_evidence_binding_status.to_s
278
+ })
279
+ end
280
+
281
+ recorded = event.statement(statement.key)
282
+ identity_source = statement_identity_source(event, state)
283
+ identity_matches = identity_source &&
284
+ event.policy_key == policy.key &&
285
+ identity_source.policy_key == policy.key &&
286
+ identity_source.actor_reference == state.actor_reference &&
287
+ identity_source.tenant_key.to_s == state.tenant_key.to_s &&
288
+ identity_source.subject_key.to_s == state.subject_key.to_s &&
289
+ identity_source.represented_party_reference.to_s ==
290
+ state.represented_party_reference.to_s &&
291
+ recorded&.kind == statement.kind &&
292
+ recorded&.action == state.current_action
293
+
294
+ unless identity_matches
295
+ return Result.failure(:integrity_check_failed, policy_key: policy.key,
296
+ statement_key: statement.key,
297
+ event_id: event.id)
298
+ end
299
+
300
+ # An active grant needs a source that can legitimately grant: a human
301
+ # action this application captured, or a legacy record imported with
302
+ # its provenance. An exemption or a lifecycle event backing an
303
+ # "active" projection is a hand-crafted row, and stays refused.
304
+ active_grant_source = event.human_action? || event.event_type == "imported_legacy"
305
+ if state.state == "active" && !active_grant_source
306
+ return Result.failure(:no_evidence, policy_key: policy.key,
307
+ statement_key: statement.key,
308
+ event_id: event.id)
309
+ end
310
+
311
+ mismatched = event.documents.any? do |binding|
312
+ !binding.still_matches_stored_version? ||
313
+ !binding.document_version&.verify_content_digest ||
314
+ !binding.document_version&.verify_rendered_content_digest
315
+ end
316
+
317
+ return unless mismatched
318
+
319
+ Result.failure(:document_digest_mismatch, policy_key: policy.key,
320
+ statement_key: statement.key,
321
+ event_id: event.id)
322
+ end
323
+
324
+ # Lifecycle events record who performed the transition. For an automatic
325
+ # expiry/consumption that is a system actor; for an administrative
326
+ # revocation it may be an operator. Neither replaces the human whose
327
+ # statement is being changed. Its immutable root event is therefore the
328
+ # source of actor/tenant/subject identity, while the current event is the
329
+ # source of the transition action itself.
330
+ def statement_identity_source(event, state)
331
+ return event if CurrentState::INITIAL_EVENT_TYPES.include?(event.event_type)
332
+ return nil unless CurrentState::TRANSITION_STATE_BY_EVENT_TYPE.key?(event.event_type)
333
+ return nil unless event.root_event_id.to_s == state.root_event_id.to_s
334
+
335
+ root = Event.find_by(id: event.root_event_id)
336
+ predecessor = Event.find_by(id: event.predecessor_event_id)
337
+ return nil unless root&.digest_verified? && predecessor&.digest_verified?
338
+ return nil unless (predecessor.root_event_id.presence || predecessor.id).to_s == root.id.to_s
339
+
340
+ root
341
+ end
342
+
343
+ def verify_event(event_id, policy:, subject:, acting_for:, at:, require_current_revision: false)
344
+ event = Event.find_by(id: event_id)
345
+
346
+ return Result.failure(:no_evidence, event_id: event_id) unless event
347
+
348
+ if policy && event.policy_key != policy.to_s
349
+ return Result.failure(:presentation_policy_mismatch, policy_key: event.policy_key,
350
+ event_id: event.id)
351
+ end
352
+
353
+ if event.disposed?
354
+ if event.documented_core_disposition?
355
+ return Result.failure(:core_event_disposed, policy_key: event.policy_key, event_id: event.id)
356
+ end
357
+
358
+ return Result.failure(
359
+ :integrity_check_failed,
360
+ policy_key: event.policy_key,
361
+ event_id: event.id,
362
+ details: { "disposition" => "not_documented" }
363
+ )
364
+ end
365
+
366
+ unless event.evidence_integrity_verified?
367
+ return Result.failure(
368
+ :integrity_check_failed,
369
+ policy_key: event.policy_key,
370
+ event_id: event.id,
371
+ details: { "request_evidence_binding" => event.request_evidence_binding_status.to_s }
372
+ )
373
+ end
374
+
375
+ # Two different ways a document can stop matching, and both have to be
376
+ # caught. The first is the version row being swapped or its recorded
377
+ # digest changed. The second is subtler and more likely: the row keeps
378
+ # its digest column while someone edits the bytes underneath it, which
379
+ # would leave every receipt citing it silently describing content that
380
+ # is no longer there. So the stored bytes get re-digested, not trusted.
381
+ mismatched = event.documents.reject do |binding|
382
+ binding.still_matches_stored_version? &&
383
+ binding.document_version&.verify_content_digest &&
384
+ binding.document_version.verify_rendered_content_digest
385
+ end
386
+
387
+ unless mismatched.empty?
388
+ return Result.failure(:document_digest_mismatch, policy_key: event.policy_key,
389
+ event_id: event.id,
390
+ details: { "documents" => mismatched.map(&:document_key) })
391
+ end
392
+
393
+ if subject && event.subject_key.present? &&
394
+ event.subject_key != StatementState.subject_key_for(subject)
395
+ return Result.failure(:wrong_subject, policy_key: event.policy_key, event_id: event.id)
396
+ end
397
+
398
+ fingerprint_failure = check_event_subject_fingerprint(event, subject)
399
+ return fingerprint_failure if fingerprint_failure
400
+
401
+ if require_current_revision
402
+ stale = check_event_policy_revision(event)
403
+ return stale if stale
404
+ end
405
+
406
+ if !acting_for.equal?(UNSPECIFIED) &&
407
+ event.represented_party_reference.to_s != Reference.represented_party(acting_for).to_s
408
+ return Result.failure(:represented_party_mismatch,
409
+ policy_key: event.policy_key, event_id: event.id)
410
+ end
411
+
412
+ Result.success(
413
+ policy_key: event.policy_key,
414
+ event_id: event.id,
415
+ details: { "request_evidence_binding" => event.request_evidence_binding_status.to_s }
416
+ )
417
+ end
418
+
419
+ # The same subject binding `verify_policy` applies, applied to one
420
+ # recorded event. Matching `subject_key` only proves the evidence is
421
+ # about the same RECORD; the fingerprint is what proves it is still about
422
+ # the same record in the same state, which is the whole point of a policy
423
+ # that fingerprints its subject.
424
+ #
425
+ # Recomputing needs the compiled policy, so a policy that is no longer
426
+ # declared is reported as `:unknown_policy` rather than passed. "We can
427
+ # no longer check this" is not the same answer as "this is fine", and on
428
+ # this question they must never be spelled the same way.
429
+ def check_event_subject_fingerprint(event, subject)
430
+ return nil if subject.nil? || event.subject_fingerprint.blank?
431
+
432
+ policy = Clickwrap.policies[event.policy_key]
433
+
434
+ return Result.failure(:unknown_policy, policy_key: event.policy_key, event_id: event.id) unless policy
435
+
436
+ expected = SubjectFingerprint.for(policy, subject)
437
+
438
+ return nil if expected && Digest.secure_compare?(expected, event.subject_fingerprint.to_s)
439
+
440
+ Result.failure(:subject_fingerprint_mismatch, policy_key: event.policy_key, event_id: event.id)
441
+ end
442
+
443
+ # "Was this act made under the wording that is current NOW?", asked of one
444
+ # event. Deliberately compared by digest against the compiled policy
445
+ # rather than through `PolicyRevision.freeze_for`, because verifying is a
446
+ # read: asking whether old evidence is still good must not write a new
447
+ # revision row as a side effect.
448
+ def check_event_policy_revision(event)
449
+ policy = Clickwrap.policies[event.policy_key]
450
+
451
+ return Result.failure(:unknown_policy, policy_key: event.policy_key, event_id: event.id) unless policy
452
+
453
+ recorded = event.policy_revision&.revision_digest
454
+ return nil if recorded.present? && Digest.secure_compare?(recorded, policy.revision.to_s)
455
+
456
+ Result.failure(:stale_policy_revision, policy_key: event.policy_key, event_id: event.id)
457
+ end
458
+
459
+ def load_states(policy, actor_reference, tenant, subject, acting_for)
460
+ StatementState
461
+ .for_policy(policy.key)
462
+ .for_actor(actor_reference)
463
+ .where(
464
+ tenant_key: tenant_key_for(tenant),
465
+ subject_key: StatementState.subject_key_for(subject),
466
+ represented_party_reference: Reference.represented_party(acting_for)
467
+ )
468
+ .index_by(&:statement_key)
469
+ end
470
+
471
+ def reference_for(actor)
472
+ Reference.actor(actor)
473
+ end
474
+
475
+ def tenant_key_for(tenant)
476
+ Reference.tenant(tenant)
477
+ end
478
+
479
+ def subject_fingerprint_for(statement, subject)
480
+ return nil if subject.nil? || statement.subject_fingerprint_with.nil?
481
+
482
+ value = statement.subject_fingerprint_with.call(subject)
483
+ value.nil? ? nil : Digest.digest(value.to_s)
484
+ end
485
+
486
+ # Returns the document key whose current published version is newer than
487
+ # the one this evidence was captured against, or nil when everything the
488
+ # statement requires is still current.
489
+ def stale_document?(statement, state)
490
+ recorded = Array(state.document_version_ids).map(&:to_s)
491
+
492
+ statement.document_keys.find do |document_key|
493
+ document = ::Clickwrap::Document.find_by(
494
+ document_key: document_key,
495
+ tenant_key: state.tenant_key.presence
496
+ )
497
+ current = document&.current_version(locale: I18n.locale)
498
+
499
+ current && !recorded.include?(current.id.to_s)
500
+ end
501
+ end
502
+ end
503
+ end
504
+ end
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clickwrap
4
- VERSION = "0.0.0"
4
+ VERSION = "0.1.0"
5
+
6
+ # The canonical schema version for receipts, event digests, and presentation
7
+ # manifests. This is deliberately independent of VERSION: gem releases may
8
+ # come and go without changing how historical evidence is serialized, and a
9
+ # change here always means a new explicit schema plus a verifier that still
10
+ # reads every previously released version.
11
+ CANONICAL_SCHEMA_VERSION = "clickwrap.receipt.v1"
12
+
13
+ # Bumped only when the receiver-side verification logic changes in a way an
14
+ # auditor should be able to see in a receipt.
15
+ VERIFIER_VERSION = "1"
5
16
  end