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,450 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Import
|
|
5
|
+
# `Clickwrap.import_legacy!` — bring an `accepted_terms_at` column, a
|
|
6
|
+
# bespoke audit table, or any other pre-Clickwrap record into the event log
|
|
7
|
+
# without inventing anything that was never recorded.
|
|
8
|
+
#
|
|
9
|
+
# ===========================================================================
|
|
10
|
+
# The governing rule: HISTORICAL WEAKNESS STAYS VISIBLE RATHER THAN BEING
|
|
11
|
+
# LAUNDERED INTO MODERN CERTAINTY.
|
|
12
|
+
#
|
|
13
|
+
# An import is the single easiest place in this gem to manufacture evidence
|
|
14
|
+
# by accident. Every field a modern capture fills in is sitting right there
|
|
15
|
+
# with an obvious plausible value: the current Terms text, today's document
|
|
16
|
+
# digest, the submit-button label from the current view, the assertion
|
|
17
|
+
# sentence from the current policy, an IP address from the user's last
|
|
18
|
+
# session. Writing any of them here would produce a row that is
|
|
19
|
+
# indistinguishable from a real capture and is, in the parts that matter, a
|
|
20
|
+
# fabrication.
|
|
21
|
+
#
|
|
22
|
+
# So this importer NEVER synthesizes:
|
|
23
|
+
#
|
|
24
|
+
# * a presentation manifest — nobody signed one, and there is no offer to
|
|
25
|
+
# reproduce;
|
|
26
|
+
# * an assertion — we do not know the sentence the old system offered;
|
|
27
|
+
# * submit-button text — we do not know what the control said;
|
|
28
|
+
# * an IP address or browser user-agent — these were not observed by us,
|
|
29
|
+
# and a later session's address is a different fact about a different
|
|
30
|
+
# request;
|
|
31
|
+
# * document bytes or a digest — unless the caller can point at a version
|
|
32
|
+
# that is actually published here.
|
|
33
|
+
#
|
|
34
|
+
# Every key the caller lists in `unknown:` is recorded explicitly as unknown
|
|
35
|
+
# in a structured field on the event, AND said in plain words in the
|
|
36
|
+
# assertion text of each statement — which is inside the digested canonical
|
|
37
|
+
# body, so the admission travels with the evidence rather than beside it.
|
|
38
|
+
#
|
|
39
|
+
# The event also keeps `occurred_at` (when it happened, according to the old
|
|
40
|
+
# record) strictly separate from `recorded_at_by_server` (when we wrote it
|
|
41
|
+
# down). That gap is a fact about the evidence and it stays visible.
|
|
42
|
+
# ===========================================================================
|
|
43
|
+
class Legacy
|
|
44
|
+
# What an import did, or would do. Returned by both the dry run and the
|
|
45
|
+
# real thing so a migration script reads the same either way.
|
|
46
|
+
Result = Data.define(:status, :policy_key, :actor_reference, :occurred_at,
|
|
47
|
+
:recorded_at_by_server, :known, :unknown, :statement_keys,
|
|
48
|
+
:counts_as_current, :idempotency_key, :event, :message) do
|
|
49
|
+
def imported? = status == :imported
|
|
50
|
+
def already_imported? = status == :already_imported
|
|
51
|
+
def planned? = status == :planned
|
|
52
|
+
def written? = !event.nil?
|
|
53
|
+
def receipt = event && Receipt.new(event)
|
|
54
|
+
def event_id = event&.id
|
|
55
|
+
def to_s = message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The capture channels an import may claim. `imported_provider` means the
|
|
59
|
+
# record came from another system; `system` means this application wrote
|
|
60
|
+
# it without a human at a keyboard. Neither is `web_browser`, because no
|
|
61
|
+
# browser was involved and a receipt that said otherwise would be wrong.
|
|
62
|
+
PERMITTED_CHANNELS = %w[imported_provider system].freeze
|
|
63
|
+
|
|
64
|
+
# Naming an unknown as unknown is the whole point, so the vocabulary is
|
|
65
|
+
# open: a host may list any key it wants. These are the ones the README
|
|
66
|
+
# and the FinePrint importer use, kept here so a typo in a migration
|
|
67
|
+
# script is at least visibly a typo next to its neighbours.
|
|
68
|
+
CONVENTIONAL_UNKNOWN_KEYS = %w[
|
|
69
|
+
exact_document_bytes
|
|
70
|
+
document_version
|
|
71
|
+
presentation
|
|
72
|
+
presentation_manifest
|
|
73
|
+
assertion
|
|
74
|
+
submit_button_text
|
|
75
|
+
protected_action
|
|
76
|
+
request_evidence
|
|
77
|
+
ip_address
|
|
78
|
+
browser_user_agent
|
|
79
|
+
capture_channel
|
|
80
|
+
authentication_context
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
def initialize(policy:, actor:, occurred_at:, because:, known: {}, unknown: [],
|
|
84
|
+
dry_run: false, subject: nil, tenant: nil, statements: nil,
|
|
85
|
+
capture_channel: "imported_provider", source: nil,
|
|
86
|
+
counts_as_current: true)
|
|
87
|
+
@policy = policy
|
|
88
|
+
@actor = actor
|
|
89
|
+
@occurred_at = coerce_time(occurred_at)
|
|
90
|
+
@because = because.to_s
|
|
91
|
+
@known = normalize_known(known)
|
|
92
|
+
@unknown = normalize_unknown(unknown)
|
|
93
|
+
@dry_run = dry_run
|
|
94
|
+
@subject = subject
|
|
95
|
+
@tenant = tenant
|
|
96
|
+
@statement_keys = statements&.map(&:to_s)
|
|
97
|
+
@capture_channel = capture_channel.to_s
|
|
98
|
+
@source = source&.to_s
|
|
99
|
+
@counts_as_current = counts_as_current == true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
attr_reader :policy, :actor, :occurred_at, :because, :known, :unknown, :dry_run,
|
|
103
|
+
:subject, :tenant, :capture_channel, :source, :counts_as_current
|
|
104
|
+
|
|
105
|
+
def import!
|
|
106
|
+
validate!
|
|
107
|
+
|
|
108
|
+
existing = Event.find_by(policy_key: policy.key, idempotency_key: idempotency_key)
|
|
109
|
+
return already_imported(existing) if existing
|
|
110
|
+
|
|
111
|
+
return planned if dry_run
|
|
112
|
+
|
|
113
|
+
write!
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
alias call import!
|
|
117
|
+
|
|
118
|
+
# The derived key. Two runs of the same migration script over the same
|
|
119
|
+
# legacy row produce the same key, so re-running an import is a no-op
|
|
120
|
+
# rather than a second history for the same person.
|
|
121
|
+
#
|
|
122
|
+
# It covers what the legacy record actually said: who, which policy, which
|
|
123
|
+
# subject and tenant, when it happened, and every `known:` value. Change
|
|
124
|
+
# any of those and it is a different import, which is correct — a
|
|
125
|
+
# different claim deserves a different event rather than silently
|
|
126
|
+
# colliding with the first one.
|
|
127
|
+
def idempotency_key
|
|
128
|
+
@idempotency_key ||= "imported_legacy:#{Digest.hex(CanonicalJson.generate(identity_body))}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def identity_body
|
|
134
|
+
{
|
|
135
|
+
"policy" => policy.key,
|
|
136
|
+
"actor" => actor_reference,
|
|
137
|
+
"subject" => subject_key,
|
|
138
|
+
"tenant" => tenant_key,
|
|
139
|
+
"occurred_at" => Receipt.format_time(occurred_at),
|
|
140
|
+
"statements" => statements_to_import.map(&:key),
|
|
141
|
+
"known" => known,
|
|
142
|
+
"unknown" => unknown,
|
|
143
|
+
"source" => source,
|
|
144
|
+
"capture_channel" => capture_channel,
|
|
145
|
+
"counts_as_current" => counts_as_current,
|
|
146
|
+
"because" => because
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate!
|
|
151
|
+
policy.validate_tenant!(tenant)
|
|
152
|
+
|
|
153
|
+
if because.strip.empty?
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"Importing legacy evidence needs a `because:` in plain English saying where the " \
|
|
156
|
+
"record came from — \"Imported from users.accepted_terms_at\" is exactly right. " \
|
|
157
|
+
"It is stored on the event, and years from now it is the only thing that will " \
|
|
158
|
+
"explain to a reader why this row does not look like a normal capture."
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if occurred_at.nil?
|
|
162
|
+
raise ArgumentError,
|
|
163
|
+
"Importing legacy evidence needs `occurred_at:` — the time the old record says " \
|
|
164
|
+
"this happened. Clickwrap will not substitute the time of the import, because " \
|
|
165
|
+
"the distance between when something happened and when it was written down is " \
|
|
166
|
+
"itself evidence. If the legacy row genuinely has no time, do not import it as " \
|
|
167
|
+
"an act; record an exemption with `Clickwrap.exempt!` instead."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
return if PERMITTED_CHANNELS.include?(capture_channel)
|
|
171
|
+
|
|
172
|
+
raise ArgumentError,
|
|
173
|
+
"An import cannot claim capture channel #{capture_channel.inspect}. An imported " \
|
|
174
|
+
"record was not captured through a presentation this application rendered, so it " \
|
|
175
|
+
"is one of: #{PERMITTED_CHANNELS.join(", ")}."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def statements_to_import
|
|
179
|
+
if @statement_keys
|
|
180
|
+
@statement_keys.map { |key| policy.statement!(key) }
|
|
181
|
+
else
|
|
182
|
+
# Optional statements are deliberately excluded. A legacy boolean
|
|
183
|
+
# column recorded one decision; reading it as a grant of an optional
|
|
184
|
+
# consent purpose it never mentioned would invent the very thing an
|
|
185
|
+
# optional control exists to keep honest. Name them in `statements:`
|
|
186
|
+
# if the old record really did cover them.
|
|
187
|
+
policy.required_statements
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def write!
|
|
192
|
+
now = Clickwrap.now
|
|
193
|
+
revision = PolicyRevision.freeze_legacy_import_for(
|
|
194
|
+
policy,
|
|
195
|
+
source: source,
|
|
196
|
+
statements: statements_to_import,
|
|
197
|
+
known: known,
|
|
198
|
+
unknown: unknown
|
|
199
|
+
)
|
|
200
|
+
event = nil
|
|
201
|
+
|
|
202
|
+
::ActiveRecord::Base.transaction do
|
|
203
|
+
# Actor lock BEFORE the event insert: saving the event reserves the
|
|
204
|
+
# chain head, and every writer takes these two locks actor-first
|
|
205
|
+
# (the order capture uses) so concurrent paths cannot deadlock.
|
|
206
|
+
StatementIdentityLock.acquire_for_actor!(actor_reference)
|
|
207
|
+
|
|
208
|
+
event = build_event(now, revision)
|
|
209
|
+
build_statements(event, now)
|
|
210
|
+
build_documents(event)
|
|
211
|
+
|
|
212
|
+
event.save!
|
|
213
|
+
event.finalize_integrity!
|
|
214
|
+
# Project into current state, exactly as a capture would: the point
|
|
215
|
+
# of a migration is that `agreed_to?` keeps answering what the old
|
|
216
|
+
# system answered. The projection carries this event's id, so the
|
|
217
|
+
# imported provenance is one join away from every "yes".
|
|
218
|
+
CurrentState.apply!(event) if counts_as_current
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
Result.new(
|
|
222
|
+
status: :imported, policy_key: policy.key, actor_reference: actor_reference,
|
|
223
|
+
occurred_at: occurred_at, recorded_at_by_server: now, known: known, unknown: unknown,
|
|
224
|
+
statement_keys: statements_to_import.map(&:key), counts_as_current: counts_as_current,
|
|
225
|
+
idempotency_key: idempotency_key,
|
|
226
|
+
event: event,
|
|
227
|
+
message: "Imported #{policy.key} for #{actor_reference} as event #{event.id}. " \
|
|
228
|
+
"#{unknown_sentence}"
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def build_event(now, revision)
|
|
233
|
+
Event.new(
|
|
234
|
+
event_type: "imported_legacy",
|
|
235
|
+
policy_key: policy.key,
|
|
236
|
+
policy_revision: revision,
|
|
237
|
+
actor: actor.is_a?(::ActiveRecord::Base) ? actor : nil,
|
|
238
|
+
actor_reference: actor_reference,
|
|
239
|
+
tenant_key: tenant_key.presence,
|
|
240
|
+
subject: subject.is_a?(::ActiveRecord::Base) ? subject : nil,
|
|
241
|
+
subject_key: subject_key,
|
|
242
|
+
capture_channel: capture_channel,
|
|
243
|
+
# Not `authenticated_session`, not `unknown`: this record reached us
|
|
244
|
+
# from somewhere else, and that is a different fact from "we do not
|
|
245
|
+
# know how they were attributed".
|
|
246
|
+
attribution_method: "imported_provider",
|
|
247
|
+
# The two times stay apart. `occurred_at` is what the old record says;
|
|
248
|
+
# `recorded_at_by_server` is when this row was written. Collapsing
|
|
249
|
+
# them would quietly upgrade a migration into a contemporaneous
|
|
250
|
+
# observation.
|
|
251
|
+
occurred_at: occurred_at,
|
|
252
|
+
recorded_at_by_server: now,
|
|
253
|
+
idempotency_key: idempotency_key,
|
|
254
|
+
provider_receipt: known.presence,
|
|
255
|
+
provider_verification: import_provenance(now),
|
|
256
|
+
reason: because,
|
|
257
|
+
retention_class_key: policy.retention_class_key,
|
|
258
|
+
canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
259
|
+
gem_version: Clickwrap::VERSION,
|
|
260
|
+
application_version: Clickwrap.config.resolved_application_version,
|
|
261
|
+
created_at: now
|
|
262
|
+
# presentation_manifest and presentation_manifest_digest are left
|
|
263
|
+
# unset on purpose. There was no manifest. A synthesized one would be
|
|
264
|
+
# a signed description of an offer nobody made.
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The structured record of what this import knew and what it did not. It
|
|
269
|
+
# lives on the event so a reader never has to reconstruct the migration
|
|
270
|
+
# script to find out which parts of a receipt are missing on purpose.
|
|
271
|
+
def import_provenance(now)
|
|
272
|
+
{
|
|
273
|
+
"import_method" => "legacy_record",
|
|
274
|
+
"source" => source,
|
|
275
|
+
"because" => because,
|
|
276
|
+
"imported_at" => Receipt.format_time(now),
|
|
277
|
+
"occurred_at_source" => Receipt.format_time(occurred_at),
|
|
278
|
+
"known" => known,
|
|
279
|
+
"unknown" => unknown,
|
|
280
|
+
"counts_as_current" => counts_as_current,
|
|
281
|
+
"not_collected" => %w[presentation_manifest ip_address browser_user_agent ip_geolocation],
|
|
282
|
+
"means" => "Recorded from a pre-existing record in this application or another system. " \
|
|
283
|
+
"Clickwrap did not present this content and did not observe this action. " \
|
|
284
|
+
"The fields listed under \"unknown\" were not recorded by whatever did."
|
|
285
|
+
}.compact
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def build_statements(event, now)
|
|
289
|
+
statements_to_import.each_with_index do |statement, index|
|
|
290
|
+
event.statements.build(
|
|
291
|
+
ordinal: index,
|
|
292
|
+
statement_key: statement.key,
|
|
293
|
+
kind: statement.kind,
|
|
294
|
+
action: statement.initial_action,
|
|
295
|
+
assertion_text: assertion_text_for(statement),
|
|
296
|
+
assertion_locale: "en",
|
|
297
|
+
required: statement.required?,
|
|
298
|
+
optional: statement.optional?,
|
|
299
|
+
# No answer was captured by this application. The action records
|
|
300
|
+
# what the old system says happened; `answered` records whether we
|
|
301
|
+
# have the answer itself, and we do not.
|
|
302
|
+
answer: nil,
|
|
303
|
+
answered: false,
|
|
304
|
+
purpose_key: statement.purpose_key,
|
|
305
|
+
withdrawal_path: statement.withdrawal_path,
|
|
306
|
+
valid_from: occurred_at,
|
|
307
|
+
expires_at: statement.expires_after(occurred_at),
|
|
308
|
+
one_time: statement.one_time?,
|
|
309
|
+
requires: statement.requires,
|
|
310
|
+
created_at: now
|
|
311
|
+
)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The sentence that goes in the receipt. It is deliberately not the
|
|
316
|
+
# policy's current assertion text: that sentence may have been written
|
|
317
|
+
# years after the act, and putting it here would claim the source system
|
|
318
|
+
# offered wording that may not have existed yet.
|
|
319
|
+
def assertion_text_for(statement)
|
|
320
|
+
[
|
|
321
|
+
"Imported from a pre-existing record: it states that this actor " \
|
|
322
|
+
"#{Vocabulary.initial_action_for(statement.kind)} #{statement.key} " \
|
|
323
|
+
"on #{Receipt.format_time(occurred_at)}.",
|
|
324
|
+
"The source system's original offer wording was not recorded, so this receipt does " \
|
|
325
|
+
"not reproduce it.",
|
|
326
|
+
unknown_sentence,
|
|
327
|
+
because
|
|
328
|
+
].compact.reject(&:empty?).join(" ")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def unknown_sentence
|
|
332
|
+
return "" if unknown.empty?
|
|
333
|
+
|
|
334
|
+
"Not recorded by the source and therefore unknown: #{unknown.join(", ")}."
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Documents are linked only when the caller can point at bytes that are
|
|
338
|
+
# actually published here AND has not told us the bytes are unknown. A
|
|
339
|
+
# version label alone is a claim about a label, not about content, and an
|
|
340
|
+
# EventDocument row asserts a digest.
|
|
341
|
+
def build_documents(event)
|
|
342
|
+
return if unknown.intersect?(%w[exact_document_bytes document_version])
|
|
343
|
+
|
|
344
|
+
label = known["document_version"]
|
|
345
|
+
return if label.blank?
|
|
346
|
+
|
|
347
|
+
ordinal = 0
|
|
348
|
+
|
|
349
|
+
statements_to_import.each do |statement|
|
|
350
|
+
statement.document_keys.each do |document_key|
|
|
351
|
+
version = published_version(document_key, label)
|
|
352
|
+
next unless version
|
|
353
|
+
|
|
354
|
+
event.documents.build(
|
|
355
|
+
statement_key: statement.key,
|
|
356
|
+
document_key: document_key,
|
|
357
|
+
document_version_id: version.id,
|
|
358
|
+
version_label: version.version_label,
|
|
359
|
+
locale: version.locale,
|
|
360
|
+
source_media_type: version.media_type,
|
|
361
|
+
source_content_digest: version.content_digest,
|
|
362
|
+
rendered_media_type: version.rendered_media_type.presence || version.media_type,
|
|
363
|
+
rendered_content_digest: version.rendered_content_digest.presence || version.content_digest,
|
|
364
|
+
renderer_name: version.renderer_name,
|
|
365
|
+
renderer_version: version.renderer_version,
|
|
366
|
+
sanitizer_name: version.sanitizer_name,
|
|
367
|
+
sanitizer_version: version.sanitizer_version,
|
|
368
|
+
ordinal: ordinal,
|
|
369
|
+
created_at: event.recorded_at_by_server
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
ordinal += 1
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def published_version(document_key, label)
|
|
378
|
+
document = ::Clickwrap::Document.find_by(
|
|
379
|
+
document_key: document_key,
|
|
380
|
+
tenant_key: tenant_key.presence
|
|
381
|
+
)
|
|
382
|
+
document ||= ::Clickwrap::Document.find_by(document_key: document_key, tenant_key: nil)
|
|
383
|
+
return nil unless document
|
|
384
|
+
|
|
385
|
+
document.versions.find_by(version_label: label.to_s)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def already_imported(event)
|
|
389
|
+
Result.new(
|
|
390
|
+
status: :already_imported, policy_key: policy.key, actor_reference: actor_reference,
|
|
391
|
+
occurred_at: occurred_at, recorded_at_by_server: event.recorded_at_by_server,
|
|
392
|
+
known: known, unknown: unknown, statement_keys: event.statements.map(&:statement_key),
|
|
393
|
+
counts_as_current: event.provider_verification.to_h.fetch("counts_as_current", true),
|
|
394
|
+
idempotency_key: idempotency_key, event: event,
|
|
395
|
+
message: "Already imported as event #{event.id}; nothing was written."
|
|
396
|
+
)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def planned
|
|
400
|
+
Result.new(
|
|
401
|
+
status: :planned, policy_key: policy.key, actor_reference: actor_reference,
|
|
402
|
+
occurred_at: occurred_at, recorded_at_by_server: nil, known: known, unknown: unknown,
|
|
403
|
+
statement_keys: statements_to_import.map(&:key), counts_as_current: counts_as_current,
|
|
404
|
+
idempotency_key: idempotency_key,
|
|
405
|
+
event: nil,
|
|
406
|
+
message: "Would import #{policy.key} for #{actor_reference} " \
|
|
407
|
+
"(#{statements_to_import.map(&:key).join(", ")}). #{unknown_sentence}".strip
|
|
408
|
+
)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def normalize_known(raw)
|
|
412
|
+
(raw || {}).to_h { |key, value| [key.to_s, value&.to_s] }.compact
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def normalize_unknown(raw)
|
|
416
|
+
Array(raw).map(&:to_s).reject(&:empty?).uniq.sort
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Legacy times arrive in whatever shape the old system stored them: a Time,
|
|
420
|
+
# a Date, an ActiveSupport::TimeWithZone, or — when a migration reads
|
|
421
|
+
# another gem's table through a raw connection — a plain String. All of
|
|
422
|
+
# them are coerced to UTC here rather than at four call sites, and an
|
|
423
|
+
# unparseable value becomes nil so `validate!` refuses the import with a
|
|
424
|
+
# sentence instead of raising NoMethodError somewhere downstream.
|
|
425
|
+
def coerce_time(value)
|
|
426
|
+
return nil if value.nil?
|
|
427
|
+
return value.utc if value.respond_to?(:utc)
|
|
428
|
+
|
|
429
|
+
parsed = value.respond_to?(:to_time) ? value.to_time : Time.parse(value.to_s)
|
|
430
|
+
parsed&.utc
|
|
431
|
+
rescue ArgumentError, TypeError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def actor_reference
|
|
436
|
+
@actor_reference ||= Reference.actor(actor)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def subject_key = StatementState.subject_key_for(subject)
|
|
440
|
+
|
|
441
|
+
def tenant_key
|
|
442
|
+
return "" if tenant.nil?
|
|
443
|
+
return tenant.to_s if tenant.is_a?(String) || tenant.is_a?(Symbol)
|
|
444
|
+
return tenant.to_gid.to_s if tenant.respond_to?(:to_gid)
|
|
445
|
+
|
|
446
|
+
"#{tenant.class.name}/#{tenant.id}"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Integrations
|
|
5
|
+
# Optional adapter for https://github.com/rameerez/organizations.
|
|
6
|
+
#
|
|
7
|
+
# There is deliberately no runtime dependency on that gem. The adapter is
|
|
8
|
+
# selected only by a policy that says
|
|
9
|
+
# `permit_acting_for_organization`, and it talks to the public Membership
|
|
10
|
+
# association/API when capture occurs. A missing gem, wrong represented
|
|
11
|
+
# party type, missing membership, stale/insufficient role, or insufficient
|
|
12
|
+
# permission all deny authority.
|
|
13
|
+
class OrganizationsAuthority
|
|
14
|
+
SOURCE = "organizations.membership"
|
|
15
|
+
ADAPTER_VERSION = "1"
|
|
16
|
+
|
|
17
|
+
def verify(actor:, represented_party:, authority_rule:, tenant:,
|
|
18
|
+
authentication_context:)
|
|
19
|
+
return denied unless organizations_available?
|
|
20
|
+
return denied unless represented_party.is_a?(::Organizations::Organization)
|
|
21
|
+
return denied if represented_party.respond_to?(:persisted?) && !represented_party.persisted?
|
|
22
|
+
return denied unless actor.respond_to?(:id) && actor.id.present?
|
|
23
|
+
return denied if actor.respond_to?(:persisted?) && !actor.persisted?
|
|
24
|
+
return denied unless represented_party.respond_to?(:memberships)
|
|
25
|
+
return denied if tenant.present? && Reference.tenant(tenant) != Reference.tenant(represented_party)
|
|
26
|
+
|
|
27
|
+
# Capture calls authority adapters inside its transaction. Taking the
|
|
28
|
+
# membership row lock makes a concurrent removal or demotion serialize
|
|
29
|
+
# with the evidence write instead of authorizing from a stale role.
|
|
30
|
+
membership = represented_party.memberships.lock.find_by(user_id: actor.id)
|
|
31
|
+
return denied unless membership
|
|
32
|
+
return denied unless sufficient_role?(membership, authority_rule.minimum_role)
|
|
33
|
+
return denied unless sufficient_permission?(membership, authority_rule.required_permission)
|
|
34
|
+
|
|
35
|
+
AuthorityDecision.new(
|
|
36
|
+
authorized: true,
|
|
37
|
+
source: SOURCE,
|
|
38
|
+
role: membership_role(membership),
|
|
39
|
+
verified_at: Clickwrap.now,
|
|
40
|
+
details: {
|
|
41
|
+
"adapter_version" => ADAPTER_VERSION,
|
|
42
|
+
"membership_reference" => Reference.record(membership),
|
|
43
|
+
"represented_party_reference" => Reference.record(represented_party),
|
|
44
|
+
"minimum_role" => authority_rule.minimum_role,
|
|
45
|
+
"required_permission" => authority_rule.required_permission,
|
|
46
|
+
"required_permission_was_granted" => authority_rule.required_permission.present? || nil,
|
|
47
|
+
"authentication_method" => authentication_context.to_h[:method]&.to_s
|
|
48
|
+
}.compact
|
|
49
|
+
)
|
|
50
|
+
rescue NoMethodError
|
|
51
|
+
denied
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def organizations_available?
|
|
57
|
+
defined?(::Organizations::Membership) && defined?(::Organizations::Roles)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def membership_role(membership)
|
|
61
|
+
membership.respond_to?(:role_sym) ? membership.role_sym.to_s : membership.role.to_s
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sufficient_role?(membership, minimum_role)
|
|
65
|
+
return true if minimum_role.blank?
|
|
66
|
+
|
|
67
|
+
membership.respond_to?(:is_at_least?) && membership.is_at_least?(minimum_role)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sufficient_permission?(membership, required_permission)
|
|
71
|
+
return true if required_permission.blank?
|
|
72
|
+
|
|
73
|
+
membership.respond_to?(:has_permission_to?) && membership.has_permission_to?(required_permission)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def denied
|
|
77
|
+
AuthorityDecision.new(authorized: false, source: SOURCE)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Integrity
|
|
5
|
+
# The adapter contract for publishing exact event-chain snapshots somewhere
|
|
6
|
+
# outside the primary database, plus a reference base implementation that
|
|
7
|
+
# publishes nowhere and says so. Configuration itself defaults to nil.
|
|
8
|
+
#
|
|
9
|
+
# config.anchor_event_history_with = MyIndependentAnchor.new
|
|
10
|
+
#
|
|
11
|
+
# WHY THIS SEAM EXISTS. A chain makes a rewrite of history detectable for as
|
|
12
|
+
# long as a checkpoint remains trustworthy, and the ordinary checkpoint lives
|
|
13
|
+
# in the same database as the events. Whoever can rewrite one can usually
|
|
14
|
+
# rewrite the other. Publishing an exact snapshot somewhere the application cannot quietly edit —
|
|
15
|
+
# an append-only object store with a retention lock, a separate account, a
|
|
16
|
+
# notary, a transparency log, a printout in a safe — narrows that gap.
|
|
17
|
+
#
|
|
18
|
+
# WHAT AN ANCHOR CLAIMS. Exactly what the place it published to can support,
|
|
19
|
+
# and not one word more. Clickwrap records the reference and the receipt an
|
|
20
|
+
# adapter returns; it never upgrades either into a guarantee the anchoring
|
|
21
|
+
# service did not make. An anchor does not make evidence impossible to
|
|
22
|
+
# alter, does not establish identity, and does not supply time — a storage
|
|
23
|
+
# service writing "received at 14:02" is telling you when it received bytes,
|
|
24
|
+
# which is a different claim from a timestamp authority's, and different
|
|
25
|
+
# again from proof that something happened at 14:02.
|
|
26
|
+
#
|
|
27
|
+
# WRITING ONE. Implement `#publish(chain_snapshot)` and
|
|
28
|
+
# `#verify(publication, chain_snapshot)`,
|
|
29
|
+
# and report honestly from `#capabilities`. Subclassing this class is the
|
|
30
|
+
# easy path: `Configuration#anchor_event_history_with=` checks that the
|
|
31
|
+
# object responds to `#anchor`, and the base class provides that name as the
|
|
32
|
+
# entry point to your `#publish`. An adapter written from scratch must
|
|
33
|
+
# respond to `#anchor` as well.
|
|
34
|
+
#
|
|
35
|
+
# Everything here is optional. Configuration defaults to nil; assigning an
|
|
36
|
+
# instance of this base class is an explicit unavailable/no-publication
|
|
37
|
+
# adapter for testing or capability reporting.
|
|
38
|
+
class Anchor
|
|
39
|
+
# What an adapter returns from `#publish`. `anchored: false` is a normal
|
|
40
|
+
# outcome, not an error — a no-op adapter, a provider outage, or a queue
|
|
41
|
+
# that has not drained yet all mean "not anchored", and the honest record
|
|
42
|
+
# says so rather than leaving a caller to infer it.
|
|
43
|
+
Publication = Data.define(:anchored, :reference, :published_at, :provider_name, :detail) do
|
|
44
|
+
def initialize(anchored: false, reference: nil, published_at: nil, provider_name: nil, detail: nil)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
"anchored" => anchored,
|
|
51
|
+
"reference" => reference,
|
|
52
|
+
"published_at" => published_at && Receipt.format_time(published_at),
|
|
53
|
+
"provider_name" => provider_name,
|
|
54
|
+
"detail" => detail
|
|
55
|
+
}.compact
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `verified: false` with a `detail` is likewise ordinary. "We could not
|
|
60
|
+
# check" and "we checked and it does not match" are different answers, and
|
|
61
|
+
# `checked` keeps them apart.
|
|
62
|
+
Verification = Data.define(:checked, :verified, :reference, :provider_name, :detail) do
|
|
63
|
+
def initialize(checked: false, verified: false, reference: nil, provider_name: nil, detail: nil)
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
{
|
|
69
|
+
"checked" => checked,
|
|
70
|
+
"verified" => verified,
|
|
71
|
+
"reference" => reference,
|
|
72
|
+
"provider_name" => provider_name,
|
|
73
|
+
"detail" => detail
|
|
74
|
+
}.compact
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Publishes this exact chain snapshot outside the primary database. Called after the
|
|
79
|
+
# events it covers have committed, never inside their transaction: an
|
|
80
|
+
# anchoring service cannot join a database transaction, and pretending
|
|
81
|
+
# otherwise is how a network timeout becomes a rolled-back capture.
|
|
82
|
+
def publish(_chain_head)
|
|
83
|
+
Publication.new(
|
|
84
|
+
anchored: false,
|
|
85
|
+
provider_name: provider_name,
|
|
86
|
+
detail: "This anchor adapter publishes nowhere, so this chain snapshot was not published outside " \
|
|
87
|
+
"the primary database."
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The name `Configuration#anchor_event_history_with=` checks for. Kept as
|
|
92
|
+
# a thin alias so the contract can read as `publish`/`verify` while the
|
|
93
|
+
# configuration keeps the verb it already documents.
|
|
94
|
+
def anchor(chain_head) = publish(chain_head)
|
|
95
|
+
|
|
96
|
+
# Re-reads what was published and compares it with the exact snapshot passed
|
|
97
|
+
# for this event. This is the half that does the work: publishing bytes nobody ever
|
|
98
|
+
# checks establishes nothing.
|
|
99
|
+
def verify(_publication, _chain_head)
|
|
100
|
+
Verification.new(
|
|
101
|
+
checked: false,
|
|
102
|
+
verified: false,
|
|
103
|
+
provider_name: provider_name,
|
|
104
|
+
detail: "This anchor adapter publishes nowhere, so there is no outside publication to compare " \
|
|
105
|
+
"with this chain snapshot."
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What this adapter actually supplies, in the words of the thing that
|
|
110
|
+
# supplies it. `clickwrap:doctor` and the receipt's integrity fragment read
|
|
111
|
+
# this rather than assuming a configured adapter means a stronger claim.
|
|
112
|
+
def capabilities
|
|
113
|
+
{
|
|
114
|
+
"name" => provider_name,
|
|
115
|
+
"available" => available?,
|
|
116
|
+
"publishes_outside_primary_database" => false,
|
|
117
|
+
"independently_verifiable" => false,
|
|
118
|
+
"supplies" => "Nothing. This is the default placeholder that reports the absence of an " \
|
|
119
|
+
"anchor for explicit adapter-contract tests. Configuration normally remains nil."
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def available? = false
|
|
124
|
+
|
|
125
|
+
def provider_name = "no_anchor"
|
|
126
|
+
|
|
127
|
+
def to_s = "#{self.class.name} (#{provider_name})"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|