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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,606 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Clickwrap
|
|
6
|
+
# The answer to "show me exactly what the application recorded."
|
|
7
|
+
#
|
|
8
|
+
# One canonical JSON body per event, plus a human-readable projection of the
|
|
9
|
+
# same facts. The canonical form is what gets digested and independently
|
|
10
|
+
# verified, so it is built from the stored evidence alone: no Ruby object
|
|
11
|
+
# serialization, no current policy source, no database column order, no
|
|
12
|
+
# locale-dependent number formatting. A verifier written years from now in
|
|
13
|
+
# another language must be able to reproduce these bytes from the data.
|
|
14
|
+
#
|
|
15
|
+
# Raw IP address, browser user-agent, and IP-geolocation values are NOT in the
|
|
16
|
+
# canonical body. They live in a separately encrypted annex with its own
|
|
17
|
+
# authorization, retention, hold, and disposition state, and the body carries
|
|
18
|
+
# only a keyed digest binding the two. That boundary is what lets the core
|
|
19
|
+
# event stay immutable and verifiable when a permitted retention process later
|
|
20
|
+
# removes the annex — deletion changes what a receipt can show, not what it
|
|
21
|
+
# says happened.
|
|
22
|
+
class Receipt
|
|
23
|
+
SCHEMA = "clickwrap.receipt.v1"
|
|
24
|
+
|
|
25
|
+
# The Clickwrap profile for timestamps: UTC, exactly six fractional digits,
|
|
26
|
+
# `Z` suffix. Fixed width so two verifiers never disagree about whether a
|
|
27
|
+
# trailing zero was significant.
|
|
28
|
+
TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%6NZ"
|
|
29
|
+
|
|
30
|
+
attr_reader :event
|
|
31
|
+
|
|
32
|
+
def initialize(event)
|
|
33
|
+
@event = event
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def find(event_id)
|
|
38
|
+
# The annex is eager-loaded only where it can exist. An installation
|
|
39
|
+
# that records no request evidence never created that table, and
|
|
40
|
+
# `includes` would read its schema before deciding there is nothing in
|
|
41
|
+
# it. The receipt still reports every category by state — the states
|
|
42
|
+
# just all come out `not_configured`, which is the true answer.
|
|
43
|
+
associations = %i[statements documents policy_revision]
|
|
44
|
+
associations << :request_evidence if SchemaRequirements.available?(:request_evidence)
|
|
45
|
+
|
|
46
|
+
event = Event.includes(*associations).find_by(id: event_id)
|
|
47
|
+
|
|
48
|
+
raise ReceiptInvalid, "No Clickwrap event with id #{event_id.inspect}" unless event
|
|
49
|
+
|
|
50
|
+
new(event)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def format_time(time)
|
|
54
|
+
return nil if time.nil?
|
|
55
|
+
|
|
56
|
+
time.utc.strftime(TIME_FORMAT)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An export names each sensitive field it includes. There is deliberately
|
|
60
|
+
# no `include_sensitive_context: true`: one flag that turns on three
|
|
61
|
+
# different categories of personal data is exactly the kind of option that
|
|
62
|
+
# makes an operator's intent unreviewable.
|
|
63
|
+
def export(receipt, requested_by: nil, because: nil,
|
|
64
|
+
include_ip_address: false, include_browser_user_agent: false,
|
|
65
|
+
include_ip_geolocation: false, access_channel: "export")
|
|
66
|
+
receipt = find(receipt) if receipt.is_a?(String)
|
|
67
|
+
requested = { ip_address: include_ip_address,
|
|
68
|
+
browser_user_agent: include_browser_user_agent,
|
|
69
|
+
ip_geolocation: include_ip_geolocation }
|
|
70
|
+
|
|
71
|
+
authorize_export!(receipt, requested, requested_by, because)
|
|
72
|
+
# A redacted receipt contains no annex value and therefore performs no
|
|
73
|
+
# privileged read. It needs neither an access row nor a transaction of
|
|
74
|
+
# its own; making ordinary exports depend on commit context would make
|
|
75
|
+
# harmless rendering fail inside otherwise unrelated host work.
|
|
76
|
+
return receipt.to_h unless requested.value?(true)
|
|
77
|
+
|
|
78
|
+
refuse_export_inside_an_outer_transaction!
|
|
79
|
+
|
|
80
|
+
exported = nil
|
|
81
|
+
Event.transaction(requires_new: true) do
|
|
82
|
+
event = Event.lock.find(receipt.event.id)
|
|
83
|
+
current_receipt = new(event)
|
|
84
|
+
included = requested.transform_keys(&:to_s)
|
|
85
|
+
requested_by_reference = Reference.actor(requested_by)
|
|
86
|
+
|
|
87
|
+
ReceiptAccess.record!(
|
|
88
|
+
event: event,
|
|
89
|
+
requested_by: requested_by,
|
|
90
|
+
because: because,
|
|
91
|
+
included_fields: included,
|
|
92
|
+
access_channel: access_channel
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
Lifecycle.append_lifecycle_event!(
|
|
96
|
+
event: event,
|
|
97
|
+
event_type: "receipt_access",
|
|
98
|
+
reason: because.presence || "Exported a redacted receipt",
|
|
99
|
+
actor: requested_by,
|
|
100
|
+
extra: {
|
|
101
|
+
protected_outcome: {
|
|
102
|
+
"receipt_access" => {
|
|
103
|
+
"requested_by_reference" => requested_by_reference,
|
|
104
|
+
"included_fields" => included,
|
|
105
|
+
"access_channel" => access_channel.to_s
|
|
106
|
+
}.compact
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
exported = current_receipt.send(
|
|
112
|
+
:to_h_with_revealed_request_evidence,
|
|
113
|
+
requested.select { |_, wanted| wanted }.keys
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
exported
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Verifies a receipt with no host application, no database, and no policy
|
|
121
|
+
# source — the standalone path the CLI uses.
|
|
122
|
+
def verify(canonical_json, documents: {})
|
|
123
|
+
ReceiptVerifier.verify(canonical_json, documents: documents)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def authorize_export!(receipt, requested, requested_by, because)
|
|
129
|
+
return unless requested.any? { |_, wanted| wanted }
|
|
130
|
+
|
|
131
|
+
if because.to_s.strip.empty?
|
|
132
|
+
raise AccessNotAuthorized,
|
|
133
|
+
"Reading unredacted request evidence needs a `because:` naming the specific " \
|
|
134
|
+
"reason — an investigation, a dispute, a request. It is recorded with the access."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
permitted = Clickwrap.config.authorize_unredacted_request_evidence_access_with
|
|
138
|
+
.call(requested_by, receipt, because)
|
|
139
|
+
|
|
140
|
+
return if permitted
|
|
141
|
+
|
|
142
|
+
raise AccessNotAuthorized,
|
|
143
|
+
"The host's authorize_unredacted_request_evidence_access_with callback declined " \
|
|
144
|
+
"this request for #{requested.select { |_, w| w }.keys.join(", ")}."
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def refuse_export_inside_an_outer_transaction!
|
|
148
|
+
return unless ::ActiveRecord::Base.connection.transaction_open?
|
|
149
|
+
|
|
150
|
+
raise AccessNotAuthorized,
|
|
151
|
+
"Receipt export cannot run inside an outer database transaction. Clickwrap records " \
|
|
152
|
+
"the access event before returning any export; call it after the surrounding " \
|
|
153
|
+
"transaction commits so the audit record cannot later roll back."
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# --- Identity -------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def event_id = event.id
|
|
160
|
+
def policy_key = event.policy_key
|
|
161
|
+
def recorded_at_by_server = event.recorded_at_by_server
|
|
162
|
+
def actor_reference = event.actor_reference
|
|
163
|
+
def actor = event.actor
|
|
164
|
+
def subject = event.subject
|
|
165
|
+
def committed? = true
|
|
166
|
+
|
|
167
|
+
def statements = event.statements
|
|
168
|
+
def documents = event.documents
|
|
169
|
+
def request_evidence = event.request_evidence
|
|
170
|
+
|
|
171
|
+
def held? = event.on_legal_hold?
|
|
172
|
+
|
|
173
|
+
# --- Serialization --------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
# The canonical, digestible body.
|
|
176
|
+
#
|
|
177
|
+
# `integrity.receipt_digest` covers this exact body with only the
|
|
178
|
+
# self-referential `integrity.receipt_digest` field removed — the digest
|
|
179
|
+
# cannot cover itself, and the exclusion has to be one a verifier in another
|
|
180
|
+
# language can reproduce without guessing. It is a
|
|
181
|
+
# different value from `integrity.event_digest`, which the application
|
|
182
|
+
# computed over the event's own canonical body when the event was written.
|
|
183
|
+
# Both are reported, because they answer different questions: one says this
|
|
184
|
+
# file has not been edited, the other says the row it describes has not been.
|
|
185
|
+
#
|
|
186
|
+
# An export that reveals request evidence is a different document from a
|
|
187
|
+
# redacted one, so it carries a different receipt digest. That is correct:
|
|
188
|
+
# each file verifies as the file it actually is.
|
|
189
|
+
def to_h
|
|
190
|
+
build_receipt_body(revealed_request_evidence: [])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def to_h_with_revealed_request_evidence(categories)
|
|
194
|
+
build_receipt_body(revealed_request_evidence: Array(categories).map(&:to_sym))
|
|
195
|
+
end
|
|
196
|
+
private :to_h_with_revealed_request_evidence
|
|
197
|
+
|
|
198
|
+
def build_receipt_body(revealed_request_evidence:)
|
|
199
|
+
body = build_body(reveal: revealed_request_evidence)
|
|
200
|
+
algorithm = event.digest_algorithm.presence || "sha256"
|
|
201
|
+
covered = body.deep_dup
|
|
202
|
+
|
|
203
|
+
body["integrity"]["receipt_digest"] = Digest.digest_canonical(covered, algorithm: algorithm)
|
|
204
|
+
body
|
|
205
|
+
end
|
|
206
|
+
private :build_receipt_body
|
|
207
|
+
|
|
208
|
+
def to_canonical_json = CanonicalJson.generate(to_h)
|
|
209
|
+
|
|
210
|
+
def to_json(*) = to_canonical_json
|
|
211
|
+
|
|
212
|
+
def as_json(*) = to_h
|
|
213
|
+
|
|
214
|
+
# The human-readable projection. Same facts, rendered — never a different
|
|
215
|
+
# set of facts, and never a stronger claim than the canonical body makes.
|
|
216
|
+
def to_html(view_context: nil) = ReceiptHtml.new(self, view_context: view_context).render
|
|
217
|
+
|
|
218
|
+
def build_body(reveal:)
|
|
219
|
+
{
|
|
220
|
+
"schema" => SCHEMA,
|
|
221
|
+
"event_id" => event.id,
|
|
222
|
+
"event_type" => event.event_type,
|
|
223
|
+
"event" => event.canonical_body,
|
|
224
|
+
"policy" => policy_fragment,
|
|
225
|
+
"actor" => actor_fragment,
|
|
226
|
+
"acts" => statements.map(&:canonical_fragment),
|
|
227
|
+
"documents" => documents.map(&:canonical_fragment),
|
|
228
|
+
"presentation" => presentation_fragment,
|
|
229
|
+
"outcome" => event.protected_outcome.presence,
|
|
230
|
+
"lifecycle" => lifecycle_fragment,
|
|
231
|
+
"provider" => provider_fragment,
|
|
232
|
+
"request_evidence" => request_evidence_fragment(reveal),
|
|
233
|
+
"retention" => retention_fragment,
|
|
234
|
+
"integrity" => integrity_fragment,
|
|
235
|
+
"recorded_at_by_server" => self.class.format_time(event.recorded_at_by_server),
|
|
236
|
+
"occurred_at" => self.class.format_time(event.occurred_at),
|
|
237
|
+
"system" => system_fragment,
|
|
238
|
+
"verifier_instructions" => verifier_instructions
|
|
239
|
+
}.compact
|
|
240
|
+
end
|
|
241
|
+
private :build_body
|
|
242
|
+
|
|
243
|
+
# PDF is optional rendering of the receipt, never the source of truth. The
|
|
244
|
+
# gem ships no PDF dependency; a host configures a renderer if it wants one.
|
|
245
|
+
def to_pdf(*)
|
|
246
|
+
raise ConfigurationError,
|
|
247
|
+
"Clickwrap does not render PDFs itself — a PDF library is not a dependency of an " \
|
|
248
|
+
"evidence gem, and a PDF is a rendering of the receipt rather than the record. " \
|
|
249
|
+
"Render `to_html` with your own PDF pipeline if you need one."
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# --- Verification ---------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def verify
|
|
255
|
+
Verification.verify(event.id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def digest_verified? = event.digest_verified?
|
|
259
|
+
|
|
260
|
+
# --- Legal holds ----------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def place_on_legal_hold!(because:, placed_by:, review_at:)
|
|
263
|
+
SchemaRequirements.require!(:retention_ops)
|
|
264
|
+
|
|
265
|
+
hold = nil
|
|
266
|
+
|
|
267
|
+
::ActiveRecord::Base.transaction do
|
|
268
|
+
locked_event = Event.lock.find(event.id)
|
|
269
|
+
hold = LegalHold.create!(
|
|
270
|
+
hold_scope: "event",
|
|
271
|
+
event_id: event.id,
|
|
272
|
+
reason: because,
|
|
273
|
+
placed_by_reference: reference_for(placed_by),
|
|
274
|
+
placed_at: Clickwrap.now,
|
|
275
|
+
review_at: review_at,
|
|
276
|
+
created_at: Clickwrap.now
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
locked_event.set_legal_hold!(true)
|
|
280
|
+
Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_placed",
|
|
281
|
+
reason: because, actor: placed_by)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
hold
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def release_legal_hold!(because:, released_by:)
|
|
288
|
+
::ActiveRecord::Base.transaction do
|
|
289
|
+
locked_event = Event.lock.find(event.id)
|
|
290
|
+
holds = LegalHold.lock.for_event(event.id).in_effect.to_a
|
|
291
|
+
return nil if holds.empty?
|
|
292
|
+
|
|
293
|
+
holds.each do |hold|
|
|
294
|
+
hold.release!(because: because, released_by: released_by)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
locked_event.set_legal_hold!(LegalHold.for_event(event.id).in_effect.exists?)
|
|
298
|
+
Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_released",
|
|
299
|
+
reason: because, actor: released_by)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def legal_holds = LegalHold.for_event(event.id)
|
|
304
|
+
|
|
305
|
+
def inspect = "#<Clickwrap::Receipt #{event_id} #{policy_key}>"
|
|
306
|
+
|
|
307
|
+
def to_s = event_id
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def policy_fragment
|
|
312
|
+
{
|
|
313
|
+
"key" => event.policy_key,
|
|
314
|
+
"revision" => event.policy_revision&.revision_digest,
|
|
315
|
+
"retention_class" => event.retention_class_key
|
|
316
|
+
}.compact
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def actor_fragment
|
|
320
|
+
{
|
|
321
|
+
"reference" => event.actor_reference,
|
|
322
|
+
"attribution" => {
|
|
323
|
+
"method" => event.attribution_method,
|
|
324
|
+
"authenticated" => event.attribution_method == "authenticated_session"
|
|
325
|
+
},
|
|
326
|
+
"authentication_method" => event.authentication_method,
|
|
327
|
+
"snapshot" => event.actor_snapshot.presence,
|
|
328
|
+
"acting_for" => acting_for_fragment,
|
|
329
|
+
"tenant" => event.tenant_key,
|
|
330
|
+
"subject" => subject_fragment
|
|
331
|
+
}.compact
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def acting_for_fragment
|
|
335
|
+
return nil if event.represented_party_reference.blank?
|
|
336
|
+
|
|
337
|
+
{
|
|
338
|
+
"type" => event.represented_party_type,
|
|
339
|
+
"reference" => event.represented_party_reference,
|
|
340
|
+
"authority_source" => event.authority_source,
|
|
341
|
+
"authority_role" => event.authority_role,
|
|
342
|
+
"authority_verified_at" => self.class.format_time(event.authority_verified_at),
|
|
343
|
+
"authority_details" => event.authority_details.presence
|
|
344
|
+
}.compact
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def subject_fragment
|
|
348
|
+
return nil if event.subject_key.blank?
|
|
349
|
+
|
|
350
|
+
{ "reference" => event.subject_key, "fingerprint" => event.subject_fingerprint }.compact
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def presentation_fragment
|
|
354
|
+
return nil if event.presentation_manifest_digest.blank?
|
|
355
|
+
|
|
356
|
+
manifest = event.presentation_manifest || {}
|
|
357
|
+
|
|
358
|
+
{
|
|
359
|
+
"manifest_digest" => event.presentation_manifest_digest,
|
|
360
|
+
"submit_button_text" => manifest["submit_button_text"],
|
|
361
|
+
# Present only when the offer was ONE control carrying one composed
|
|
362
|
+
# sentence. The acts below say what was recorded; this says what the
|
|
363
|
+
# person actually read, which is the thing a substitution argument is
|
|
364
|
+
# about, and which statements that single answer covered.
|
|
365
|
+
"combined_sentence" => manifest.dig("combined_control", "sentence"),
|
|
366
|
+
"combined_statements" => manifest.dig("combined_control", "covers"),
|
|
367
|
+
"locale" => manifest["locale"],
|
|
368
|
+
"capture_channel" => event.capture_channel,
|
|
369
|
+
"offered_at" => manifest["issued_at"],
|
|
370
|
+
# Said plainly, in the receipt itself, so nobody has to infer it: this
|
|
371
|
+
# is what the server generated and accepted, not what a person read.
|
|
372
|
+
"proves" => "The server generated this presentation manifest and accepted a submission " \
|
|
373
|
+
"bound to it. It does not establish that the person read or understood the " \
|
|
374
|
+
"documents, saw particular pixels, or received a legally sufficient interface."
|
|
375
|
+
}.compact
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def lifecycle_fragment
|
|
379
|
+
successors = Event.where(root_event_id: event.id).or(Event.where(predecessor_event_id: event.id))
|
|
380
|
+
.chronological
|
|
381
|
+
|
|
382
|
+
fragment = {
|
|
383
|
+
"root_event_id" => event.root_event_id,
|
|
384
|
+
"predecessor_event_id" => event.predecessor_event_id,
|
|
385
|
+
"successors" => successors.map do |successor|
|
|
386
|
+
{
|
|
387
|
+
"event_id" => successor.id,
|
|
388
|
+
"event_type" => successor.event_type,
|
|
389
|
+
"recorded_at_by_server" => self.class.format_time(successor.recorded_at_by_server),
|
|
390
|
+
"reason" => successor.reason,
|
|
391
|
+
# A lifecycle summary without the successor's independently
|
|
392
|
+
# digestible body could be rewritten and covered only by the
|
|
393
|
+
# self-contained receipt digest. Embedding both lets the standalone
|
|
394
|
+
# verifier re-derive every event that changed this receipt's state.
|
|
395
|
+
"event" => successor.canonical_body,
|
|
396
|
+
"event_digest" => successor.event_digest
|
|
397
|
+
}.compact
|
|
398
|
+
end
|
|
399
|
+
}.compact
|
|
400
|
+
|
|
401
|
+
fragment["successors"].empty? && fragment.length == 1 ? nil : fragment
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def provider_fragment
|
|
405
|
+
return nil if event.provider_name.blank?
|
|
406
|
+
|
|
407
|
+
{
|
|
408
|
+
"name" => event.provider_name,
|
|
409
|
+
"event_id" => event.provider_event_id,
|
|
410
|
+
"verification" => event.provider_verification.presence,
|
|
411
|
+
# An imported provider event is not a click this application captured,
|
|
412
|
+
# and the receipt says so rather than letting the two look alike.
|
|
413
|
+
"note" => "Recorded from an external provider's receipt. Clickwrap did not present this " \
|
|
414
|
+
"content or observe this action."
|
|
415
|
+
}.compact
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Five distinct states, kept distinct. "Blank" is never allowed to blur "we
|
|
419
|
+
# chose not to collect this" into "collection failed" into "we deleted it
|
|
420
|
+
# under a retention rule" — those tell a reader completely different things.
|
|
421
|
+
def request_evidence_fragment(reveal)
|
|
422
|
+
annex = request_evidence
|
|
423
|
+
|
|
424
|
+
RequestEvidence::CATEGORIES.to_h do |category|
|
|
425
|
+
[category.to_s, category_fragment(annex, category, reveal)]
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def category_fragment(annex, category, reveal)
|
|
430
|
+
return { "state" => "not_configured" } if annex.nil?
|
|
431
|
+
|
|
432
|
+
authorized = reveal.include?(category)
|
|
433
|
+
state = annex.state_for(category, authorized_to_read: authorized, held: held?)
|
|
434
|
+
|
|
435
|
+
fragment = { "state" => state }
|
|
436
|
+
fragment["unavailable_reason"] = annex.unavailable_reason_for(category) if state == "unavailable"
|
|
437
|
+
fragment["deleted_at"] = self.class.format_time(annex.public_send(:"#{category}_deleted_at")) if
|
|
438
|
+
state == "deleted_after_retention"
|
|
439
|
+
|
|
440
|
+
fragment.merge!(revealed_values(annex, category)) if authorized && state == "recorded"
|
|
441
|
+
fragment
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def revealed_values(annex, category)
|
|
445
|
+
case category
|
|
446
|
+
when :ip_address
|
|
447
|
+
{
|
|
448
|
+
"value" => annex.ip_address,
|
|
449
|
+
"reader" => annex.ip_address_reader_name,
|
|
450
|
+
"trusted_proxy_configuration_digest" => annex.trusted_proxy_configuration_digest,
|
|
451
|
+
"recorded_at" => self.class.format_time(annex.ip_address_recorded_at),
|
|
452
|
+
# Stated in the receipt because the alternative is a reader assuming
|
|
453
|
+
# otherwise: an address is a network observation about a request.
|
|
454
|
+
"means" => "IP address observed by the configured reader for this request. Not identity."
|
|
455
|
+
}.compact
|
|
456
|
+
when :browser_user_agent
|
|
457
|
+
{
|
|
458
|
+
"value" => annex.browser_user_agent,
|
|
459
|
+
"was_client_supplied" => annex.browser_user_agent_was_client_supplied?,
|
|
460
|
+
"recorded_at" => self.class.format_time(annex.browser_user_agent_recorded_at),
|
|
461
|
+
"means" => "The User-Agent header the client sent. Client-supplied, and not a device " \
|
|
462
|
+
"identity or proof of what was rendered."
|
|
463
|
+
}.compact
|
|
464
|
+
when :ip_geolocation
|
|
465
|
+
geolocation_values(annex)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def geolocation_values(annex)
|
|
470
|
+
values = annex.authorized_ip_geolocation_fields.to_h do |field|
|
|
471
|
+
[field, geolocation_field_value(annex, field)]
|
|
472
|
+
end.compact
|
|
473
|
+
|
|
474
|
+
values.merge(
|
|
475
|
+
"provider_name" => annex.ip_geolocation_provider_name,
|
|
476
|
+
"provider_source" => annex.ip_geolocation_provider_source,
|
|
477
|
+
"database_version" => annex.ip_geolocation_database_version,
|
|
478
|
+
"database_sha256" => annex.ip_geolocation_database_sha256,
|
|
479
|
+
"accuracy_radius_confidence_percentage" => annex.ip_geolocation_accuracy_radius_confidence_percentage,
|
|
480
|
+
"was_estimated" => annex.ip_geolocation_was_estimated?,
|
|
481
|
+
"source_was_verified_by_host" => annex.ip_geolocation_source_was_verified_by_host?,
|
|
482
|
+
"resolved_at" => self.class.format_time(annex.ip_geolocation_resolved_at),
|
|
483
|
+
"means" => "One provider's estimate for the observed IP address at the time shown. " \
|
|
484
|
+
"Not GPS, not a street address, and not proof that the person was there."
|
|
485
|
+
).compact
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def geolocation_field_value(annex, field)
|
|
489
|
+
case field
|
|
490
|
+
when "country" then annex.ip_geolocation_country_code
|
|
491
|
+
when "region" then annex.ip_geolocation_region_code || annex.ip_geolocation_region_name
|
|
492
|
+
when "city" then annex.ip_geolocation_city_name
|
|
493
|
+
when "postal_code" then annex.ip_geolocation_postal_code
|
|
494
|
+
when "latitude_and_longitude"
|
|
495
|
+
latitude = annex.ip_geolocation_latitude
|
|
496
|
+
longitude = annex.ip_geolocation_longitude
|
|
497
|
+
# Both or neither: half a coordinate is not a result, and a lone
|
|
498
|
+
# latitude in an export invites someone to pair it with a guess.
|
|
499
|
+
latitude && longitude ? { "latitude" => latitude.to_s, "longitude" => longitude.to_s } : nil
|
|
500
|
+
when "timezone" then annex.ip_geolocation_timezone
|
|
501
|
+
when "continent" then annex.ip_geolocation_continent_code
|
|
502
|
+
when "metro_code" then annex.ip_geolocation_metro_code
|
|
503
|
+
when "accuracy_radius_in_kilometers" then annex.ip_geolocation_accuracy_radius_in_kilometers
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def retention_fragment
|
|
508
|
+
{
|
|
509
|
+
"class" => event.retention_class_key,
|
|
510
|
+
"core_event_retained_until" => self.class.format_time(event.retain_core_event_until),
|
|
511
|
+
"retention_rule" => event.retention_rule_name,
|
|
512
|
+
"core_event_disposed_at" => self.class.format_time(event.core_event_disposed_at),
|
|
513
|
+
"on_legal_hold" => event.on_legal_hold?
|
|
514
|
+
}.compact
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Each tier states exactly what it detects. The baseline claim is
|
|
518
|
+
# deliberately modest, because a hash computed and stored by the same system
|
|
519
|
+
# that stored the record cannot say more than this.
|
|
520
|
+
def integrity_fragment
|
|
521
|
+
{
|
|
522
|
+
"digest_algorithm" => event.digest_algorithm,
|
|
523
|
+
"event_digest" => event.event_digest,
|
|
524
|
+
"previous_event_digest" => event.previous_event_digest,
|
|
525
|
+
"chain_scope" => event.chain_scope,
|
|
526
|
+
"chain_sequence" => event.chain_sequence,
|
|
527
|
+
"request_evidence_category_binding_digests" =>
|
|
528
|
+
event.request_evidence_category_binding_digests.presence,
|
|
529
|
+
"request_evidence_digest_algorithm" => event.request_evidence_digest_algorithm,
|
|
530
|
+
"request_evidence_key_id" => event.request_evidence_key_id,
|
|
531
|
+
"request_evidence_binding_status" => event.request_evidence_binding_status.to_s,
|
|
532
|
+
"attestations" => event.integrity_attestations.map(&:canonical_fragment).presence,
|
|
533
|
+
"tier" => integrity_tier,
|
|
534
|
+
"detects" => integrity_claim
|
|
535
|
+
}.compact
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def integrity_tier
|
|
539
|
+
verified = event.integrity_attestations.select { |attestation| attestation.verified_for?(event) }
|
|
540
|
+
return "third_party_timestamp" if verified.any? do |attestation|
|
|
541
|
+
attestation.kind == "third_party_timestamp" &&
|
|
542
|
+
attestation.adapter_capabilities.to_h["independently_verifiable"] == true
|
|
543
|
+
end
|
|
544
|
+
return "external_event_anchoring" if verified.any? do |attestation|
|
|
545
|
+
attestation.kind == "event_anchor" &&
|
|
546
|
+
attestation.adapter_capabilities.to_h["publishes_outside_primary_database"] == true
|
|
547
|
+
end
|
|
548
|
+
return "chained_history" if event.chain_scope.present?
|
|
549
|
+
|
|
550
|
+
"baseline"
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def integrity_claim
|
|
554
|
+
case integrity_tier
|
|
555
|
+
when "third_party_timestamp"
|
|
556
|
+
"A configured timestamp provider returned a token over this exact event digest, and the " \
|
|
557
|
+
"configured adapter recorded a successful verification. The provider result, status, " \
|
|
558
|
+
"and capabilities are preserved so a reader can evaluate that provider's own claim."
|
|
559
|
+
when "external_event_anchoring"
|
|
560
|
+
"A configured adapter reported publishing this exact event chain position outside the " \
|
|
561
|
+
"primary database, and its verifier accepted the stored publication result. This " \
|
|
562
|
+
"improves detection only while that external publication remains available and trustworthy."
|
|
563
|
+
when "chained_history"
|
|
564
|
+
"Event digests are chained, which makes a rewrite of history detectable for as long as " \
|
|
565
|
+
"the chain head remains trustworthy."
|
|
566
|
+
else
|
|
567
|
+
"The recorded digest detects accidental or ordinary modification of the bytes it covers. " \
|
|
568
|
+
"It does not establish who produced them, when, or that a party controlling both the " \
|
|
569
|
+
"application and the database could not have written both the record and the digest."
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def system_fragment
|
|
574
|
+
{
|
|
575
|
+
"gem_version" => event.gem_version,
|
|
576
|
+
"application_version" => event.application_version,
|
|
577
|
+
"template_version" => event.template_version,
|
|
578
|
+
"canonical_schema_version" => event.canonical_schema_version,
|
|
579
|
+
"verifier_version" => Clickwrap::VERIFIER_VERSION
|
|
580
|
+
}.compact
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def verifier_instructions
|
|
584
|
+
receipt_check =
|
|
585
|
+
"Remove only integrity.receipt_digest, canonicalize the remaining object with RFC 8785 " \
|
|
586
|
+
"(JSON Canonicalization Scheme), and compare it with integrity.receipt_digest."
|
|
587
|
+
|
|
588
|
+
event_check = if event.disposed?
|
|
589
|
+
"The original event payload was disposed of, so its event digest cannot be " \
|
|
590
|
+
"re-derived; verify the retained tombstone and the digest-bound disposition " \
|
|
591
|
+
"successor instead."
|
|
592
|
+
else
|
|
593
|
+
"Canonicalize event and compare it with integrity.event_digest."
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
"#{receipt_check} #{event_check} Verify each documents[].source_digest and " \
|
|
597
|
+
"documents[].rendered_digest against the corresponding source and rendered files in the " \
|
|
598
|
+
"bundle. Run `clickwrap verify receipt.json --documents ./dir` to perform these checks " \
|
|
599
|
+
"without the host application."
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def reference_for(actor)
|
|
603
|
+
Reference.actor(actor)
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|