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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Which optional tables this installation needs, and whether it has them.
|
|
5
|
+
#
|
|
6
|
+
# Seven of the seventeen tables this gem knows about cannot receive a row
|
|
7
|
+
# until a matching configuration is turned on, and every one of those is off
|
|
8
|
+
# by default. `clickwrap:install` therefore emits only the reachable ones, and
|
|
9
|
+
# each capability comes with its own generator flag.
|
|
10
|
+
#
|
|
11
|
+
# That trade has exactly one failure mode: a host turns the capability on and
|
|
12
|
+
# forgets the migration. It must not be discovered by a NoMethodError inside a
|
|
13
|
+
# capture at 3am, so it is discovered at boot, reported by the doctor, and
|
|
14
|
+
# refused at the entry points nothing else covers — always with the exact
|
|
15
|
+
# command that fixes it.
|
|
16
|
+
module SchemaRequirements
|
|
17
|
+
Feature = Data.define(:key, :tables, :flag, :because) do
|
|
18
|
+
def generator_command = "bin/rails generate clickwrap:install #{flag}"
|
|
19
|
+
|
|
20
|
+
def explanation
|
|
21
|
+
"#{because} That needs #{"the" if tables.length == 1}" \
|
|
22
|
+
"#{tables.length == 1 ? " #{tables.first} table" : " tables #{tables.join(" and ")}"}, " \
|
|
23
|
+
"which this database does not have. Add #{tables.length == 1 ? "it" : "them"}:\n\n " \
|
|
24
|
+
"#{generator_command}\n bin/rails db:migrate"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
FEATURES = [
|
|
29
|
+
Feature.new(
|
|
30
|
+
key: :persisted_presentations,
|
|
31
|
+
tables: %w[clickwrap_presentations].freeze,
|
|
32
|
+
flag: "--with-persisted-presentations",
|
|
33
|
+
because: "A policy declares `persist_presentations_before_submission_for`, so Clickwrap " \
|
|
34
|
+
"is expected to keep the presentation it offered before the person submitted."
|
|
35
|
+
),
|
|
36
|
+
Feature.new(
|
|
37
|
+
key: :request_evidence,
|
|
38
|
+
tables: %w[clickwrap_request_evidence].freeze,
|
|
39
|
+
flag: "--with-request-evidence",
|
|
40
|
+
because: "This installation records request evidence — an IP address, a browser " \
|
|
41
|
+
"user-agent, or provider-estimated IP geolocation."
|
|
42
|
+
),
|
|
43
|
+
Feature.new(
|
|
44
|
+
key: :integrity,
|
|
45
|
+
tables: %w[clickwrap_chain_heads clickwrap_integrity_attestations].freeze,
|
|
46
|
+
flag: "--with-integrity",
|
|
47
|
+
because: "This installation configures event chaining, external anchoring, or " \
|
|
48
|
+
"third-party timestamping."
|
|
49
|
+
),
|
|
50
|
+
Feature.new(
|
|
51
|
+
key: :retention_ops,
|
|
52
|
+
tables: %w[clickwrap_legal_holds clickwrap_disposition_plans].freeze,
|
|
53
|
+
flag: "--with-retention-ops",
|
|
54
|
+
because: "Legal holds and reviewed disposition are the operator tooling for deleting " \
|
|
55
|
+
"evidence on schedule and pausing that schedule."
|
|
56
|
+
),
|
|
57
|
+
Feature.new(
|
|
58
|
+
key: :external_actions,
|
|
59
|
+
tables: %w[clickwrap_external_actions].freeze,
|
|
60
|
+
flag: "--with-external-actions",
|
|
61
|
+
because: "`Clickwrap.authorize_external_action!` commits a pending outbox row in the " \
|
|
62
|
+
"same transaction as the evidence that authorizes it."
|
|
63
|
+
)
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
FEATURES_BY_KEY = FEATURES.index_by(&:key).freeze
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
def feature!(key) = FEATURES_BY_KEY.fetch(key)
|
|
70
|
+
|
|
71
|
+
# Features whose tables are absent, among the ones the CONFIGURATION says
|
|
72
|
+
# this installation uses. Retention operations and external actions are
|
|
73
|
+
# not configured — they are called — so they are checked at their entry
|
|
74
|
+
# points instead, through `require!`.
|
|
75
|
+
#
|
|
76
|
+
# Empty while migrations are pending, and that is the whole point: `rails
|
|
77
|
+
# db:migrate` boots the application before it runs the migration that
|
|
78
|
+
# would satisfy this check, so a boot-time raise here would make the fix
|
|
79
|
+
# unrunnable. An installation mid-migration is not a misconfigured one.
|
|
80
|
+
def missing_for_configuration
|
|
81
|
+
return [] unless schema_is_settled?
|
|
82
|
+
|
|
83
|
+
%i[persisted_presentations request_evidence integrity]
|
|
84
|
+
.map { |key| feature!(key) }
|
|
85
|
+
.select { |feature| configured?(feature.key) && installed?(feature) == false }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# False when migrations are pending, when Clickwrap's own core tables are
|
|
89
|
+
# not there yet, and when the question cannot be answered at all. Each of
|
|
90
|
+
# those is an installation part-way through being set up, and the fix for
|
|
91
|
+
# a missing table is a migration — so refusing to boot before it can run
|
|
92
|
+
# would make the fix unreachable.
|
|
93
|
+
def schema_is_settled?
|
|
94
|
+
return false if table_presence("clickwrap_events") != true
|
|
95
|
+
|
|
96
|
+
!pending_migrations?
|
|
97
|
+
rescue StandardError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `migration_context.migrations_paths` is relative to the application
|
|
102
|
+
# root, and nothing guarantees the process is running from there — a rake
|
|
103
|
+
# task, a test suite, and a console all disagree. Resolving against
|
|
104
|
+
# `Rails.root` is what makes the answer the same from all three.
|
|
105
|
+
def pending_migrations?
|
|
106
|
+
context = ::ActiveRecord::Base.connection_pool.migration_context
|
|
107
|
+
root = (::Rails.root if defined?(::Rails) && ::Rails.respond_to?(:root))
|
|
108
|
+
|
|
109
|
+
paths = Array(context.migrations_paths).map do |path|
|
|
110
|
+
root && !Pathname.new(path).absolute? ? root.join(path).to_s : path
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
::ActiveRecord::MigrationContext.new(paths).needs_migration?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# "Is it safe to query this capability's tables?" — false only when the
|
|
117
|
+
# answer is a definite no. An unanswerable question is not a no: code that
|
|
118
|
+
# skipped work because a connection was not up yet would skip it silently.
|
|
119
|
+
def available?(key)
|
|
120
|
+
installed?(feature!(key)) != false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Raised at the entry point of a capability nothing in the configuration
|
|
124
|
+
# announces. `installed?` returning nil means the question could not be
|
|
125
|
+
# asked (no database yet), and an unanswerable question is not a refusal.
|
|
126
|
+
def require!(key)
|
|
127
|
+
feature = feature!(key)
|
|
128
|
+
return if installed?(feature) != false
|
|
129
|
+
|
|
130
|
+
raise ConfigurationError, feature.explanation
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# true, false, or nil when there is no database to ask. Memoized per
|
|
134
|
+
# feature and cleared with the rest of the global state, because asking
|
|
135
|
+
# the connection on every capture would be a query per event.
|
|
136
|
+
def installed?(feature)
|
|
137
|
+
cache = (@installed ||= {})
|
|
138
|
+
return cache[feature.key] if cache.key?(feature.key)
|
|
139
|
+
|
|
140
|
+
answers = feature.tables.map { |table| table_presence(table) }
|
|
141
|
+
# One unanswerable table makes the whole answer unknown. Folding nil
|
|
142
|
+
# into false here would report "not installed" for every application
|
|
143
|
+
# whose connection was not up yet, which is most of them at boot.
|
|
144
|
+
cache[feature.key] = answers.any?(&:nil?) ? nil : answers.all?
|
|
145
|
+
rescue StandardError
|
|
146
|
+
cache[feature.key] = nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reset!
|
|
150
|
+
@installed = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Three answers, not two: true, false, and nil for "there is no live
|
|
156
|
+
# connection to ask". Deliberately not a predicate, because a predicate
|
|
157
|
+
# that can answer nil is a predicate somebody will read as false.
|
|
158
|
+
def table_presence(table)
|
|
159
|
+
connection = ::ActiveRecord::Base.connection
|
|
160
|
+
return nil unless connection.active?
|
|
161
|
+
|
|
162
|
+
connection.data_source_exists?(table)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def configured?(key)
|
|
166
|
+
case key
|
|
167
|
+
when :persisted_presentations then any_policy_persists_presentations?
|
|
168
|
+
when :request_evidence then records_request_evidence?
|
|
169
|
+
when :integrity then configures_integrity?
|
|
170
|
+
else false
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def any_policy_persists_presentations?
|
|
175
|
+
Clickwrap.policies.any? { |policy| policy.persist_presentations_for.present? }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def records_request_evidence?
|
|
179
|
+
config = Clickwrap.config
|
|
180
|
+
return true if config.record_ip_address_by_default || config.record_browser_user_agent_by_default
|
|
181
|
+
return true if Vocabulary::IP_GEOLOCATION_DATA_FIELDS.any? do |field|
|
|
182
|
+
config.public_send(:"record_ip_geolocation_#{field}_by_default")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Clickwrap.policies.any? { |policy| policy.request_evidence.records_anything? }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def configures_integrity?
|
|
189
|
+
config = Clickwrap.config
|
|
190
|
+
|
|
191
|
+
[config.chain_event_history_with, config.anchor_event_history_with,
|
|
192
|
+
config.timestamp_receipts_with].any?(&:present?)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Services
|
|
5
|
+
# `Clickwrap.authorize_external_action!` — the outbox.
|
|
6
|
+
#
|
|
7
|
+
# ===========================================================================
|
|
8
|
+
# READ THIS BEFORE CHANGING ANYTHING HERE.
|
|
9
|
+
#
|
|
10
|
+
# This is a DISTRIBUTED RELIABILITY PROTOCOL. It is NOT a cross-system ACID
|
|
11
|
+
# transaction, and no amount of care in this file could make it one.
|
|
12
|
+
#
|
|
13
|
+
# Stripe, an identity service, a timestamp authority, a remote signature
|
|
14
|
+
# provider: none of them can enlist in your database transaction. There is
|
|
15
|
+
# no two-phase commit here, no compensating rollback that reaches into
|
|
16
|
+
# someone else's ledger, and no moment at which "the evidence committed" and
|
|
17
|
+
# "the provider acted" are known to be true together. Clickwrap never claims
|
|
18
|
+
# atomicity across two independent systems, and the receipt this produces
|
|
19
|
+
# does not claim it either.
|
|
20
|
+
#
|
|
21
|
+
# What this protocol actually gives you is narrower and achievable:
|
|
22
|
+
#
|
|
23
|
+
# 1. ONE local transaction commits the evidence event and a `pending`
|
|
24
|
+
# outbox row carrying a server-generated idempotency key.
|
|
25
|
+
# 2. The provider is called OUTSIDE that transaction, by the host, with
|
|
26
|
+
# that key — so a retry reaches the provider as the same request rather
|
|
27
|
+
# than as a second one.
|
|
28
|
+
# 3. The outcome is appended back idempotently through
|
|
29
|
+
# `record_provider_success_and_consume!`, `record_provider_failure!`,
|
|
30
|
+
# or `record_provider_outcome_unknown!`.
|
|
31
|
+
# 4. `unknown` stays `unknown` until someone or something resolves it.
|
|
32
|
+
#
|
|
33
|
+
# Step 4 is the part people delete first and regret longest. A timeout is
|
|
34
|
+
# not a failure — it is an absence of information. Writing "failed" because
|
|
35
|
+
# the socket closed is how a second debit happens; writing "succeeded"
|
|
36
|
+
# because the retry returned 200 is how a fictional one does. The
|
|
37
|
+
# reconciliation task exists precisely so the ambiguous case can be settled
|
|
38
|
+
# later with better information than we have at the moment it occurs.
|
|
39
|
+
# ===========================================================================
|
|
40
|
+
class AuthorizeExternalAction
|
|
41
|
+
# The key handed to the provider. It is derived from the committed event
|
|
42
|
+
# id, which means one evidence event maps to exactly one external action
|
|
43
|
+
# forever: a retried capture that replays onto the same event reuses this
|
|
44
|
+
# key rather than minting a second one, and a provider that honors
|
|
45
|
+
# idempotency keys will therefore not act twice.
|
|
46
|
+
#
|
|
47
|
+
# It is server-generated. A client-supplied idempotency key would let a
|
|
48
|
+
# browser decide whether a second debit is a duplicate — which is exactly
|
|
49
|
+
# the decision the server exists to make.
|
|
50
|
+
def self.idempotency_key_for(policy_key:, event_id:)
|
|
51
|
+
"clickwrap-#{policy_key}-#{event_id}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(policy:, provider_name: nil,
|
|
55
|
+
after_pending_action_is_saved_inside_transaction: nil,
|
|
56
|
+
**capture_options)
|
|
57
|
+
@policy = policy
|
|
58
|
+
@provider_name = provider_name&.to_s
|
|
59
|
+
@after_pending_action_is_saved_inside_transaction =
|
|
60
|
+
after_pending_action_is_saved_inside_transaction
|
|
61
|
+
@capture_options = capture_options
|
|
62
|
+
|
|
63
|
+
return if @after_pending_action_is_saved_inside_transaction.nil? ||
|
|
64
|
+
@after_pending_action_is_saved_inside_transaction.respond_to?(:call)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"after_pending_action_is_saved_inside_transaction must be callable. It receives " \
|
|
68
|
+
"pending_action: and pending_receipt: as keyword arguments."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :policy, :provider_name, :capture_options,
|
|
72
|
+
:after_pending_action_is_saved_inside_transaction
|
|
73
|
+
|
|
74
|
+
# Captures the evidence and commits the pending outbox row in ONE local
|
|
75
|
+
# transaction, then returns the ExternalAction so the caller can hand its
|
|
76
|
+
# id and idempotency key to a job.
|
|
77
|
+
#
|
|
78
|
+
# The provider call belongs after this method returns, never inside it. A
|
|
79
|
+
# transaction held open across someone else's network is a transaction
|
|
80
|
+
# holding locks on evidence rows while waiting for a stranger's DNS.
|
|
81
|
+
def call
|
|
82
|
+
SchemaRequirements.require!(:external_actions)
|
|
83
|
+
|
|
84
|
+
action = nil
|
|
85
|
+
|
|
86
|
+
receipt = Capture.new(
|
|
87
|
+
policy: policy,
|
|
88
|
+
**capture_options,
|
|
89
|
+
consume_one_time_authorizations: false,
|
|
90
|
+
record_protected_outcome: false
|
|
91
|
+
).capture_and! do |pending|
|
|
92
|
+
action = create_pending_action!(pending)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A replayed capture returns the original receipt without re-running the
|
|
96
|
+
# block, so the outbox row for that event already exists. Finding it is
|
|
97
|
+
# the correct answer: the same authorization, with the same key, not a
|
|
98
|
+
# second one that could produce a second provider call.
|
|
99
|
+
action ||= ExternalAction.find_by(event_id: receipt.event_id)
|
|
100
|
+
|
|
101
|
+
unless action
|
|
102
|
+
raise ExternalActionError,
|
|
103
|
+
"The evidence for #{policy.key} committed as event #{receipt.event_id}, but no " \
|
|
104
|
+
"pending external action was found for it. Do not call the provider: without a " \
|
|
105
|
+
"committed outbox row there is nothing to resolve the outcome against."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
action.reload
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def create_pending_action!(pending)
|
|
114
|
+
now = Clickwrap.now
|
|
115
|
+
|
|
116
|
+
action = ExternalAction.create!(
|
|
117
|
+
event_id: pending.event_id,
|
|
118
|
+
policy_key: policy.key,
|
|
119
|
+
idempotency_key: self.class.idempotency_key_for(
|
|
120
|
+
policy_key: policy.key, event_id: pending.event_id
|
|
121
|
+
),
|
|
122
|
+
provider_name: provider_name,
|
|
123
|
+
state: "pending",
|
|
124
|
+
attempt_count: 0,
|
|
125
|
+
requested_at: now,
|
|
126
|
+
created_at: now,
|
|
127
|
+
updated_at: now
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Some hosts need a temporary compatibility projection while they move
|
|
131
|
+
# an established audit ledger onto Clickwrap. This hook is deliberately
|
|
132
|
+
# named for its exact boundary: it runs only for the initial write,
|
|
133
|
+
# after the pending outbox row is saved, inside the SAME LOCAL database
|
|
134
|
+
# transaction as the event and action. A replay returns the committed
|
|
135
|
+
# action and never runs it again.
|
|
136
|
+
#
|
|
137
|
+
# Network/provider work never belongs here. A callback failure rolls all
|
|
138
|
+
# three local writes back, leaving no evidence that claims an external
|
|
139
|
+
# action was authorized when its required domain projection was not.
|
|
140
|
+
after_pending_action_is_saved_inside_transaction&.call(
|
|
141
|
+
pending_action: action,
|
|
142
|
+
pending_receipt: pending
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
action
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Services
|
|
5
|
+
# Loads the host's document, policy, and retention declarations.
|
|
6
|
+
#
|
|
7
|
+
# They live in ordinary Ruby files — `config/clickwrap.rb` and
|
|
8
|
+
# `config/clickwrap/*.rb` by default — because that makes them reviewable in
|
|
9
|
+
# a pull request and deployable like the code they are. The engine re-reads
|
|
10
|
+
# them through `to_prepare`, so a development reload picks up an edit the
|
|
11
|
+
# same way it picks up a model change.
|
|
12
|
+
#
|
|
13
|
+
# Compilation happens here, at boot, and a mistake raises. A policy that
|
|
14
|
+
# references a document nobody declared, a consent statement with no
|
|
15
|
+
# withdrawal route, a one-time authorization with no expiry — each of those
|
|
16
|
+
# is a bug that would otherwise be discovered by the first person who tried
|
|
17
|
+
# to sign up, and each one gets a sentence explaining what to do about it.
|
|
18
|
+
class LoadPolicies
|
|
19
|
+
def initialize(paths: nil, root: nil)
|
|
20
|
+
@paths = paths || Clickwrap.config.policy_paths
|
|
21
|
+
@root = root || default_root
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :paths, :root
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
files = resolve_files
|
|
28
|
+
return [] if files.empty?
|
|
29
|
+
|
|
30
|
+
reset_registries!
|
|
31
|
+
files.each { |file| load_file(file) }
|
|
32
|
+
ValidatePolicyReferences.call
|
|
33
|
+
files
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve_files
|
|
37
|
+
return [] if root.nil?
|
|
38
|
+
|
|
39
|
+
paths.flat_map { |pattern| Dir[File.join(root, pattern)] }.uniq.sort
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def default_root
|
|
45
|
+
defined?(::Rails) && ::Rails.root ? ::Rails.root.to_s : nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A reload replaces the declarations rather than adding to them, so a
|
|
49
|
+
# policy deleted from the file really is gone. Persisted evidence is
|
|
50
|
+
# untouched: an event references the frozen revision it was captured
|
|
51
|
+
# under, not whatever is in the registry today.
|
|
52
|
+
def reset_registries!
|
|
53
|
+
Clickwrap.documents.clear
|
|
54
|
+
Clickwrap.policies.clear
|
|
55
|
+
Clickwrap.retention_classes.clear
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_file(file)
|
|
59
|
+
load file
|
|
60
|
+
rescue DefinitionError, ConfigurationError => error
|
|
61
|
+
raise error.class, "#{error.message}\n\n (while loading #{relative(file)})", error.backtrace
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def relative(file)
|
|
65
|
+
root ? file.delete_prefix("#{root}/") : file
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Services
|
|
5
|
+
# `bin/rails clickwrap:publish` — reads the declared document bytes once,
|
|
6
|
+
# digests them, and freezes a database snapshot.
|
|
7
|
+
#
|
|
8
|
+
# After this runs, the snapshot is the evidence and the file on disk is only
|
|
9
|
+
# where it came from. Editing the file changes nothing about what was
|
|
10
|
+
# already published, which is exactly the property a receipt from three
|
|
11
|
+
# years ago depends on.
|
|
12
|
+
#
|
|
13
|
+
# The task is idempotent: re-publishing identical bytes under the same label
|
|
14
|
+
# is a no-op. Re-publishing *different* bytes under the same label is
|
|
15
|
+
# refused, because a version label that can mean two different documents is
|
|
16
|
+
# not a version label.
|
|
17
|
+
class PublishDocuments
|
|
18
|
+
Outcome = Data.define(:definition, :status, :version, :message) do
|
|
19
|
+
def published? = status == :published
|
|
20
|
+
def unchanged? = status == :unchanged
|
|
21
|
+
def planned? = status == :planned
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(dry_run: false, definitions: nil)
|
|
25
|
+
@dry_run = dry_run
|
|
26
|
+
@definitions = definitions || Clickwrap.documents.values
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :dry_run, :definitions
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
definitions.map { |definition| publish(definition) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
alias plan call
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def publish(definition)
|
|
40
|
+
bytes = definition.read_bytes
|
|
41
|
+
digest = Digest.digest(bytes, algorithm: digest_algorithm)
|
|
42
|
+
existing = find_existing(definition)
|
|
43
|
+
|
|
44
|
+
if existing
|
|
45
|
+
return conflict(definition, existing, digest) unless matches?(existing, digest)
|
|
46
|
+
|
|
47
|
+
return Outcome.new(definition: definition, status: :unchanged, version: existing,
|
|
48
|
+
message: "Already published with identical bytes.")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if dry_run
|
|
52
|
+
return Outcome.new(definition: definition, status: :planned, version: nil,
|
|
53
|
+
message: "Would publish #{bytes.bytesize} bytes (#{digest}).")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Outcome.new(definition: definition, status: :published,
|
|
57
|
+
version: create_version(definition, bytes, digest),
|
|
58
|
+
message: "Published #{bytes.bytesize} bytes (#{digest}).")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_existing(definition)
|
|
62
|
+
document = ::Clickwrap::Document.find_by(
|
|
63
|
+
document_key: definition.key, tenant_key: definition.tenant_key
|
|
64
|
+
)
|
|
65
|
+
return nil unless document
|
|
66
|
+
|
|
67
|
+
document.versions.find_by(version_label: definition.version_label, locale: definition.locale)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def matches?(existing, digest)
|
|
71
|
+
Digest.secure_compare?(existing.content_digest, digest)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The refusal a version label exists to make possible.
|
|
75
|
+
def conflict(definition, existing, digest)
|
|
76
|
+
raise DocumentVersionConflictError,
|
|
77
|
+
"Document #{definition} was already published with different bytes.\n " \
|
|
78
|
+
"published: #{existing.content_digest}\n " \
|
|
79
|
+
"on disk: #{digest}\n\n" \
|
|
80
|
+
"Reusing a version label for different content would silently change what every " \
|
|
81
|
+
"receipt that cites this version says the server offered. Publish the new text " \
|
|
82
|
+
"under a new version label instead."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def create_version(definition, bytes, digest)
|
|
86
|
+
rendered = render(definition, bytes)
|
|
87
|
+
backend = storage_backend_for(definition)
|
|
88
|
+
|
|
89
|
+
# Active Storage uploads may perform object-store network I/O. Finish
|
|
90
|
+
# that work before opening the evidence transaction; otherwise a slow
|
|
91
|
+
# S3 request holds database locks for its entire duration. If the
|
|
92
|
+
# subsequent database write fails, purge the just-created blob so a
|
|
93
|
+
# failed publication does not quietly accumulate unattached evidence
|
|
94
|
+
# objects.
|
|
95
|
+
uploaded_blob = backend == "active_storage" ? store_in_active_storage(definition, bytes) : nil
|
|
96
|
+
|
|
97
|
+
::ActiveRecord::Base.transaction do
|
|
98
|
+
document = ::Clickwrap::Document.find_or_create_by!(
|
|
99
|
+
document_key: definition.key, tenant_key: definition.tenant_key
|
|
100
|
+
) { |record| record.created_at = Clickwrap.now }
|
|
101
|
+
|
|
102
|
+
document.versions.create!(
|
|
103
|
+
version_label: definition.version_label,
|
|
104
|
+
locale: definition.locale,
|
|
105
|
+
media_type: definition.media_type,
|
|
106
|
+
content: backend == "database" ? text_for_database(definition, bytes) : nil,
|
|
107
|
+
storage_locator: uploaded_blob&.signed_id,
|
|
108
|
+
content_byte_size: bytes.bytesize,
|
|
109
|
+
content_digest_algorithm: digest_algorithm,
|
|
110
|
+
content_digest: digest,
|
|
111
|
+
rendered_content: rendered&.fetch(:bytes, nil),
|
|
112
|
+
rendered_media_type: rendered&.fetch(:media_type, nil),
|
|
113
|
+
rendered_content_digest: rendered && Digest.digest(rendered[:bytes], algorithm: digest_algorithm),
|
|
114
|
+
renderer_name: rendered&.fetch(:renderer_name, nil),
|
|
115
|
+
renderer_version: rendered&.fetch(:renderer_version, nil),
|
|
116
|
+
sanitizer_name: rendered&.fetch(:sanitizer_name, nil),
|
|
117
|
+
sanitizer_version: rendered&.fetch(:sanitizer_version, nil),
|
|
118
|
+
storage_backend: backend,
|
|
119
|
+
source_reference: definition.source_reference,
|
|
120
|
+
# A version with no declared schedule is effective as soon as it is
|
|
121
|
+
# published. Storing that explicitly rather than leaving NULL keeps
|
|
122
|
+
# "which version is current" a deterministic question: PostgreSQL
|
|
123
|
+
# sorts NULLs first in a descending order and SQLite sorts them
|
|
124
|
+
# last, so a nullable column here would mean two databases
|
|
125
|
+
# disagreeing about which document the server offered.
|
|
126
|
+
effective_at: definition.effective_at || Clickwrap.now,
|
|
127
|
+
published_at: Clickwrap.now,
|
|
128
|
+
created_at: Clickwrap.now
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError => error
|
|
132
|
+
purge_failed_upload(uploaded_blob, original_error: error)
|
|
133
|
+
raise
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# When a source format is transformed for display, the rendered bytes are
|
|
137
|
+
# stored and digested alongside the source. That keeps "this Markdown file
|
|
138
|
+
# existed" and "this rendered representation was offered" as two separate
|
|
139
|
+
# claims instead of letting one borrow the other's credibility.
|
|
140
|
+
#
|
|
141
|
+
# A custom renderer must return the exact bytes it offered. It is given
|
|
142
|
+
# no opportunity to render one thing and report another.
|
|
143
|
+
def render(definition, bytes)
|
|
144
|
+
renderer = definition.renderer || Clickwrap.config.document_renderer
|
|
145
|
+
return nil unless renderer
|
|
146
|
+
|
|
147
|
+
result = renderer.call(bytes, definition)
|
|
148
|
+
return nil if result.nil?
|
|
149
|
+
|
|
150
|
+
result = { bytes: result } if result.is_a?(String)
|
|
151
|
+
result = result.symbolize_keys
|
|
152
|
+
|
|
153
|
+
unless result[:bytes].is_a?(String)
|
|
154
|
+
raise ConfigurationError,
|
|
155
|
+
"A document renderer must return the exact rendered bytes as a String (or a Hash " \
|
|
156
|
+
"with a :bytes key). It returned #{result[:bytes].class}."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The safety net for renderers that made NO sanitation decision: a bare
|
|
160
|
+
# String or a Hash without sanitizer provenance gets the safe list. A
|
|
161
|
+
# renderer that DECLARED its decision — including the deliberate
|
|
162
|
+
# "none" of a byte-parity host renderer — owns it; re-sanitizing here
|
|
163
|
+
# would reparse the bytes and silently overwrite the provenance,
|
|
164
|
+
# making the stored record claim a different pipeline than the one
|
|
165
|
+
# that ran.
|
|
166
|
+
if result.fetch(:media_type, "").to_s.start_with?("text/html") &&
|
|
167
|
+
result[:sanitizer_name].to_s.empty?
|
|
168
|
+
result[:bytes] = DocumentRenderer.sanitize_html(result[:bytes])
|
|
169
|
+
result[:sanitizer_name] = DocumentRenderer::SANITIZER_NAME
|
|
170
|
+
result[:sanitizer_version] = DocumentRenderer::SANITIZER_VERSION
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
result
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Documents are read as binary, because the digest has to cover the exact
|
|
177
|
+
# bytes on disk and nothing about them may be normalized on the way in.
|
|
178
|
+
# A text column, though, holds text — so the bytes are tagged UTF-8 here,
|
|
179
|
+
# and a document that is not valid UTF-8 is refused rather than mangled.
|
|
180
|
+
#
|
|
181
|
+
# An em dash is enough to hit this: the bytes come back as ASCII-8BIT and
|
|
182
|
+
# a database that is told to store them as text will either raise or
|
|
183
|
+
# quietly corrupt them, and a corrupted document is one whose digest will
|
|
184
|
+
# never verify again.
|
|
185
|
+
def text_for_database(definition, bytes)
|
|
186
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
187
|
+
return text if text.valid_encoding?
|
|
188
|
+
|
|
189
|
+
raise DefinitionError,
|
|
190
|
+
"Document #{definition} is not valid UTF-8, so it cannot be stored in a text " \
|
|
191
|
+
"column. Binary documents — a PDF, a scan, an image — need a storage backend " \
|
|
192
|
+
"that keeps bytes as bytes: set `config.store_document_contents_in = " \
|
|
193
|
+
":active_storage`, or supply a resolver that returns them."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Where the bytes come FROM and where they are KEPT are different
|
|
197
|
+
# questions, and a per-document `resolver:` answers the first one only —
|
|
198
|
+
# exactly like `from:` and `content:`. Publishing calls it once, and from
|
|
199
|
+
# then on the frozen snapshot is the evidence.
|
|
200
|
+
#
|
|
201
|
+
# `config.store_document_contents_in = :resolver` is the other question:
|
|
202
|
+
# it says the application will hand back the bytes on every read rather
|
|
203
|
+
# than have Clickwrap keep them. That adapter still has to return
|
|
204
|
+
# immutable bytes that hash to the recorded digest, which is verified on
|
|
205
|
+
# every read.
|
|
206
|
+
def storage_backend_for(_definition)
|
|
207
|
+
Clickwrap.config.store_document_contents_in.to_s
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Larger applications can keep document bodies in content-addressed object
|
|
211
|
+
# storage instead of the database. The contract does not change: whatever
|
|
212
|
+
# the adapter returns must be the exact immutable bytes, and the digest
|
|
213
|
+
# recorded here is verified against them on every read. A locator alone is
|
|
214
|
+
# never a document version.
|
|
215
|
+
def store_in_active_storage(definition, bytes)
|
|
216
|
+
unless defined?(::ActiveStorage)
|
|
217
|
+
raise ConfigurationError,
|
|
218
|
+
"store_document_contents_in is :active_storage, but Active Storage is not loaded " \
|
|
219
|
+
"in this application. Clickwrap does not depend on it; require it, or use " \
|
|
220
|
+
":database."
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
::ActiveStorage::Blob.create_and_upload!(
|
|
224
|
+
io: StringIO.new(bytes),
|
|
225
|
+
filename: "#{definition.key}-#{definition.version_label}-#{definition.locale}",
|
|
226
|
+
content_type: definition.media_type,
|
|
227
|
+
identify: false
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def purge_failed_upload(blob, original_error:)
|
|
232
|
+
return if blob.nil?
|
|
233
|
+
|
|
234
|
+
blob.purge
|
|
235
|
+
rescue StandardError => error
|
|
236
|
+
if defined?(::Rails) && ::Rails.respond_to?(:error)
|
|
237
|
+
::Rails.error.report(
|
|
238
|
+
error,
|
|
239
|
+
handled: true,
|
|
240
|
+
context: {
|
|
241
|
+
clickwrap_operation: "purge_failed_document_upload",
|
|
242
|
+
original_error_class: original_error.class.name
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def digest_algorithm = Clickwrap.config.digest_canonical_receipts_with.to_s
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|