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,650 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Turns a verified submission into an evidence event, and — when the caller
5
+ # asks for it — commits that event in the same database transaction as the
6
+ # action it authorizes.
7
+ #
8
+ # That last part is the whole point of the gem, so it is worth being exact
9
+ # about what is promised.
10
+ #
11
+ # For a same-database protected action, `capture_and!` joins the caller's
12
+ # transaction. If the evidence write fails, the block's work rolls back with
13
+ # it. If the block raises, the event rolls back with it. Neither side can
14
+ # commit alone, and there is no rescue-and-continue path anywhere in here: an
15
+ # account that exists without the evidence that authorized it is precisely the
16
+ # failure this class exists to make impossible.
17
+ #
18
+ # For anything crossing a system boundary — a payment provider, an identity
19
+ # service, a remote signature — none of that applies, and pretending otherwise
20
+ # would be worse than useless. Use `Clickwrap.authorize_external_action!` and
21
+ # its outbox instead. This class will not claim atomicity it cannot deliver.
22
+ class Capture
23
+ # Internal control flow carrying the already-committed event for an
24
+ # idempotent replay. It never escapes Capture's public API.
25
+ class IdempotentReplay < StandardError
26
+ attr_reader :event
27
+
28
+ def initialize(event)
29
+ @event = event
30
+ super()
31
+ end
32
+ end
33
+ private_constant :IdempotentReplay
34
+
35
+ def initialize(policy:, actor: nil, subject: nil, tenant: nil, http_request: nil,
36
+ submission: nil, answers: nil, locale: nil, capture_channel: nil,
37
+ acting_for: nil, authentication_context: nil, attribution_method: nil,
38
+ idempotency_key: nil, prospective_actor: nil,
39
+ registration_flow_id: nil,
40
+ represented_party_creation_flow_id: nil,
41
+ consume_one_time_authorizations: true,
42
+ record_protected_outcome: true,
43
+ reason: nil, event_type: "capture", root_event_id: nil,
44
+ predecessor_event_id: nil, statement_action_overrides: {})
45
+ @policy = policy
46
+ @actor = actor
47
+ @prospective_actor = prospective_actor
48
+ @subject = subject
49
+ @tenant = tenant
50
+ @http_request = http_request
51
+ @submission = submission
52
+ @explicit_answers = answers
53
+ @locale = locale
54
+ @explicit_capture_channel = capture_channel&.to_s
55
+ @capture_channel = @explicit_capture_channel
56
+ @acting_for = acting_for
57
+ @authentication_context = authentication_context
58
+ @attribution_method = attribution_method
59
+ @explicit_idempotency_key = idempotency_key
60
+ @registration_flow_id = registration_flow_id
61
+ @represented_party_creation_flow_id = represented_party_creation_flow_id
62
+ @creating_represented_party = false
63
+ @consume_one_time_authorizations = consume_one_time_authorizations
64
+ @record_protected_outcome = record_protected_outcome == true
65
+ @reason = reason
66
+ @event_type = event_type.to_s
67
+ @root_event_id = root_event_id
68
+ @predecessor_event_id = predecessor_event_id
69
+ @statement_action_overrides = statement_action_overrides.to_h.transform_keys(&:to_s)
70
+ end
71
+
72
+ attr_reader :policy, :actor, :subject, :tenant, :http_request, :submission, :capture_channel
73
+
74
+ # Records evidence with no protected action attached.
75
+ def capture!
76
+ perform(protected_action: false) { |_pending| nil }
77
+ end
78
+
79
+ # Records evidence and runs the protected action inside the same
80
+ # transaction. The block receives a read-only PendingReceipt whose stable
81
+ # `event_id` the domain row can reference; export and verification are
82
+ # unavailable on it until commit, because until commit there is nothing to
83
+ # export.
84
+ def capture_and!(&block)
85
+ raise ArgumentError, "capture_and! needs a block containing the protected action" unless block
86
+
87
+ perform(protected_action: true, &block)
88
+ end
89
+
90
+ # Signup. At first render there is no persisted actor, so the presentation
91
+ # bound itself to a short-lived registration flow instead of to a fictional
92
+ # authenticated user. Here the account is created and its stable reference
93
+ # bound to the evidence, both inside one transaction, and the receipt records
94
+ # `account_registration` attribution rather than claiming a session that did
95
+ # not exist.
96
+ def register!(&block)
97
+ raise ArgumentError, "register! needs a block that persists the account" unless block
98
+
99
+ @attribution_method = "account_registration"
100
+
101
+ perform(protected_action: true) do |pending|
102
+ result = block.call(pending)
103
+
104
+ unless @prospective_actor&.persisted?
105
+ raise RegistrationFailed,
106
+ "The registration block did not persist the prospective actor. Use `save!`, or " \
107
+ "raise when validation fails, so Clickwrap can roll the evidence back with it."
108
+ end
109
+
110
+ rebind_actor_after_registration!(pending)
111
+ result
112
+ end
113
+ end
114
+
115
+ # Creates a new record of the type named by `represented_party:` and binds
116
+ # the block's persisted result to the evidence before either can commit.
117
+ # Presentation records honestly
118
+ # that membership authority was not yet verifiable; after the block saves
119
+ # the record and creates its authority relationship, the adapter rereads
120
+ # that relationship inside this same transaction and the finalized event
121
+ # is rebound to the persisted represented party.
122
+ def create_represented_party!(&block)
123
+ unless block
124
+ raise ArgumentError,
125
+ "create_represented_party! needs a block that persists the represented party"
126
+ end
127
+ unless @acting_for.respond_to?(:new_record?) && @acting_for.new_record?
128
+ raise RepresentedPartyCreationFailed,
129
+ "create_represented_party! needs the exact new `represented_party:` record " \
130
+ "that was used to render the presentation."
131
+ end
132
+
133
+ @prospective_represented_party_class = @acting_for.class
134
+ @creating_represented_party = true
135
+
136
+ perform(protected_action: true) do |pending|
137
+ result = block.call(pending)
138
+
139
+ unless result.respond_to?(:persisted?) && result.persisted?
140
+ raise RepresentedPartyCreationFailed,
141
+ "The represented-party creation block must return the persisted represented party. " \
142
+ "Save it and its authority relationship inside the block, then return that record."
143
+ end
144
+ unless result.instance_of?(@prospective_represented_party_class)
145
+ raise RepresentedPartyCreationFailed,
146
+ "The represented-party creation block returned #{result.class.name}, but the " \
147
+ "presentation was bound to #{@verified_manifest.represented_party_type}. Return a " \
148
+ "persisted record of the presented type."
149
+ end
150
+
151
+ @acting_for = result
152
+
153
+ result
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ # Everything that can be checked without a transaction is checked without
160
+ # one, and everything that must be resolved before the transaction opens is
161
+ # resolved before it opens. A transaction that stays open across a network
162
+ # call to a geolocation provider is a transaction holding locks on evidence
163
+ # rows while waiting for someone else's DNS.
164
+ def perform(protected_action:, &block)
165
+ @protected_action = protected_action
166
+ validate_actor_class!
167
+ @joined_existing_transaction = ::ActiveRecord::Base.connection.transaction_open?
168
+ replay_candidate = find_replay_candidate
169
+ policy.validate_tenant!(tenant) unless replay_candidate
170
+ verified = PresentationVerifier.new(
171
+ policy: policy,
172
+ submission: submission,
173
+ explicit_answers: @explicit_answers,
174
+ actor_reference: actor_reference,
175
+ tenant_key: tenant_key,
176
+ subject_key: subject_key,
177
+ # The protected action may be the very thing that changes the bound
178
+ # subject. An already-committed nonce is verified against its frozen
179
+ # event below; recomputing the pre-action fingerprint first would make
180
+ # a lost-response retry fail precisely because the first attempt worked.
181
+ subject_fingerprint: (subject_fingerprint unless replay_candidate),
182
+ represented_party: @acting_for,
183
+ prospective_actor: @prospective_actor,
184
+ registration_flow_id: @registration_flow_id,
185
+ explicit_capture_channel: @explicit_capture_channel,
186
+ represented_party_creation_flow_id: @represented_party_creation_flow_id,
187
+ creating_represented_party: @creating_represented_party
188
+ ).verify!(for_replay: replay_candidate.present?)
189
+ manifest = verified.manifest
190
+ revision = verified.revision
191
+ answers = verified.answers
192
+ @capture_channel = verified.capture_channel
193
+ @frozen_statement_snapshots = verified.statement_snapshots
194
+ @verified_document_versions_by_id = verified.document_versions_by_id
195
+ @verified_manifest = manifest
196
+
197
+ existing = replay_candidate || find_existing_event(idempotency_key_for(manifest))
198
+ return replay(existing, answers, manifest) if existing
199
+
200
+ request_evidence = resolve_request_evidence
201
+
202
+ event = nil
203
+ pending = nil
204
+
205
+ begin
206
+ run_in_transaction do
207
+ verify_represented_party_authority! unless @creating_represented_party
208
+ lock_statement_identities!(manifest)
209
+
210
+ event = append_event!(manifest, revision, answers, request_evidence)
211
+ pending = event.track_pending_receipt(
212
+ PendingReceipt.new(event, wait_for_outer_transaction: @joined_existing_transaction)
213
+ )
214
+
215
+ @protected_action_result = block.call(pending)
216
+
217
+ complete_represented_party_creation!(pending) if @creating_represented_party
218
+
219
+ record_protected_outcome!(event)
220
+ event.finalize_integrity!
221
+ update_projections!(event)
222
+ consume_one_time_authorizations!(event)
223
+ mark_presentation_accepted!(manifest)
224
+ end
225
+ rescue IdempotentReplay => error
226
+ return replay(error.event, answers, manifest)
227
+ end
228
+
229
+ # The after-commit hook is NOT invoked here. When this capture joined a
230
+ # caller's transaction, "here" is still inside it, and a notification sent
231
+ # from inside a transaction that later rolls back announces something that
232
+ # never happened. The hook is registered as an `after_commit` callback on
233
+ # the event instead, so Rails fires it on the real outermost commit.
234
+ pending.committed? ? pending.receipt : pending
235
+ end
236
+
237
+ # Joins the caller's transaction when there is one. `requires_new: false` is
238
+ # the whole guarantee: a host that already opened a transaction around its
239
+ # domain work gets its evidence committed by the same COMMIT, not by a
240
+ # nested one that could succeed while the outer one rolls back.
241
+ def run_in_transaction(&)
242
+ ::ActiveRecord::Base.transaction(requires_new: false, &)
243
+ rescue ::ActiveRecord::Deadlocked, ::ActiveRecord::SerializationFailure => error
244
+ raise RetryableTransactionError,
245
+ "The capture hit a #{error.class.name.demodulize.underscore.humanize.downcase}. Clickwrap " \
246
+ "does not retry automatically here because it cannot prove your protected action is " \
247
+ "safe to run twice. Retry the whole operation if it is."
248
+ end
249
+
250
+ # --- Idempotency ----------------------------------------------------------
251
+
252
+ # The presentation nonce is the idempotency key. It is server-generated,
253
+ # issued once per render, and travels inside the signed token — so a
254
+ # double-click, a retried request, and a replayed token all land on the same
255
+ # key, and the unique index decides who wins.
256
+ def idempotency_key_for(manifest)
257
+ @explicit_idempotency_key || manifest.nonce
258
+ end
259
+
260
+ def find_existing_event(key)
261
+ Event.find_by(policy_key: policy.key, idempotency_key: key)
262
+ end
263
+
264
+ # A signed manifest is safe to inspect before the full verification pass;
265
+ # `Submission#manifest` has already verified its server signature. This
266
+ # lookup authorizes no action. It only selects the stricter historical
267
+ # replay path, whose context and exact answers are checked against the
268
+ # committed event before a receipt is returned.
269
+ def find_replay_candidate
270
+ manifest = submission&.manifest
271
+ key = @explicit_idempotency_key || manifest&.nonce
272
+ key.present? ? find_existing_event(key) : nil
273
+ end
274
+
275
+ # A repeated identical submit returns the original receipt without running
276
+ # the protected action again. A repeat with different answers is a replay
277
+ # attempt, not a retry, and gets a stable failure rather than a second
278
+ # event.
279
+ def replay(event, answers, manifest)
280
+ verify_replay_context!(event, manifest)
281
+
282
+ recorded = event.statements.to_h { |statement| [statement.statement_key, statement.answer] }
283
+ submitted = answers.transform_values { |value| value&.to_s }
284
+ comparable = recorded.transform_values { |value| value&.to_s }
285
+
286
+ unless comparable == submitted.slice(*comparable.keys)
287
+ raise ReplayRejected,
288
+ "This presentation was already used to record event #{event.id}, and the answers " \
289
+ "submitted now differ from the ones recorded then. Render a new presentation."
290
+ end
291
+
292
+ Receipt.new(event)
293
+ end
294
+
295
+ def verify_replay_context!(event, manifest)
296
+ expected_actor = manifest.registration_flow_id.present? ? event.actor_reference : actor_reference
297
+ matches = event.policy_revision&.revision_digest == manifest.revision_digest &&
298
+ event.presentation_manifest_digest == manifest.digest &&
299
+ event.actor_reference == expected_actor &&
300
+ event.tenant_key.to_s == tenant_key.to_s &&
301
+ event.subject_key.to_s == subject_key.to_s &&
302
+ event.subject_fingerprint.to_s == manifest.subject_fingerprint.to_s &&
303
+ event.capture_channel == capture_channel &&
304
+ replay_represented_party_matches?(event, manifest)
305
+
306
+ return if matches && event.digest_verified?
307
+
308
+ raise ReplayRejected,
309
+ "This idempotency key was already used with a different actor, tenant, subject, " \
310
+ "policy revision, presentation, or capture channel. Render a new presentation."
311
+ end
312
+
313
+ def replay_represented_party_matches?(event, manifest)
314
+ if manifest.represented_party_will_be_created_by_protected_action?
315
+ return event.represented_party_reference.present? &&
316
+ event.authority_source.present? &&
317
+ event.authority_role.present? &&
318
+ event.authority_verified_at.present?
319
+ end
320
+
321
+ event.represented_party_reference.to_s == Reference.represented_party(@acting_for).to_s
322
+ end
323
+
324
+ # --- Locking --------------------------------------------------------------
325
+
326
+ # A one-time authorization is consumed inside the transaction that uses it,
327
+ # so the row is locked before anything else happens. Without this, two
328
+ # concurrent submits could both read an unconsumed authorization and both
329
+ # proceed — which for a withdrawal means two debits.
330
+ def lock_statement_identities!(manifest)
331
+ StatementIdentityLock.acquire_for_actor!(actor_reference)
332
+
333
+ identities = @frozen_statement_snapshots.values.map do |statement|
334
+ StatementState.identity_for(
335
+ policy_key: policy.key,
336
+ statement_key: statement["key"],
337
+ actor_reference: actor_reference,
338
+ tenant_key: tenant_key,
339
+ subject_key: subject_key,
340
+ represented_party_reference: represented_party_identity_reference
341
+ )
342
+ end
343
+
344
+ identities.sort_by { |identity| identity.fetch(:identity_digest) }.each do |identity|
345
+ StatementIdentityLock.acquire!(identity.fetch(:identity_digest))
346
+ statement = @frozen_statement_snapshots.fetch(identity.fetch(:statement_key).to_s)
347
+ verify_one_time_statement_is_available!(StatementState.find_by(identity), manifest) if statement["one_time"]
348
+ end
349
+ end
350
+
351
+ def verify_one_time_statement_is_available!(state, manifest)
352
+ return if state.nil? || !state.one_time?
353
+
354
+ current_event = state.current_event
355
+ presentation_is_newer = current_event && manifest.issued_at > current_event.recorded_at_by_server
356
+
357
+ # A terminal authorization may be deliberately recreated only from a
358
+ # presentation rendered after that terminal state existed. The outbox
359
+ # path has one additional, equally deliberate case: a newer presentation
360
+ # may supersede an authorization whose provider outcome is still pending.
361
+ # That is how a host can abandon attempt A and create attempt B without a
362
+ # late success for A ever consuming B. Distinct presentations rendered
363
+ # before either write still serialize on the identity lock and the loser
364
+ # is rejected, so this exception does not reopen the first-capture race.
365
+ return if presentation_is_newer &&
366
+ (!state.satisfies? || !@consume_one_time_authorizations)
367
+
368
+ raise OneTimeAuthorizationConflict,
369
+ "This presentation can no longer create a one-time authorization because " \
370
+ "#{state.statement_key} is already #{state.state} for the same actor, tenant, subject, " \
371
+ "and represented party. Recheck the protected action and render a new presentation " \
372
+ "after the current authorization state is known."
373
+ end
374
+
375
+ # --- Writing --------------------------------------------------------------
376
+
377
+ def append_event!(manifest, revision, answers, request_evidence)
378
+ built = EventBuilder.new(
379
+ policy: policy,
380
+ manifest: manifest,
381
+ revision: revision,
382
+ statement_snapshots: @frozen_statement_snapshots,
383
+ answers: answers,
384
+ document_versions_by_id: @verified_document_versions_by_id,
385
+ request_evidence: request_evidence,
386
+ event_type: @event_type,
387
+ root_event_id: @root_event_id,
388
+ predecessor_event_id: @predecessor_event_id,
389
+ actor: actor,
390
+ actor_reference: actor_reference,
391
+ actor_snapshot: actor_snapshot,
392
+ represented_party: (@acting_for unless @creating_represented_party),
393
+ authority_decision: @authority_decision,
394
+ tenant_key: tenant_key,
395
+ subject: subject,
396
+ subject_key: subject_key,
397
+ subject_fingerprint: subject_fingerprint,
398
+ capture_channel: capture_channel,
399
+ authentication_context: authentication_context,
400
+ attribution_method: attribution_method,
401
+ idempotency_key: idempotency_key_for(manifest),
402
+ http_request_id: http_request_id,
403
+ http_route_name: http_route_name,
404
+ reason: @reason,
405
+ statement_action_overrides: @statement_action_overrides
406
+ ).build
407
+
408
+ save_event_with_idempotency(built.event)
409
+ attach_request_evidence(built.event, built.request_evidence_annex)
410
+ built.event
411
+ end
412
+
413
+ # The unique index on (policy_key, idempotency_key) is the guarantee, not
414
+ # this rescue — but the rescue has to run inside its own savepoint, because
415
+ # PostgreSQL aborts the entire transaction on a unique violation and the
416
+ # caller's domain work is in that transaction too.
417
+ def save_event_with_idempotency(event)
418
+ ::ActiveRecord::Base.transaction(requires_new: true) { event.save! }
419
+ rescue ::ActiveRecord::RecordNotUnique
420
+ existing = find_existing_event(event.idempotency_key)
421
+ raise EventWriteFailed, "The evidence event could not be written." unless existing
422
+
423
+ raise IdempotentReplay, existing
424
+ end
425
+
426
+ def attach_request_evidence(event, annex)
427
+ return if annex.nil?
428
+
429
+ annex.save!
430
+ event.attach_request_evidence!(annex)
431
+ end
432
+
433
+ def resolve_request_evidence
434
+ return nil if http_request.nil? && !policy.request_evidence.records_anything?
435
+
436
+ RequestEvidenceExtractor.new(
437
+ policy: policy,
438
+ http_request: http_request,
439
+ capture_channel: capture_channel
440
+ ).extract
441
+ end
442
+
443
+ def verify_represented_party_authority!
444
+ return if @acting_for.nil?
445
+
446
+ decision = AuthorityVerifier.verify!(
447
+ policy: policy,
448
+ actor: actor,
449
+ represented_party: @acting_for,
450
+ tenant: tenant,
451
+ authentication_context: authentication_context
452
+ )
453
+ details = decision.details.merge(
454
+ "authority_at_presentation" => @verified_manifest.authority_at_presentation
455
+ )
456
+ details["represented_party_was_created_by_protected_action"] = true if
457
+ @creating_represented_party
458
+ @authority_decision = AuthorityDecision.new(
459
+ authorized: true,
460
+ source: decision.source,
461
+ role: decision.role,
462
+ verified_at: decision.verified_at,
463
+ details: details
464
+ )
465
+ end
466
+
467
+ def complete_represented_party_creation!(pending)
468
+ verify_represented_party_authority!
469
+
470
+ represented_party_type = if @acting_for.class.respond_to?(:polymorphic_name)
471
+ @acting_for.class.polymorphic_name
472
+ else
473
+ @acting_for.class.name
474
+ end
475
+ pending.event.update_columns(
476
+ represented_party_type: represented_party_type,
477
+ represented_party_id: @acting_for.id,
478
+ represented_party_reference: Reference.represented_party(@acting_for),
479
+ authority_source: @authority_decision.source,
480
+ authority_role: @authority_decision.role,
481
+ authority_verified_at: @authority_decision.verified_at,
482
+ authority_details: @authority_decision.details
483
+ )
484
+ end
485
+
486
+ # --- After the write ------------------------------------------------------
487
+
488
+ # An exact post-action reference is host-configured, never inferred. A block
489
+ # that returned without raising tells us the block returned without raising;
490
+ # it does not tell us what the host method meant, and guessing would put a
491
+ # claim in a receipt that nobody made.
492
+ def record_protected_outcome!(event)
493
+ return unless @protected_action && @record_protected_outcome
494
+
495
+ statement = policy.protected_outcome_statement
496
+ return unless statement
497
+
498
+ frozen = @frozen_statement_snapshots.fetch(statement.key)
499
+ unless frozen["protected_outcome_version"] == statement.protected_outcome_version
500
+ raise PresentationInvalid,
501
+ "The protected-outcome recorder changed after this presentation was issued. " \
502
+ "Re-render it against the current policy revision."
503
+ end
504
+
505
+ outcome = statement.record_protected_outcome_with.call(@protected_action_result)
506
+ event.update_columns(protected_outcome: ProtectedOutcome.validate!(outcome))
507
+ end
508
+
509
+ def update_projections!(event)
510
+ CurrentState.apply!(event)
511
+ end
512
+
513
+ def consume_one_time_authorizations!(event)
514
+ return unless @protected_action && @consume_one_time_authorizations
515
+ return unless event.statements.any?(&:one_time?)
516
+
517
+ Lifecycle.consume_authorization!(event: event, because: "Consumed by the protected action")
518
+ end
519
+
520
+ # `config.actor_class_name` decides which record the installer wires up and
521
+ # which class every "who is acting" error message names. From here it also
522
+ # decides which records may actually appear in evidence AS the actor —
523
+ # without this check the setting was a label, and a wrong one attributed
524
+ # evidence to the wrong kind of record silently, for as long as the
525
+ # evidence is kept. That is the exact consequence the installer warns
526
+ # about, so it had better be true.
527
+ #
528
+ # Deliberately narrow. A system actor and an anonymous actor are their own
529
+ # kinds and say so in the receipt; a literal String or Symbol reference is
530
+ # an actor from another system this application owns no class for; and nil
531
+ # is "nobody yet", which the registration path binds later.
532
+ def validate_actor_class!
533
+ candidates = [actor, @prospective_actor].compact.reject do |candidate|
534
+ candidate.is_a?(SystemActor) || candidate.is_a?(AnonymousActor) ||
535
+ candidate.is_a?(String) || candidate.is_a?(Symbol)
536
+ end
537
+ return if candidates.empty?
538
+
539
+ # Resolved only once there is something to check, so an application whose
540
+ # every actor is anonymous or system-owned never has to name a class it
541
+ # does not have.
542
+ expected = expected_actor_class
543
+
544
+ candidates.each do |candidate|
545
+ next if candidate.is_a?(expected)
546
+
547
+ raise ConfigurationError,
548
+ "Clickwrap was asked to record #{candidate.class.name} as the actor, but " \
549
+ "`config.actor_class_name` says the records that can act are " \
550
+ "#{Clickwrap.config.actor_class_name}. One of the two is wrong, and evidence " \
551
+ "attributed to the wrong kind of record stays wrong for as long as it is kept. " \
552
+ "Either pass a #{Clickwrap.config.actor_class_name}, or set " \
553
+ "`config.actor_class_name` in config/initializers/clickwrap.rb to the class that " \
554
+ "really acts. (An organization or other party accepted FOR belongs in " \
555
+ "`acting_for:`, not `actor:`.)"
556
+ end
557
+ end
558
+
559
+ def expected_actor_class
560
+ Clickwrap.config.actor_class
561
+ rescue NameError => error
562
+ raise ConfigurationError,
563
+ "`config.actor_class_name` is set to " \
564
+ "#{Clickwrap.config.actor_class_name.inspect}, which does not name a loadable " \
565
+ "class (#{error.message}). Clickwrap checks every recorded actor against it, so it " \
566
+ "has to resolve."
567
+ end
568
+
569
+ # Only a policy that retains pre-submit presentations ever wrote a row for
570
+ # this nonce, so only that policy has one to mark — and on a default
571
+ # install the table does not exist at all, which is what makes the guard
572
+ # load-bearing rather than an optimization. The event builder has always
573
+ # asked the same question before looking; this path did not.
574
+ def mark_presentation_accepted!(manifest)
575
+ return unless policy.persist_presentations?
576
+
577
+ Presentation.find_by(nonce: manifest.nonce)&.mark_accepted!
578
+ end
579
+
580
+ def rebind_actor_after_registration!(pending)
581
+ @actor = @prospective_actor
582
+ @actor_reference = nil
583
+ return if actor.nil? || !actor.persisted?
584
+
585
+ pending.event.update_columns(
586
+ actor_type: actor.class.name,
587
+ actor_id: actor.id,
588
+ actor_reference: actor_reference,
589
+ actor_snapshot: actor_snapshot
590
+ )
591
+ end
592
+
593
+ # --- Derived values -------------------------------------------------------
594
+
595
+ def actor_reference
596
+ @actor_reference ||= actor ? Reference.actor(actor) : "registration/#{@registration_flow_id}"
597
+ end
598
+
599
+ def actor_snapshot
600
+ return {} if actor.nil?
601
+
602
+ Clickwrap.config.snapshot_actor_with.call(actor) || {}
603
+ end
604
+
605
+ def tenant_key = Reference.tenant(tenant)
606
+
607
+ def represented_party_identity_reference
608
+ return "represented_party_creation/#{@represented_party_creation_flow_id}" if @creating_represented_party
609
+
610
+ Reference.represented_party(@acting_for)
611
+ end
612
+
613
+ def subject_key = StatementState.subject_key_for(subject)
614
+
615
+ def subject_fingerprint
616
+ return @subject_fingerprint if defined?(@subject_fingerprint)
617
+
618
+ @subject_fingerprint = SubjectFingerprint.for(policy, subject)
619
+ end
620
+
621
+ def authentication_context
622
+ @authentication_context = (@authentication_context || {}).to_h.symbolize_keys
623
+ end
624
+
625
+ def attribution_method
626
+ @attribution_method ||
627
+ if actor.nil? && @prospective_actor
628
+ "account_registration"
629
+ elsif actor.is_a?(SystemActor) then "system_process"
630
+ elsif actor.is_a?(AnonymousActor) then "anonymous_identifier"
631
+ elsif authentication_context[:method].present? then "authenticated_session"
632
+ else "unknown"
633
+ end
634
+ end
635
+
636
+ def http_request_id
637
+ return nil unless http_request.respond_to?(:request_id)
638
+
639
+ http_request.request_id
640
+ end
641
+
642
+ def http_route_name
643
+ return nil unless http_request.respond_to?(:path_parameters)
644
+
645
+ controller = http_request.path_parameters[:controller]
646
+ action = http_request.path_parameters[:action]
647
+ [controller, action].compact.join("#").presence
648
+ end
649
+ end
650
+ end