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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# One digest-bound evidence event with fixed, named post-write transitions.
|
|
5
|
+
#
|
|
6
|
+
# A capture, a withdrawal, a correction, an expiry, a consumption, a
|
|
7
|
+
# disposition, a hold — each is a row here, linked to what it acts on. The
|
|
8
|
+
# ordinary `update` and `destroy` calls are refused. Finalization, pointer
|
|
9
|
+
# nullification, request-evidence linking, legal-hold state, and reviewed core
|
|
10
|
+
# disposition use explicit fixed write sets; lifecycle and disposition facts
|
|
11
|
+
# append linked events explaining what happened and why. Optional PostgreSQL
|
|
12
|
+
# hardening enforces those same sets below the model layer.
|
|
13
|
+
#
|
|
14
|
+
# What an event proves is bounded and stated in the receipt: the server
|
|
15
|
+
# generated and accepted a particular presentation, an explicit action was
|
|
16
|
+
# submitted against it, and it committed together with whatever domain action
|
|
17
|
+
# it protected. It does not prove that a person read anything, understood
|
|
18
|
+
# anything, saw particular pixels, or that a party controlling both the
|
|
19
|
+
# application and the database could not have written the row.
|
|
20
|
+
class Event < ApplicationRecord
|
|
21
|
+
self.table_name = "clickwrap_events"
|
|
22
|
+
self.primary_key = "id"
|
|
23
|
+
|
|
24
|
+
# Ordinary mutation is not part of this model's contract. Rails' own
|
|
25
|
+
# `touch`, counter caches, and `update_column` would all bypass the
|
|
26
|
+
# callbacks below, so the columns those would reach simply do not exist:
|
|
27
|
+
# there is no `updated_at`, no counter cache, and no lock version.
|
|
28
|
+
self.record_timestamps = false
|
|
29
|
+
|
|
30
|
+
# MySQL does not permit a default on a native JSON column. Keep the
|
|
31
|
+
# portable NOT NULL guarantee by assigning the empty binding manifest in
|
|
32
|
+
# the model for lifecycle/import events that have no request annex.
|
|
33
|
+
attribute :request_evidence_category_binding_digests, default: -> { {} }
|
|
34
|
+
|
|
35
|
+
# Named write sets used by the opt-in PostgreSQL hardening migration. They
|
|
36
|
+
# document every post-INSERT path the gem itself needs, so the migration can
|
|
37
|
+
# refuse to install if its trigger policy ever drifts from runtime code.
|
|
38
|
+
FINALIZATION_COLUMNS = %w[
|
|
39
|
+
chain_scope chain_sequence previous_event_digest event_digest
|
|
40
|
+
digest_algorithm canonical_schema_version
|
|
41
|
+
].freeze
|
|
42
|
+
POINTER_NULLIFICATION_COLUMNS = %w[
|
|
43
|
+
actor_type actor_id represented_party_type represented_party_id
|
|
44
|
+
subject_type subject_id
|
|
45
|
+
].freeze
|
|
46
|
+
DISPOSITION_COLUMNS = %w[
|
|
47
|
+
actor_type actor_id actor_reference actor_snapshot
|
|
48
|
+
represented_party_type represented_party_id represented_party_reference
|
|
49
|
+
authority_source authority_role authority_verified_at authority_details
|
|
50
|
+
tenant_key subject_type subject_id subject_key subject_fingerprint
|
|
51
|
+
authentication_method authentication_context idempotency_key
|
|
52
|
+
http_request_id http_route_name presentation_id presentation_manifest
|
|
53
|
+
presentation_manifest_digest protected_outcome provider_receipt
|
|
54
|
+
provider_verification reason core_event_disposed_at
|
|
55
|
+
core_event_disposition_event_id
|
|
56
|
+
].freeze
|
|
57
|
+
MODEL_CALLBACK_MUTABLE_COLUMNS = %w[
|
|
58
|
+
core_event_disposed_at core_event_disposition_event_id on_legal_hold request_evidence_id
|
|
59
|
+
].freeze
|
|
60
|
+
MUTABLE_COLUMNS = MODEL_CALLBACK_MUTABLE_COLUMNS
|
|
61
|
+
DATABASE_HARDENING_WRITE_SETS = {
|
|
62
|
+
"finalization" => FINALIZATION_COLUMNS,
|
|
63
|
+
"pointer_nullification" => POINTER_NULLIFICATION_COLUMNS,
|
|
64
|
+
"disposition" => DISPOSITION_COLUMNS,
|
|
65
|
+
"legal_hold" => %w[on_legal_hold].freeze,
|
|
66
|
+
"request_evidence_link" => %w[request_evidence_id].freeze
|
|
67
|
+
}.transform_values { |columns| columns.map(&:to_s).sort.freeze }.freeze
|
|
68
|
+
|
|
69
|
+
belongs_to :policy_revision,
|
|
70
|
+
class_name: "Clickwrap::PolicyRevision",
|
|
71
|
+
optional: true,
|
|
72
|
+
inverse_of: :events
|
|
73
|
+
|
|
74
|
+
belongs_to :presentation,
|
|
75
|
+
class_name: "Clickwrap::Presentation",
|
|
76
|
+
optional: true,
|
|
77
|
+
inverse_of: :events
|
|
78
|
+
|
|
79
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
80
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
81
|
+
belongs_to :represented_party, polymorphic: true, optional: true
|
|
82
|
+
|
|
83
|
+
has_many :statements,
|
|
84
|
+
-> { order(:ordinal) },
|
|
85
|
+
class_name: "Clickwrap::EventStatement",
|
|
86
|
+
foreign_key: :event_id,
|
|
87
|
+
inverse_of: :event,
|
|
88
|
+
dependent: :restrict_with_error
|
|
89
|
+
|
|
90
|
+
has_many :documents,
|
|
91
|
+
-> { order(:ordinal) },
|
|
92
|
+
class_name: "Clickwrap::EventDocument",
|
|
93
|
+
foreign_key: :event_id,
|
|
94
|
+
inverse_of: :event,
|
|
95
|
+
dependent: :restrict_with_error
|
|
96
|
+
|
|
97
|
+
has_one :request_evidence,
|
|
98
|
+
class_name: "Clickwrap::RequestEvidence",
|
|
99
|
+
foreign_key: :event_id,
|
|
100
|
+
inverse_of: :event,
|
|
101
|
+
dependent: :restrict_with_error
|
|
102
|
+
|
|
103
|
+
has_many :legal_holds,
|
|
104
|
+
class_name: "Clickwrap::LegalHold",
|
|
105
|
+
foreign_key: :event_id,
|
|
106
|
+
inverse_of: :event,
|
|
107
|
+
dependent: :restrict_with_error
|
|
108
|
+
|
|
109
|
+
has_many :accesses,
|
|
110
|
+
class_name: "Clickwrap::ReceiptAccess",
|
|
111
|
+
foreign_key: :event_id,
|
|
112
|
+
inverse_of: :event,
|
|
113
|
+
dependent: :restrict_with_error
|
|
114
|
+
|
|
115
|
+
has_many :integrity_attestations,
|
|
116
|
+
-> { order(:attempted_at, :id) },
|
|
117
|
+
class_name: "Clickwrap::IntegrityAttestation",
|
|
118
|
+
foreign_key: :event_id,
|
|
119
|
+
inverse_of: :event,
|
|
120
|
+
dependent: :restrict_with_error
|
|
121
|
+
|
|
122
|
+
has_one :external_action,
|
|
123
|
+
class_name: "Clickwrap::ExternalAction",
|
|
124
|
+
foreign_key: :event_id,
|
|
125
|
+
inverse_of: :event,
|
|
126
|
+
dependent: :restrict_with_error
|
|
127
|
+
|
|
128
|
+
# --- Associations whose tables are optional -------------------------------
|
|
129
|
+
#
|
|
130
|
+
# `clickwrap:install` emits only the tables an installation can put a row
|
|
131
|
+
# in; the rest arrive with their own flag. Reading one of these associations
|
|
132
|
+
# on an installation that never created the table would read the schema of
|
|
133
|
+
# a table that deliberately does not exist — so each reader answers "there
|
|
134
|
+
# is nothing here", which is not a fallback but the exact truth: without the
|
|
135
|
+
# table, no row was ever written.
|
|
136
|
+
#
|
|
137
|
+
# Guarded here rather than at each call site, because the call sites are
|
|
138
|
+
# everywhere and the answer is a property of this record.
|
|
139
|
+
def presentation
|
|
140
|
+
super if SchemaRequirements.available?(:persisted_presentations)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def request_evidence
|
|
144
|
+
super if SchemaRequirements.available?(:request_evidence)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def integrity_attestations
|
|
148
|
+
return IntegrityAttestation.none unless SchemaRequirements.available?(:integrity)
|
|
149
|
+
|
|
150
|
+
super
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def legal_holds
|
|
154
|
+
return LegalHold.none unless SchemaRequirements.available?(:retention_ops)
|
|
155
|
+
|
|
156
|
+
super
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def external_action
|
|
160
|
+
super if SchemaRequirements.available?(:external_actions)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
belongs_to :root_event, class_name: "Clickwrap::Event", optional: true
|
|
164
|
+
belongs_to :predecessor_event, class_name: "Clickwrap::Event", optional: true
|
|
165
|
+
|
|
166
|
+
has_many :successor_events,
|
|
167
|
+
class_name: "Clickwrap::Event",
|
|
168
|
+
foreign_key: :predecessor_event_id,
|
|
169
|
+
inverse_of: :predecessor_event,
|
|
170
|
+
dependent: :restrict_with_error
|
|
171
|
+
|
|
172
|
+
validates :event_type, inclusion: { in: Vocabulary::EVENT_TYPES }
|
|
173
|
+
validates :capture_channel, inclusion: { in: Vocabulary::CAPTURE_CHANNELS }
|
|
174
|
+
validates :attribution_method, inclusion: { in: Vocabulary::ATTRIBUTION_METHODS }
|
|
175
|
+
validates :policy_key, :actor_reference, :recorded_at_by_server, presence: true
|
|
176
|
+
validates :canonical_schema_version, :gem_version, presence: true
|
|
177
|
+
validate :represented_party_has_complete_authority
|
|
178
|
+
|
|
179
|
+
before_validation :assign_identifier, on: :create
|
|
180
|
+
before_validation :assign_retention_schedule_from_class, on: :create
|
|
181
|
+
before_create :assign_recording_sequence!
|
|
182
|
+
# Reserve a chain position before INSERTing this event or any autosaved
|
|
183
|
+
# statement/document rows. InnoDB can otherwise deadlock two first writers:
|
|
184
|
+
# each transaction holds evidence-row locks and then both try to create the
|
|
185
|
+
# same previously-absent chain head. Taking the one chain-head lock first
|
|
186
|
+
# gives PostgreSQL and MySQL the same lock order for captures, lifecycle
|
|
187
|
+
# events, and imports.
|
|
188
|
+
before_create :assign_chain_position!
|
|
189
|
+
before_update :refuse_ordinary_update
|
|
190
|
+
before_commit :ensure_integrity_was_finalized, on: :create
|
|
191
|
+
|
|
192
|
+
# `prepend: true` matters. The `dependent: :restrict_with_error` callbacks
|
|
193
|
+
# on the associations above are themselves `before_destroy` hooks, and they
|
|
194
|
+
# were registered first, so without prepending they would abort the destroy
|
|
195
|
+
# by returning false — quietly. A caller that does not check the return
|
|
196
|
+
# value would then believe it had deleted evidence. Raising first makes the
|
|
197
|
+
# refusal impossible to miss.
|
|
198
|
+
before_destroy :refuse_destroy, prepend: true
|
|
199
|
+
|
|
200
|
+
# Optional integrations run only after the required work has committed, and
|
|
201
|
+
# only ever after it. Registering this as an `after_commit` rather than
|
|
202
|
+
# calling it at the end of the capture matters when Clickwrap joined a
|
|
203
|
+
# host's transaction: at that point the capture has returned but the outer
|
|
204
|
+
# transaction has not committed, and a notification sent from inside a
|
|
205
|
+
# transaction that later rolls back announces something that never happened.
|
|
206
|
+
#
|
|
207
|
+
# A failure here is reported and swallowed. By the time it runs, the
|
|
208
|
+
# evidence and the action it protected are durable, and an analytics outage
|
|
209
|
+
# must not be able to undo them.
|
|
210
|
+
after_commit :at_apparent_commit_boundary, on: :create
|
|
211
|
+
after_rollback :invalidate_pending_receipts_after_rollback!, on: :create
|
|
212
|
+
|
|
213
|
+
scope :captures, -> { where(event_type: "capture") }
|
|
214
|
+
scope :human_actions, -> { where(event_type: Vocabulary::HUMAN_ACTION_EVENT_TYPES) }
|
|
215
|
+
scope :for_actor, ->(reference) { where(actor_reference: reference) }
|
|
216
|
+
scope :for_policy, ->(key) { where(policy_key: key.to_s) }
|
|
217
|
+
scope :for_subject_key, ->(key) { where(subject_key: key.to_s) }
|
|
218
|
+
scope :on_hold, -> { where(on_legal_hold: true) }
|
|
219
|
+
scope :not_disposed, -> { where(core_event_disposed_at: nil) }
|
|
220
|
+
# Server timestamps can tie or move backwards, and ULIDs are identifiers,
|
|
221
|
+
# not an ordering protocol. Events written after the ordering migration
|
|
222
|
+
# receive one database-generated sequence value. Upgrade migrations leave
|
|
223
|
+
# older, already-digested events nil on purpose: inventing a sequence later
|
|
224
|
+
# would rewrite their canonical body. Those legacy rows sort first using the
|
|
225
|
+
# same timestamp/id fallback the gem used before the stronger order existed.
|
|
226
|
+
scope :chronological, lambda {
|
|
227
|
+
order(Arel.sql("CASE WHEN recording_sequence IS NULL THEN 0 ELSE 1 END"))
|
|
228
|
+
.order(:recording_sequence, :recorded_at_by_server, :id)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
scope :due_for_core_disposition, lambda { |at = Clickwrap.now|
|
|
232
|
+
not_disposed.where(on_legal_hold: false).where(retain_core_event_until: ..at)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Questions an `after_event_is_committed` hook actually asks. A host wiring
|
|
236
|
+
# up "stop processing when someone withdraws" should not have to know that
|
|
237
|
+
# the answer is a string comparison against an event-type vocabulary.
|
|
238
|
+
def consent_was_withdrawn? = event_type == "withdrawal"
|
|
239
|
+
def consent_was_granted? = capture? && statements.any? { |s| s.kind == "consent" && s.answered? }
|
|
240
|
+
def declaration_was_corrected? = event_type == "correction"
|
|
241
|
+
def authorization_was_consumed? = event_type == "consumption"
|
|
242
|
+
def evidence_was_disposed? = event_type == "disposition"
|
|
243
|
+
|
|
244
|
+
# The purposes this event affected, for a hook that needs to know which
|
|
245
|
+
# processing to stop.
|
|
246
|
+
def purpose_keys = statements.filter_map(&:purpose_key).uniq
|
|
247
|
+
|
|
248
|
+
def capture? = event_type == "capture"
|
|
249
|
+
def imported? = %w[imported_legacy external_receipt].include?(event_type)
|
|
250
|
+
def exemption? = event_type == "exemption"
|
|
251
|
+
def human_action? = Vocabulary.human_action_event_type?(event_type)
|
|
252
|
+
def disposed? = core_event_disposed_at.present?
|
|
253
|
+
def held? = on_legal_hold?
|
|
254
|
+
|
|
255
|
+
def statement(statement_key)
|
|
256
|
+
statements.find { |candidate| candidate.statement_key == statement_key.to_s }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def policy = Clickwrap.policies[policy_key]
|
|
260
|
+
|
|
261
|
+
def compiled_policy_snapshot = policy_revision&.compiled_snapshot
|
|
262
|
+
|
|
263
|
+
def receipt = Receipt.new(self)
|
|
264
|
+
|
|
265
|
+
# The canonical body this event's digest covers. It deliberately excludes
|
|
266
|
+
# the mutable columns: whether a legal hold is currently in effect, and
|
|
267
|
+
# whether the optional annex has since been disposed of, are facts about
|
|
268
|
+
# today, not about what was recorded. Including them would make an ordinary
|
|
269
|
+
# retention run look like tampering.
|
|
270
|
+
def canonical_body
|
|
271
|
+
{
|
|
272
|
+
"schema" => canonical_schema_version,
|
|
273
|
+
"event_id" => id,
|
|
274
|
+
"event_type" => event_type,
|
|
275
|
+
"policy" => { "key" => policy_key, "revision" => policy_revision&.revision_digest }.compact,
|
|
276
|
+
"actor" => canonical_actor,
|
|
277
|
+
"tenant" => tenant_key,
|
|
278
|
+
"subject" => canonical_subject,
|
|
279
|
+
"capture_channel" => capture_channel,
|
|
280
|
+
"authentication_method" => authentication_method,
|
|
281
|
+
"authentication_context" => authentication_context.presence,
|
|
282
|
+
"recorded_at_by_server" => Receipt.format_time(recorded_at_by_server),
|
|
283
|
+
"occurred_at" => Receipt.format_time(occurred_at),
|
|
284
|
+
"idempotency_key" => idempotency_key,
|
|
285
|
+
"http_request_id" => http_request_id,
|
|
286
|
+
"http_route_name" => http_route_name,
|
|
287
|
+
"acts" => statements.map(&:canonical_fragment),
|
|
288
|
+
"documents" => documents.map(&:canonical_fragment),
|
|
289
|
+
"presentation" => canonical_presentation,
|
|
290
|
+
"protected_outcome" => protected_outcome.presence,
|
|
291
|
+
"provider" => canonical_provider,
|
|
292
|
+
"request_evidence" => canonical_request_evidence_binding,
|
|
293
|
+
"predecessor_event_id" => predecessor_event_id,
|
|
294
|
+
"root_event_id" => root_event_id,
|
|
295
|
+
"reason" => reason,
|
|
296
|
+
"retention" => {
|
|
297
|
+
"class" => retention_class_key,
|
|
298
|
+
"retain_core_event_until" => Receipt.format_time(retain_core_event_until),
|
|
299
|
+
"rule" => retention_rule_name
|
|
300
|
+
}.compact.presence,
|
|
301
|
+
"chain" => {
|
|
302
|
+
"scope" => chain_scope,
|
|
303
|
+
"sequence" => chain_sequence,
|
|
304
|
+
"previous_event_digest" => previous_event_digest
|
|
305
|
+
}.compact.presence,
|
|
306
|
+
"gem_version" => gem_version,
|
|
307
|
+
"application_version" => application_version,
|
|
308
|
+
"template_version" => template_version,
|
|
309
|
+
"created_at" => Receipt.format_time(created_at)
|
|
310
|
+
}.compact
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def compute_digest
|
|
314
|
+
Digest.digest_canonical(canonical_body, algorithm: digest_algorithm || "sha256")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Recomputes the digest and compares it with the one recorded at write time.
|
|
318
|
+
# A false here means the row's meaningful bytes changed since it was
|
|
319
|
+
# written; it does not, on its own, say who changed them or when.
|
|
320
|
+
def digest_verified?
|
|
321
|
+
return false if event_digest.blank?
|
|
322
|
+
|
|
323
|
+
Digest.secure_compare?(compute_digest, event_digest)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# A disposed core payload cannot be recomputed: the point of disposition is
|
|
327
|
+
# that those bytes are gone. Keep that state separate from a verifying
|
|
328
|
+
# digest. A valid, digest-bound disposition event can account for the
|
|
329
|
+
# mismatch, while an unexplained marker or altered row remains a failure.
|
|
330
|
+
def digest_integrity_status
|
|
331
|
+
if disposed?
|
|
332
|
+
return :documented_core_disposition if documented_core_disposition?
|
|
333
|
+
|
|
334
|
+
return :unaccounted_mismatch
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
return :verified if digest_verified?
|
|
338
|
+
|
|
339
|
+
:unaccounted_mismatch
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def digest_integrity_accounted_for?
|
|
343
|
+
digest_integrity_status != :unaccounted_mismatch
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# The optional annex has its own keyed binding. A permitted category
|
|
347
|
+
# disposition makes the original HMAC no longer recomputable; that state is
|
|
348
|
+
# accepted only when every deletion timestamp has a valid linked
|
|
349
|
+
# disposition event naming the category.
|
|
350
|
+
def request_evidence_binding_status
|
|
351
|
+
annex = request_evidence
|
|
352
|
+
digests = request_evidence_category_binding_digests.to_h
|
|
353
|
+
has_binding = digests.present? || request_evidence_key_id.present? ||
|
|
354
|
+
request_evidence_digest_algorithm.present?
|
|
355
|
+
|
|
356
|
+
return :not_recorded if annex.nil? && !has_binding
|
|
357
|
+
return :missing_annex_or_binding if annex.nil? || !has_binding
|
|
358
|
+
return :missing_annex_or_binding unless digests.keys.sort == RequestEvidence::CATEGORIES.map(&:to_s).sort
|
|
359
|
+
|
|
360
|
+
return :binding_key_unavailable unless annex.binding_key_available?(request_evidence_key_id)
|
|
361
|
+
|
|
362
|
+
disposed = false
|
|
363
|
+
RequestEvidence::CATEGORIES.each do |category|
|
|
364
|
+
if annex.deleted_for?(category)
|
|
365
|
+
disposed = true
|
|
366
|
+
return :undocumented_disposition unless request_evidence_disposition_documented?(annex, category)
|
|
367
|
+
|
|
368
|
+
next
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
verified = annex.category_binding_digest_verified?(
|
|
372
|
+
category: category,
|
|
373
|
+
digest: digests[category.to_s],
|
|
374
|
+
algorithm: request_evidence_digest_algorithm,
|
|
375
|
+
key_id: request_evidence_key_id
|
|
376
|
+
)
|
|
377
|
+
return :digest_mismatch unless verified
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
disposed ? :disposed_with_documented_events : :verified
|
|
381
|
+
rescue ::ActiveRecord::Encryption::Errors::Base
|
|
382
|
+
# Losing or rotating away an encryption key is different from a digest
|
|
383
|
+
# mismatch. The annex cannot currently be read, so verification reports
|
|
384
|
+
# that limited fact instead of crashing or calling the bytes modified.
|
|
385
|
+
:annex_unreadable
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def evidence_integrity_verified?
|
|
389
|
+
digest_verified? && %i[
|
|
390
|
+
not_recorded verified disposed_with_documented_events
|
|
391
|
+
].include?(request_evidence_binding_status)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Finalization is deliberately explicit and happens only after every child
|
|
395
|
+
# statement/document, protected outcome, registration binding, retention
|
|
396
|
+
# decision, and chain position exists. Computing this in `before_create`
|
|
397
|
+
# produced digests for a half-built event and made normal successful
|
|
398
|
+
# captures fail their own integrity check.
|
|
399
|
+
def finalize_integrity!
|
|
400
|
+
raise EventWriteFailed, "Event #{id} has not been persisted, so it cannot be finalized." unless persisted?
|
|
401
|
+
if event_digest.present?
|
|
402
|
+
raise EventWriteFailed,
|
|
403
|
+
"Event #{id} was already finalized and cannot be finalized twice."
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Normally assigned by the before-create callback so chain contention is
|
|
407
|
+
# the first database lock this event takes. Keep this idempotent call as a
|
|
408
|
+
# defensive invariant for a host that deliberately bypassed callbacks
|
|
409
|
+
# while constructing an internal event.
|
|
410
|
+
assign_chain_position!
|
|
411
|
+
self.event_digest = compute_digest
|
|
412
|
+
|
|
413
|
+
update_columns(
|
|
414
|
+
chain_scope: chain_scope,
|
|
415
|
+
chain_sequence: chain_sequence,
|
|
416
|
+
previous_event_digest: previous_event_digest,
|
|
417
|
+
event_digest: event_digest,
|
|
418
|
+
digest_algorithm: digest_algorithm,
|
|
419
|
+
canonical_schema_version: canonical_schema_version
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
ChainHead.record!(chain_scope: chain_scope, event_id: id, event_digest: event_digest) if chain_scope.present?
|
|
423
|
+
self
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def track_pending_receipt(pending_receipt)
|
|
427
|
+
(@pending_receipts ||= []) << pending_receipt
|
|
428
|
+
pending_receipt
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Called only once durable commit is no longer capable of being rolled
|
|
432
|
+
# back. Public because DurableCommitCallback invokes it through Active
|
|
433
|
+
# Record's transaction-record protocol; it is not host application API.
|
|
434
|
+
def finalize_durable_commit!
|
|
435
|
+
commit_pending_receipts
|
|
436
|
+
run_after_commit_hook
|
|
437
|
+
run_integrity_attestors
|
|
438
|
+
self
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def invalidate_pending_receipts_after_rollback!
|
|
442
|
+
Array(@pending_receipts).each(&:mark_rolled_back!)
|
|
443
|
+
self
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# --- The two permitted mutations -----------------------------------------
|
|
447
|
+
|
|
448
|
+
# Marks the core event as disposed of under a retention rule. The row stays:
|
|
449
|
+
# what disappears is the payload, and the disposition is itself recorded as
|
|
450
|
+
# a linked event, so an auditor sees a documented deletion rather than a gap.
|
|
451
|
+
def dispose_core_payload!(disposition_event:, at: Clickwrap.now)
|
|
452
|
+
transaction do
|
|
453
|
+
# Mark and link the documented disposition first. The PostgreSQL
|
|
454
|
+
# hardening tier permits child DELETEs only while their parent carries
|
|
455
|
+
# this marker. If any later deletion fails, this transaction rolls the
|
|
456
|
+
# marker back as well, so callers can never observe a half-disposed row.
|
|
457
|
+
update_columns(
|
|
458
|
+
actor_type: nil,
|
|
459
|
+
actor_id: nil,
|
|
460
|
+
actor_reference: "",
|
|
461
|
+
actor_snapshot: {},
|
|
462
|
+
represented_party_type: nil,
|
|
463
|
+
represented_party_id: nil,
|
|
464
|
+
represented_party_reference: "",
|
|
465
|
+
authority_source: nil,
|
|
466
|
+
authority_role: nil,
|
|
467
|
+
authority_verified_at: nil,
|
|
468
|
+
authority_details: {},
|
|
469
|
+
tenant_key: "",
|
|
470
|
+
subject_type: nil,
|
|
471
|
+
subject_id: nil,
|
|
472
|
+
subject_key: "",
|
|
473
|
+
subject_fingerprint: nil,
|
|
474
|
+
authentication_method: nil,
|
|
475
|
+
authentication_context: {},
|
|
476
|
+
idempotency_key: nil,
|
|
477
|
+
http_request_id: nil,
|
|
478
|
+
http_route_name: nil,
|
|
479
|
+
presentation_id: nil,
|
|
480
|
+
presentation_manifest: nil,
|
|
481
|
+
presentation_manifest_digest: nil,
|
|
482
|
+
protected_outcome: nil,
|
|
483
|
+
provider_receipt: nil,
|
|
484
|
+
provider_verification: nil,
|
|
485
|
+
reason: nil,
|
|
486
|
+
core_event_disposed_at: at,
|
|
487
|
+
core_event_disposition_event_id: disposition_event.id
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Calling `delete_all` through a `has_many` association asks Active
|
|
491
|
+
# Record to null the foreign key. These evidence children have an
|
|
492
|
+
# intentionally non-null foreign key, so issue real, scoped DELETEs.
|
|
493
|
+
EventStatement.where(event_id: id).delete_all
|
|
494
|
+
EventDocument.where(event_id: id).delete_all
|
|
495
|
+
# A root and its later lifecycle events are independently retained
|
|
496
|
+
# evidence payloads. Disposing the root must not erase a projection that
|
|
497
|
+
# now points at a still-retained correction, renewal, withdrawal, or
|
|
498
|
+
# other successor. Remove only projections for which this exact event is
|
|
499
|
+
# current; a later successor removes its own projection when its own
|
|
500
|
+
# schedule becomes due.
|
|
501
|
+
StatementState.where(current_event_id: id).delete_all
|
|
502
|
+
end
|
|
503
|
+
self
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def documented_core_disposition?
|
|
507
|
+
return false unless disposed? && core_event_disposition_event_id.present?
|
|
508
|
+
|
|
509
|
+
disposition = Event.find_by(id: core_event_disposition_event_id)
|
|
510
|
+
facts = disposition&.protected_outcome.to_h["core_event_disposition"].to_h
|
|
511
|
+
disposition_event_links_to_self?(disposition) && disposition.digest_verified? &&
|
|
512
|
+
facts["event_id"] == id &&
|
|
513
|
+
facts["original_event_digest"] == event_digest &&
|
|
514
|
+
facts["disposed_at"] == Receipt.format_time(core_event_disposed_at)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def set_legal_hold!(held)
|
|
518
|
+
update_columns(on_legal_hold: held)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Only the foreign key. The binding digest was written when the event was
|
|
522
|
+
# created, because it is part of the canonical body the event digest covers;
|
|
523
|
+
# setting it afterwards would leave every event with request evidence
|
|
524
|
+
# failing its own verification. `request_evidence_id` is safe to set here
|
|
525
|
+
# precisely because it is NOT in the canonical body — it is a pointer, not
|
|
526
|
+
# a fact about what was recorded.
|
|
527
|
+
def attach_request_evidence!(record)
|
|
528
|
+
update_columns(request_evidence_id: record.id)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def to_s = "#{event_type} #{policy_key} #{id}"
|
|
532
|
+
|
|
533
|
+
private
|
|
534
|
+
|
|
535
|
+
def assign_identifier
|
|
536
|
+
self.id ||= Identifier.generate(recorded_at_by_server || Clickwrap.now)
|
|
537
|
+
self.canonical_schema_version ||= Clickwrap::CANONICAL_SCHEMA_VERSION
|
|
538
|
+
self.gem_version ||= Clickwrap::VERSION
|
|
539
|
+
self.digest_algorithm ||= Clickwrap.config.digest_canonical_receipts_with.to_s
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Freeze every event's core-payload schedule when the event is written.
|
|
543
|
+
# Captures, imports, exemptions, and lifecycle events all pass through this
|
|
544
|
+
# callback, so none silently falls back to a retention class that may have
|
|
545
|
+
# changed years later. Each lifecycle event starts from its own
|
|
546
|
+
# `recorded_at_by_server`; disposing a root therefore never implies that a
|
|
547
|
+
# later successor is due too.
|
|
548
|
+
#
|
|
549
|
+
# Disposition events deliberately carry no further disposal schedule. Their
|
|
550
|
+
# small, digest-bound payload is the tombstone that explains why an earlier
|
|
551
|
+
# event no longer has its original body. Disposing that explanation would
|
|
552
|
+
# make the earlier lawful deletion indistinguishable from damage and would
|
|
553
|
+
# create an endless chain of disposition-of-disposition events.
|
|
554
|
+
def assign_retention_schedule_from_class
|
|
555
|
+
return if retention_class_key.blank?
|
|
556
|
+
return if event_type == "disposition"
|
|
557
|
+
return if retain_core_event_until.present? || retention_rule_name.present?
|
|
558
|
+
|
|
559
|
+
rule = Clickwrap.retention_class!(retention_class_key).rule_for(:core_event)
|
|
560
|
+
return if rule.nil?
|
|
561
|
+
|
|
562
|
+
if rule.duration?
|
|
563
|
+
self.retain_core_event_until = recorded_at_by_server + rule.duration
|
|
564
|
+
else
|
|
565
|
+
self.retention_rule_name = rule.host_event_name.to_s
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Deliberately built from `actor_reference` alone, not from the polymorphic
|
|
570
|
+
# `actor_type`/`actor_id` columns.
|
|
571
|
+
#
|
|
572
|
+
# Those columns are a convenience pointer at a row that may not always
|
|
573
|
+
# exist: deleting an account nullifies them, by design, because evidence
|
|
574
|
+
# must outlive the account it describes. If the digest covered them, an
|
|
575
|
+
# ordinary account deletion would make every one of that person's events
|
|
576
|
+
# fail verification — a lawful, expected operation looking exactly like
|
|
577
|
+
# tampering. The reference is the stable pseudonymous identity, and a
|
|
578
|
+
# GlobalID already carries the class inside it.
|
|
579
|
+
def canonical_actor
|
|
580
|
+
{
|
|
581
|
+
"reference" => actor_reference,
|
|
582
|
+
"attribution" => {
|
|
583
|
+
"method" => attribution_method,
|
|
584
|
+
"authenticated" => attribution_method == "authenticated_session"
|
|
585
|
+
},
|
|
586
|
+
"snapshot" => actor_snapshot.presence,
|
|
587
|
+
"represented_party" => if represented_party_reference.present?
|
|
588
|
+
{
|
|
589
|
+
"type" => represented_party_type,
|
|
590
|
+
"reference" => represented_party_reference
|
|
591
|
+
}.compact
|
|
592
|
+
end,
|
|
593
|
+
"authority" => {
|
|
594
|
+
"source" => authority_source,
|
|
595
|
+
"role" => authority_role,
|
|
596
|
+
"verified_at" => Receipt.format_time(authority_verified_at),
|
|
597
|
+
"details" => authority_details.presence
|
|
598
|
+
}.compact.presence
|
|
599
|
+
}.compact
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def canonical_subject
|
|
603
|
+
return nil if subject_key.blank?
|
|
604
|
+
|
|
605
|
+
{ "reference" => subject_key, "fingerprint" => subject_fingerprint }.compact
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def canonical_presentation
|
|
609
|
+
return nil if presentation_manifest_digest.blank?
|
|
610
|
+
|
|
611
|
+
{
|
|
612
|
+
"manifest_digest" => presentation_manifest_digest,
|
|
613
|
+
"manifest" => presentation_manifest.presence
|
|
614
|
+
}.compact
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def canonical_provider
|
|
618
|
+
return nil if provider_name.blank?
|
|
619
|
+
|
|
620
|
+
{
|
|
621
|
+
"name" => provider_name,
|
|
622
|
+
"event_id" => provider_event_id,
|
|
623
|
+
"receipt" => provider_receipt.presence,
|
|
624
|
+
"verification" => provider_verification.presence
|
|
625
|
+
}.compact
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def canonical_request_evidence_binding
|
|
629
|
+
digests = request_evidence_category_binding_digests.to_h
|
|
630
|
+
return nil if digests.empty?
|
|
631
|
+
|
|
632
|
+
{
|
|
633
|
+
"category_digests" => digests,
|
|
634
|
+
"algorithm" => request_evidence_digest_algorithm,
|
|
635
|
+
"key_id" => request_evidence_key_id
|
|
636
|
+
}.compact
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def assign_chain_position!
|
|
640
|
+
return unless Clickwrap.config.chain_event_history_with
|
|
641
|
+
return if chain_scope.present? || chain_sequence.present? || previous_event_digest.present?
|
|
642
|
+
|
|
643
|
+
self.chain_scope = [tenant_key.presence || "global", policy_key].join("/")
|
|
644
|
+
self.previous_event_digest, self.chain_sequence = ChainHead.reserve!(chain_scope: chain_scope)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def assign_recording_sequence!
|
|
648
|
+
self.recording_sequence ||= RecordingSequence.create!.id
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# The recording sequence is DELIBERATELY absent from the canonical body and
|
|
652
|
+
# every receipt: it is the installation's private total order (a global
|
|
653
|
+
# counter), and publishing it would hand each receipt holder an enumerable
|
|
654
|
+
# census of how many events this installation records. Ordering questions
|
|
655
|
+
# are answered live against the database (`recorded_after?`), never off a
|
|
656
|
+
# receipt.
|
|
657
|
+
|
|
658
|
+
def ensure_integrity_was_finalized
|
|
659
|
+
return if event_digest.present? && digest_verified?
|
|
660
|
+
|
|
661
|
+
raise EventWriteFailed,
|
|
662
|
+
"Clickwrap event #{id} reached the commit boundary without a valid finalized digest. " \
|
|
663
|
+
"Every writer must append all covered facts and call `finalize_integrity!` before commit."
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def represented_party_has_complete_authority
|
|
667
|
+
if represented_party_reference.blank?
|
|
668
|
+
facts = [authority_source, authority_role, authority_verified_at, authority_details.presence]
|
|
669
|
+
errors.add(:represented_party_reference, "is missing while authority facts are present") if facts.any?
|
|
670
|
+
return
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
missing = {
|
|
674
|
+
authority_source: authority_source,
|
|
675
|
+
authority_role: authority_role,
|
|
676
|
+
authority_verified_at: authority_verified_at
|
|
677
|
+
}.select { |_, value| value.blank? }.keys
|
|
678
|
+
return if missing.empty?
|
|
679
|
+
|
|
680
|
+
errors.add(:represented_party_reference, "requires #{missing.join(", ")}")
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def commit_pending_receipts
|
|
684
|
+
Array(@pending_receipts).each(&:mark_committed!)
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def run_after_commit_hook
|
|
688
|
+
Clickwrap.config.after_event_is_committed.call(self)
|
|
689
|
+
rescue StandardError => error
|
|
690
|
+
Clickwrap.report_after_commit_failure(error, self)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def run_integrity_attestors
|
|
694
|
+
Integrity::Attestor.attest_after_commit(self)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def at_apparent_commit_boundary
|
|
698
|
+
connection = ::ActiveRecord::Base.connection
|
|
699
|
+
|
|
700
|
+
if connection.transaction_open?
|
|
701
|
+
@durable_commit_callback ||= DurableCommitCallback.defer(self)
|
|
702
|
+
else
|
|
703
|
+
finalize_durable_commit!
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def request_evidence_disposition_documented?(annex, category)
|
|
708
|
+
events = Event.where(root_event_id: root_event_id || id, event_type: "disposition").to_a
|
|
709
|
+
|
|
710
|
+
events.any? do |candidate|
|
|
711
|
+
disposition = candidate.protected_outcome.to_h["request_evidence_disposition"].to_h
|
|
712
|
+
disposition_event_links_to_self?(candidate) && candidate.digest_verified? &&
|
|
713
|
+
disposition["category"] == category.to_s &&
|
|
714
|
+
disposition["annex_id"].to_s == annex.id.to_s &&
|
|
715
|
+
disposition["disposed_at"].to_s == Receipt.format_time(annex.public_send(:"#{category}_deleted_at"))
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def disposition_event_links_to_self?(candidate)
|
|
720
|
+
candidate&.event_type == "disposition" &&
|
|
721
|
+
candidate.predecessor_event_id.to_s == id.to_s &&
|
|
722
|
+
candidate.root_event_id.to_s == (root_event_id.presence || id).to_s
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def refuse_ordinary_update
|
|
726
|
+
touched = changed - MUTABLE_COLUMNS
|
|
727
|
+
return if touched.empty?
|
|
728
|
+
|
|
729
|
+
raise EventWriteFailed,
|
|
730
|
+
"Clickwrap events refuse ordinary mutation, so #{touched.join(", ")} cannot be updated on " \
|
|
731
|
+
"event #{id}. Corrections, withdrawals, expiries, and supersessions are new linked " \
|
|
732
|
+
"events; that is what keeps a receipt able to show what was true at the time as " \
|
|
733
|
+
"well as what is true now."
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def refuse_destroy
|
|
737
|
+
raise EventWriteFailed,
|
|
738
|
+
"Clickwrap events cannot be destroyed through the ordinary model API. Event #{id} " \
|
|
739
|
+
"must remain at its chain position. Disposition " \
|
|
740
|
+
"runs through Clickwrap::Retention with a reason and its own recorded event."
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
end
|