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,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Everything that happens to evidence after it is captured.
5
+ #
6
+ # The rule that governs all of it: nothing here rewrites history. Withdrawing
7
+ # consent, correcting a declaration, consuming an authorization, expiring an
8
+ # acknowledgment, superseding an agreement — each appends a new event linked
9
+ # to the one it acts on, and updates the current-state projection. The original
10
+ # event stays exactly as it was written, because a receipt has to be able to
11
+ # show both what is true now and what was true then.
12
+ #
13
+ # The lifecycle each kind gets is the one it actually needs. Consent is
14
+ # withdrawable because someone must be able to change their mind as easily as
15
+ # they agreed. An agreement is not: withdrawing future consent to marketing
16
+ # does not retroactively unmake a contract, and a gem that let it would be
17
+ # recording something false.
18
+ module Lifecycle
19
+ class << self
20
+ # Withdraws a consent purpose. First-class, because consent that cannot be
21
+ # withdrawn as easily as it was given is not what this gem will record as
22
+ # consent — the policy compiler already refused to accept one without a
23
+ # withdrawal route.
24
+ def withdraw!(purpose_key, actor:, because:, tenant: nil, subject: nil,
25
+ acting_for: nil, http_request: nil)
26
+ require_reason!(because, "Withdrawing consent")
27
+
28
+ candidates = StatementState
29
+ .for_actor(reference_for(actor))
30
+ .for_purpose(purpose_key)
31
+ .where(kind: "consent",
32
+ subject_key: Reference.subject(subject),
33
+ represented_party_reference: Reference.represented_party(acting_for))
34
+ .to_a
35
+
36
+ # Tenant is matched under EACH state's own policy semantics, exactly as
37
+ # the grant was recorded: a consent captured under
38
+ # `tenant_is :not_applicable` lives at a nil tenant key, and the
39
+ # ambient organization in the withdrawing session must not hide it —
40
+ # that would make consent granted personally unwithdrawable the moment
41
+ # the person joins an organization.
42
+ for_purpose = candidates.select do |state|
43
+ state.tenant_key == expected_tenant_key_for(state, tenant)
44
+ end
45
+
46
+ states = for_purpose.select { |state| state.state == "active" }
47
+
48
+ if states.empty?
49
+ # Two different situations, told apart, because a person pressing
50
+ # "withdraw" twice is not the same as an application withdrawing
51
+ # something that was never granted — and a controller showing the
52
+ # first person an error would be both wrong and alarming.
53
+ if for_purpose.any? { |state| state.state == "withdrawn" }
54
+ raise AlreadyWithdrawnError,
55
+ "Consent for #{purpose_key.inspect} was already withdrawn. Nothing further " \
56
+ "was recorded; withdrawing twice is not an error worth showing a person."
57
+ end
58
+
59
+ raise NotWithdrawableError,
60
+ "There is no consent for #{purpose_key.inspect} to withdraw. It was never " \
61
+ "granted — and leaving an optional control unselected creates no grant, so " \
62
+ "there may be nothing here to find."
63
+ end
64
+
65
+ # One transaction for every matching state: a person withdrawing a
66
+ # purpose granted under several statements must never end up half
67
+ # withdrawn with an error implying nothing happened.
68
+ events = StatementState.transaction do
69
+ states.map do |state|
70
+ transition!(state, to: "withdrawn", event_type: "withdrawal", action: "withdrawn",
71
+ because: because, http_request: http_request, actor: actor)
72
+ end
73
+ end
74
+
75
+ events.length == 1 ? events.first : events
76
+ end
77
+
78
+ # The tenant key this state's grant was recorded under, given what the
79
+ # withdrawing caller can see. The state's own policy translates the
80
+ # caller's ambient tenant (`:not_applicable` policies always record nil);
81
+ # a state whose policy is no longer declared falls back to the raw value,
82
+ # which is the only reading its records can support.
83
+ def expected_tenant_key_for(state, tenant)
84
+ policy = begin
85
+ Clickwrap.policy!(state.policy_key)
86
+ rescue UnknownPolicyError
87
+ nil
88
+ end
89
+ return Reference.tenant(tenant) if policy.nil?
90
+
91
+ Reference.tenant(policy.tenant_from_controller(tenant))
92
+ end
93
+
94
+ # Records a corrected factual statement. A correction does not imply the
95
+ # original was false when it was made — people's circumstances change, and
96
+ # conflating "this changed" with "this was a lie" would be both wrong and
97
+ # unfair to the person who declared it.
98
+ def correct!(statement_key, actor:, subject: nil, tenant: nil, replaces: nil,
99
+ acting_for: nil, because: nil, http_request: nil, submission: nil, answers: nil)
100
+ require_reason!(because, "Correcting a declaration or attestation")
101
+ state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
102
+ acting_for: acting_for)
103
+
104
+ unless Vocabulary.correctable?(state.kind)
105
+ raise LifecycleError,
106
+ "#{statement_key} is a #{state.kind}, which is not corrected. An agreement is " \
107
+ "superseded by a new version; consent is withdrawn and granted again."
108
+ end
109
+
110
+ origin = lifecycle_origin!(state, replaces)
111
+ policy = Clickwrap.policy!(state.policy_key)
112
+
113
+ Capture.new(
114
+ policy: policy,
115
+ actor: actor,
116
+ subject: subject,
117
+ tenant: tenant,
118
+ acting_for: acting_for,
119
+ http_request: http_request,
120
+ submission: submission,
121
+ answers: answers,
122
+ reason: because,
123
+ event_type: "correction",
124
+ root_event_id: state.root_event_id,
125
+ predecessor_event_id: origin.id,
126
+ statement_action_overrides: { statement_key.to_s => "corrected" }
127
+ ).capture!
128
+ end
129
+
130
+ # A renewal always starts a new validity period rather than extending the
131
+ # old one, so a stale expiry can never quietly survive a renewal.
132
+ def renew!(statement_key, actor:, subject: nil, tenant: nil, because: nil,
133
+ acting_for: nil, http_request: nil, submission: nil, answers: nil)
134
+ require_reason!(because, "Renewing a statement")
135
+ state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
136
+ acting_for: acting_for)
137
+ policy = Clickwrap.policy!(state.policy_key)
138
+ statement = policy.statement!(statement_key)
139
+
140
+ unless statement.expirable? && statement.valid_for.present?
141
+ raise LifecycleError,
142
+ "#{statement_key} is a #{statement.kind} with no validity period, so it cannot be renewed."
143
+ end
144
+
145
+ action = statement.kind == "consent" ? "renewed" : statement.initial_action
146
+ Capture.new(
147
+ policy: policy,
148
+ actor: actor,
149
+ subject: subject,
150
+ tenant: tenant,
151
+ acting_for: acting_for,
152
+ http_request: http_request,
153
+ submission: submission,
154
+ answers: answers,
155
+ reason: because,
156
+ event_type: "renewal",
157
+ root_event_id: state.root_event_id,
158
+ predecessor_event_id: state.current_event_id,
159
+ statement_action_overrides: { statement_key.to_s => action }
160
+ ).capture!
161
+ end
162
+
163
+ def change_consent_scope!(statement_key, actor:, because:, subject: nil, tenant: nil,
164
+ acting_for: nil, http_request: nil, submission: nil, answers: nil)
165
+ require_reason!(because, "Changing consent scope")
166
+ state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
167
+ acting_for: acting_for)
168
+
169
+ unless state.kind == "consent"
170
+ raise LifecycleError, "Only consent has a changeable scope; #{statement_key} is #{state.kind}."
171
+ end
172
+
173
+ Capture.new(
174
+ policy: Clickwrap.policy!(state.policy_key), actor: actor, subject: subject, tenant: tenant,
175
+ http_request: http_request, submission: submission, answers: answers, reason: because,
176
+ acting_for: acting_for,
177
+ event_type: "scope_change", root_event_id: state.root_event_id,
178
+ predecessor_event_id: state.current_event_id,
179
+ statement_action_overrides: { statement_key.to_s => "scope_changed" }
180
+ ).capture!
181
+ end
182
+
183
+ def revoke!(statement_key, actor:, because:, subject: nil, tenant: nil,
184
+ acting_for: nil, http_request: nil)
185
+ require_reason!(because, "Revoking an authorization")
186
+
187
+ state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
188
+ acting_for: acting_for)
189
+
190
+ transition!(state, to: "revoked", event_type: "revocation", action: "revoked",
191
+ because: because, http_request: http_request, actor: actor)
192
+ end
193
+
194
+ def supersede!(statement_key, actor:, subject: nil, tenant: nil, acting_for: nil,
195
+ because: nil, http_request: nil)
196
+ state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
197
+ acting_for: acting_for)
198
+
199
+ transition!(state, to: "superseded", event_type: "supersession", action: "superseded",
200
+ because: because, http_request: http_request, actor: actor)
201
+ end
202
+
203
+ # Consumes a one-time authorization. Called inside the transaction that
204
+ # performs the protected action, after the row lock the capture took, so
205
+ # two concurrent attempts cannot both spend the same authorization.
206
+ def consume_authorization!(event:, because: nil)
207
+ source_event = Event.includes(:statements).find(event.id)
208
+ authorizations = source_event.statements.select(&:one_time?)
209
+
210
+ authorizations.map do |authorization|
211
+ ::ActiveRecord::Base.transaction do
212
+ identity = StatementState.identity_for(
213
+ policy_key: source_event.policy_key,
214
+ statement_key: authorization.statement_key,
215
+ actor_reference: source_event.actor_reference,
216
+ tenant_key: source_event.tenant_key,
217
+ subject_key: source_event.subject_key,
218
+ represented_party_reference: source_event.represented_party_reference
219
+ )
220
+ StatementIdentityLock.acquire_for_actor!(source_event.actor_reference)
221
+ StatementIdentityLock.acquire!(identity.fetch(:identity_digest))
222
+ state = StatementState.lock.find_by(
223
+ identity
224
+ )
225
+
226
+ if authorization_was_consumed?(source_event, authorization.statement_key)
227
+ raise AlreadyConsumedError,
228
+ "Authorization #{authorization.statement_key} from event #{source_event.id} " \
229
+ "was already consumed. A one-time authorization cannot be spent twice."
230
+ end
231
+
232
+ consumed_at = Clickwrap.now
233
+ consumption = append_lifecycle_event!(
234
+ event: source_event,
235
+ event_type: "consumption",
236
+ reason: because.presence || "The one-time authorization was consumed",
237
+ actor: nil
238
+ ) do |appended|
239
+ appended.statements.create!(
240
+ ordinal: 0,
241
+ statement_key: authorization.statement_key,
242
+ kind: authorization.kind,
243
+ action: "consumed",
244
+ assertion_text: "The authorization #{authorization.statement_key} was consumed.",
245
+ assertion_locale: "en",
246
+ required: false,
247
+ optional: false,
248
+ answered: false,
249
+ purpose_key: authorization.purpose_key,
250
+ valid_from: consumed_at,
251
+ created_at: consumed_at
252
+ )
253
+ end
254
+
255
+ # A later authorization for the same actor/subject may already be
256
+ # current while an earlier provider result is being reconciled.
257
+ # Record consumption of the earlier authorization without spending
258
+ # or deactivating the later one.
259
+ if state && state.root_event_id.to_s == source_event.id.to_s && state.state == "active"
260
+ CurrentState.transition!(state, to: "consumed", event: consumption, at: consumed_at)
261
+ end
262
+
263
+ consumption
264
+ end
265
+ end
266
+ end
267
+
268
+ # Expires everything past its validity. Reporting and tidiness only:
269
+ # verification evaluates expiry live against the clock, so evidence never
270
+ # becomes wrongly valid because a job did not run — which is also why one
271
+ # contended row (a person withdrawing mid-sweep) skips instead of
272
+ # aborting the whole batch and leaving every later state untouched.
273
+ def expire_due!(at: Clickwrap.now)
274
+ expired = []
275
+ StatementState.due_for_expiry(at).find_each do |state|
276
+ expired << transition!(state, to: "expired", event_type: "expiry", action: "expired",
277
+ because: "The validity period recorded at capture ended",
278
+ actor: nil, at: at)
279
+ rescue LifecycleError, ::ActiveRecord::ActiveRecordError
280
+ # This row moved under the sweep (withdrawn, consumed, or locked by a
281
+ # live transition). The next sweep — or live verification — owns it.
282
+ next
283
+ end
284
+ expired
285
+ end
286
+
287
+ # An explicitly recorded system exemption.
288
+ #
289
+ # Seeds, imports, invitations, admin actions, and service accounts must
290
+ # never "accept" by omitting a browser parameter or by fabricating a human
291
+ # click. An exemption says plainly that no human action occurred, records
292
+ # who created it and why, and never satisfies `agreed_to?` — it answers
293
+ # the separate `exempted_from?` question. There is no "missing checkbox
294
+ # means system account" inference anywhere in this gem.
295
+ def exempt!(policy_key, actor:, because:, subject: nil, tenant: nil)
296
+ require_reason!(because, "Recording an exemption")
297
+
298
+ policy = Clickwrap.policy!(policy_key.to_s)
299
+
300
+ unless policy.permits_exemptions?
301
+ raise LifecycleError,
302
+ "Policy #{policy.key} does not permit exemptions. If system-created records " \
303
+ "legitimately bypass it, say so in the policy with `permit_exemptions`, so the " \
304
+ "decision is visible in review rather than implied by a call site."
305
+ end
306
+
307
+ now = Clickwrap.now
308
+ revision = PolicyRevision.freeze_for(policy)
309
+
310
+ ::ActiveRecord::Base.transaction do
311
+ # Actor lock BEFORE the event insert: creating the event reserves the
312
+ # chain head, and every writer must take these two locks in the same
313
+ # order (actor first, chain head second — the order capture uses) or
314
+ # two concurrent paths deadlock against each other.
315
+ StatementIdentityLock.acquire_for_actor!(reference_for(actor))
316
+
317
+ event = Event.create!(
318
+ event_type: "exemption",
319
+ policy_key: policy.key,
320
+ policy_revision: revision,
321
+ actor: actor.is_a?(::ActiveRecord::Base) ? actor : nil,
322
+ actor_reference: reference_for(actor),
323
+ # Reference.tenant, never to_s: an Active Record tenant's to_s is a
324
+ # per-process memory address, which would make the exemption
325
+ # permanently unfindable (and no two exemptions equal).
326
+ tenant_key: Reference.tenant(tenant),
327
+ subject: subject.is_a?(::ActiveRecord::Base) ? subject : nil,
328
+ subject_key: StatementState.subject_key_for(subject),
329
+ capture_channel: "system",
330
+ attribution_method: "system_process",
331
+ recorded_at_by_server: now,
332
+ reason: because,
333
+ retention_class_key: policy.retention_class_key,
334
+ canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
335
+ gem_version: Clickwrap::VERSION,
336
+ created_at: now
337
+ )
338
+
339
+ policy.statements.each_with_index do |statement, index|
340
+ event.statements.create!(
341
+ ordinal: index,
342
+ statement_key: statement.key,
343
+ kind: statement.kind,
344
+ action: statement.initial_action,
345
+ assertion_text: "Exempted: no human action was recorded for this statement.",
346
+ assertion_locale: "en",
347
+ required: statement.required?,
348
+ optional: statement.optional?,
349
+ answered: false,
350
+ purpose_key: statement.purpose_key,
351
+ valid_from: now,
352
+ created_at: now
353
+ )
354
+ end
355
+
356
+ event.finalize_integrity!
357
+ CurrentState.apply!(event.reload)
358
+ event
359
+ end
360
+ end
361
+
362
+ # Appends a linked lifecycle event without touching the projection. Used
363
+ # by holds and dispositions, which record that something happened to the
364
+ # evidence rather than changing what the evidence says.
365
+ def append_lifecycle_event!(event:, event_type:, reason:, actor: nil, extra: {}, &block)
366
+ now = Clickwrap.now
367
+ lifecycle_actor = actor || SystemActor.new("clickwrap_lifecycle")
368
+ human_operator = actor.present? && !actor.is_a?(SystemActor)
369
+
370
+ Event.transaction do
371
+ # Actor lock BEFORE the event insert (which reserves the chain head):
372
+ # every evidence writer takes these two locks actor-first, the order
373
+ # capture uses, so no two paths can deadlock against each other.
374
+ StatementIdentityLock.acquire_for_actor!(event.actor_reference)
375
+
376
+ appended = Event.create!(
377
+ {
378
+ event_type: event_type,
379
+ policy_key: event.policy_key,
380
+ policy_revision_id: event.policy_revision_id,
381
+ root_event_id: event.root_event_id || event.id,
382
+ predecessor_event_id: event.id,
383
+ actor: lifecycle_actor.is_a?(::ActiveRecord::Base) ? lifecycle_actor : nil,
384
+ actor_reference: reference_for(lifecycle_actor),
385
+ tenant_key: event.tenant_key,
386
+ subject_key: event.subject_key,
387
+ capture_channel: "system",
388
+ attribution_method: human_operator ? "operator_session" : "system_process",
389
+ recorded_at_by_server: now,
390
+ reason: reason,
391
+ retention_class_key: event.retention_class_key,
392
+ canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
393
+ gem_version: Clickwrap::VERSION,
394
+ created_at: now
395
+ }.merge(extra)
396
+ )
397
+
398
+ block&.call(appended)
399
+ appended.finalize_integrity!
400
+ appended
401
+ end
402
+ end
403
+
404
+ private
405
+
406
+ def transition!(state, to:, event_type:, action:, because:, actor:, http_request: nil,
407
+ predecessor: nil, at: nil)
408
+ at ||= Clickwrap.now
409
+ original_current_event_id = state.current_event_id
410
+
411
+ ::ActiveRecord::Base.transaction do
412
+ StatementIdentityLock.acquire_for_actor!(state.actor_reference)
413
+ StatementIdentityLock.acquire!(state.identity_digest)
414
+ state = StatementState.lock.find(state.id)
415
+
416
+ if state.current_event_id.to_s != original_current_event_id.to_s
417
+ if event_type == "withdrawal" && state.state == "withdrawn"
418
+ raise AlreadyWithdrawnError,
419
+ "Consent for #{state.purpose_key.inspect} was already withdrawn. Nothing " \
420
+ "further was recorded."
421
+ end
422
+
423
+ raise LifecycleError,
424
+ "#{state.statement_key} changed while this lifecycle action was waiting. " \
425
+ "Reload its current evidence and try the intended transition again."
426
+ end
427
+
428
+ origin = Event.find_by(id: predecessor_id(predecessor) || state.current_event_id)
429
+ event = append_lifecycle_event!(
430
+ event: origin,
431
+ event_type: event_type,
432
+ reason: because,
433
+ actor: actor,
434
+ extra: {
435
+ http_request_id: http_request.respond_to?(:request_id) ? http_request.request_id : nil
436
+ }.compact
437
+ ) do |appended|
438
+ appended.statements.create!(
439
+ ordinal: 0,
440
+ statement_key: state.statement_key,
441
+ kind: state.kind,
442
+ action: action,
443
+ assertion_text: lifecycle_assertion(action, state),
444
+ assertion_locale: "en",
445
+ required: false,
446
+ optional: false,
447
+ answered: false,
448
+ purpose_key: state.purpose_key,
449
+ valid_from: at,
450
+ created_at: at
451
+ )
452
+ end
453
+
454
+ CurrentState.transition!(state, to: to, event: event, at: at)
455
+ event
456
+ end
457
+ end
458
+
459
+ def lifecycle_assertion(action, state)
460
+ case action
461
+ when "withdrawn" then "Consent for #{state.purpose_key} was withdrawn."
462
+ when "corrected" then "The declaration #{state.statement_key} was corrected."
463
+ when "revoked" then "The authorization #{state.statement_key} was revoked."
464
+ when "consumed" then "The authorization #{state.statement_key} was consumed."
465
+ when "expired" then "The validity period for #{state.statement_key} ended."
466
+ when "superseded" then "#{state.statement_key} was superseded."
467
+ else "#{state.statement_key} became #{action}."
468
+ end
469
+ end
470
+
471
+ def authorization_was_consumed?(event, statement_key)
472
+ Event.where(root_event_id: event.id, event_type: "consumption")
473
+ .joins(:statements)
474
+ .where(clickwrap_event_statements: { statement_key: statement_key, action: "consumed" })
475
+ .exists?
476
+ end
477
+
478
+ def predecessor_id(replaces)
479
+ return nil if replaces.nil?
480
+ return replaces if replaces.is_a?(String)
481
+ return replaces.event_id if replaces.respond_to?(:event_id)
482
+
483
+ replaces.id
484
+ end
485
+
486
+ def lifecycle_origin!(state, replaces)
487
+ id = predecessor_id(replaces) || state.current_event_id
488
+ origin = Event.find_by(id: id)
489
+
490
+ unless origin && origin.policy_key == state.policy_key &&
491
+ origin.actor_reference == state.actor_reference &&
492
+ origin.tenant_key.to_s == state.tenant_key.to_s &&
493
+ origin.subject_key.to_s == state.subject_key.to_s &&
494
+ origin.represented_party_reference.to_s == state.represented_party_reference.to_s &&
495
+ origin.statement(state.statement_key)
496
+ raise LifecycleError,
497
+ "The event named by `replaces:` is not the current statement for this actor, tenant, and subject."
498
+ end
499
+
500
+ origin
501
+ end
502
+
503
+ def find_state!(statement_key, actor:, subject:, tenant:, acting_for:)
504
+ state = StatementState
505
+ .for_actor(reference_for(actor))
506
+ .for_statement(statement_key)
507
+ .where(subject_key: Reference.subject(subject),
508
+ tenant_key: Reference.tenant(tenant),
509
+ represented_party_reference: Reference.represented_party(acting_for))
510
+ .first
511
+
512
+ return state if state
513
+
514
+ raise UnknownStatementError,
515
+ "There is no recorded #{statement_key.inspect} for this actor and subject to act on."
516
+ end
517
+
518
+ def reference_for(actor)
519
+ return nil if actor.nil?
520
+ return actor if actor.is_a?(String)
521
+
522
+ Reference.actor(actor)
523
+ end
524
+
525
+ def require_reason!(because, what)
526
+ return unless because.to_s.strip.empty?
527
+
528
+ raise LifecycleError,
529
+ "#{what} needs a `because:` in plain English. It is stored on the event, and it is " \
530
+ "the only thing that will explain this to someone reading the record later."
531
+ end
532
+ end
533
+ end
534
+ end