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,650 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Turns a verified submission into an evidence event, and — when the caller
|
|
5
|
+
# asks for it — commits that event in the same database transaction as the
|
|
6
|
+
# action it authorizes.
|
|
7
|
+
#
|
|
8
|
+
# That last part is the whole point of the gem, so it is worth being exact
|
|
9
|
+
# about what is promised.
|
|
10
|
+
#
|
|
11
|
+
# For a same-database protected action, `capture_and!` joins the caller's
|
|
12
|
+
# transaction. If the evidence write fails, the block's work rolls back with
|
|
13
|
+
# it. If the block raises, the event rolls back with it. Neither side can
|
|
14
|
+
# commit alone, and there is no rescue-and-continue path anywhere in here: an
|
|
15
|
+
# account that exists without the evidence that authorized it is precisely the
|
|
16
|
+
# failure this class exists to make impossible.
|
|
17
|
+
#
|
|
18
|
+
# For anything crossing a system boundary — a payment provider, an identity
|
|
19
|
+
# service, a remote signature — none of that applies, and pretending otherwise
|
|
20
|
+
# would be worse than useless. Use `Clickwrap.authorize_external_action!` and
|
|
21
|
+
# its outbox instead. This class will not claim atomicity it cannot deliver.
|
|
22
|
+
class Capture
|
|
23
|
+
# Internal control flow carrying the already-committed event for an
|
|
24
|
+
# idempotent replay. It never escapes Capture's public API.
|
|
25
|
+
class IdempotentReplay < StandardError
|
|
26
|
+
attr_reader :event
|
|
27
|
+
|
|
28
|
+
def initialize(event)
|
|
29
|
+
@event = event
|
|
30
|
+
super()
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
private_constant :IdempotentReplay
|
|
34
|
+
|
|
35
|
+
def initialize(policy:, actor: nil, subject: nil, tenant: nil, http_request: nil,
|
|
36
|
+
submission: nil, answers: nil, locale: nil, capture_channel: nil,
|
|
37
|
+
acting_for: nil, authentication_context: nil, attribution_method: nil,
|
|
38
|
+
idempotency_key: nil, prospective_actor: nil,
|
|
39
|
+
registration_flow_id: nil,
|
|
40
|
+
represented_party_creation_flow_id: nil,
|
|
41
|
+
consume_one_time_authorizations: true,
|
|
42
|
+
record_protected_outcome: true,
|
|
43
|
+
reason: nil, event_type: "capture", root_event_id: nil,
|
|
44
|
+
predecessor_event_id: nil, statement_action_overrides: {})
|
|
45
|
+
@policy = policy
|
|
46
|
+
@actor = actor
|
|
47
|
+
@prospective_actor = prospective_actor
|
|
48
|
+
@subject = subject
|
|
49
|
+
@tenant = tenant
|
|
50
|
+
@http_request = http_request
|
|
51
|
+
@submission = submission
|
|
52
|
+
@explicit_answers = answers
|
|
53
|
+
@locale = locale
|
|
54
|
+
@explicit_capture_channel = capture_channel&.to_s
|
|
55
|
+
@capture_channel = @explicit_capture_channel
|
|
56
|
+
@acting_for = acting_for
|
|
57
|
+
@authentication_context = authentication_context
|
|
58
|
+
@attribution_method = attribution_method
|
|
59
|
+
@explicit_idempotency_key = idempotency_key
|
|
60
|
+
@registration_flow_id = registration_flow_id
|
|
61
|
+
@represented_party_creation_flow_id = represented_party_creation_flow_id
|
|
62
|
+
@creating_represented_party = false
|
|
63
|
+
@consume_one_time_authorizations = consume_one_time_authorizations
|
|
64
|
+
@record_protected_outcome = record_protected_outcome == true
|
|
65
|
+
@reason = reason
|
|
66
|
+
@event_type = event_type.to_s
|
|
67
|
+
@root_event_id = root_event_id
|
|
68
|
+
@predecessor_event_id = predecessor_event_id
|
|
69
|
+
@statement_action_overrides = statement_action_overrides.to_h.transform_keys(&:to_s)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attr_reader :policy, :actor, :subject, :tenant, :http_request, :submission, :capture_channel
|
|
73
|
+
|
|
74
|
+
# Records evidence with no protected action attached.
|
|
75
|
+
def capture!
|
|
76
|
+
perform(protected_action: false) { |_pending| nil }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Records evidence and runs the protected action inside the same
|
|
80
|
+
# transaction. The block receives a read-only PendingReceipt whose stable
|
|
81
|
+
# `event_id` the domain row can reference; export and verification are
|
|
82
|
+
# unavailable on it until commit, because until commit there is nothing to
|
|
83
|
+
# export.
|
|
84
|
+
def capture_and!(&block)
|
|
85
|
+
raise ArgumentError, "capture_and! needs a block containing the protected action" unless block
|
|
86
|
+
|
|
87
|
+
perform(protected_action: true, &block)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Signup. At first render there is no persisted actor, so the presentation
|
|
91
|
+
# bound itself to a short-lived registration flow instead of to a fictional
|
|
92
|
+
# authenticated user. Here the account is created and its stable reference
|
|
93
|
+
# bound to the evidence, both inside one transaction, and the receipt records
|
|
94
|
+
# `account_registration` attribution rather than claiming a session that did
|
|
95
|
+
# not exist.
|
|
96
|
+
def register!(&block)
|
|
97
|
+
raise ArgumentError, "register! needs a block that persists the account" unless block
|
|
98
|
+
|
|
99
|
+
@attribution_method = "account_registration"
|
|
100
|
+
|
|
101
|
+
perform(protected_action: true) do |pending|
|
|
102
|
+
result = block.call(pending)
|
|
103
|
+
|
|
104
|
+
unless @prospective_actor&.persisted?
|
|
105
|
+
raise RegistrationFailed,
|
|
106
|
+
"The registration block did not persist the prospective actor. Use `save!`, or " \
|
|
107
|
+
"raise when validation fails, so Clickwrap can roll the evidence back with it."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
rebind_actor_after_registration!(pending)
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Creates a new record of the type named by `represented_party:` and binds
|
|
116
|
+
# the block's persisted result to the evidence before either can commit.
|
|
117
|
+
# Presentation records honestly
|
|
118
|
+
# that membership authority was not yet verifiable; after the block saves
|
|
119
|
+
# the record and creates its authority relationship, the adapter rereads
|
|
120
|
+
# that relationship inside this same transaction and the finalized event
|
|
121
|
+
# is rebound to the persisted represented party.
|
|
122
|
+
def create_represented_party!(&block)
|
|
123
|
+
unless block
|
|
124
|
+
raise ArgumentError,
|
|
125
|
+
"create_represented_party! needs a block that persists the represented party"
|
|
126
|
+
end
|
|
127
|
+
unless @acting_for.respond_to?(:new_record?) && @acting_for.new_record?
|
|
128
|
+
raise RepresentedPartyCreationFailed,
|
|
129
|
+
"create_represented_party! needs the exact new `represented_party:` record " \
|
|
130
|
+
"that was used to render the presentation."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@prospective_represented_party_class = @acting_for.class
|
|
134
|
+
@creating_represented_party = true
|
|
135
|
+
|
|
136
|
+
perform(protected_action: true) do |pending|
|
|
137
|
+
result = block.call(pending)
|
|
138
|
+
|
|
139
|
+
unless result.respond_to?(:persisted?) && result.persisted?
|
|
140
|
+
raise RepresentedPartyCreationFailed,
|
|
141
|
+
"The represented-party creation block must return the persisted represented party. " \
|
|
142
|
+
"Save it and its authority relationship inside the block, then return that record."
|
|
143
|
+
end
|
|
144
|
+
unless result.instance_of?(@prospective_represented_party_class)
|
|
145
|
+
raise RepresentedPartyCreationFailed,
|
|
146
|
+
"The represented-party creation block returned #{result.class.name}, but the " \
|
|
147
|
+
"presentation was bound to #{@verified_manifest.represented_party_type}. Return a " \
|
|
148
|
+
"persisted record of the presented type."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
@acting_for = result
|
|
152
|
+
|
|
153
|
+
result
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Everything that can be checked without a transaction is checked without
|
|
160
|
+
# one, and everything that must be resolved before the transaction opens is
|
|
161
|
+
# resolved before it opens. A transaction that stays open across a network
|
|
162
|
+
# call to a geolocation provider is a transaction holding locks on evidence
|
|
163
|
+
# rows while waiting for someone else's DNS.
|
|
164
|
+
def perform(protected_action:, &block)
|
|
165
|
+
@protected_action = protected_action
|
|
166
|
+
validate_actor_class!
|
|
167
|
+
@joined_existing_transaction = ::ActiveRecord::Base.connection.transaction_open?
|
|
168
|
+
replay_candidate = find_replay_candidate
|
|
169
|
+
policy.validate_tenant!(tenant) unless replay_candidate
|
|
170
|
+
verified = PresentationVerifier.new(
|
|
171
|
+
policy: policy,
|
|
172
|
+
submission: submission,
|
|
173
|
+
explicit_answers: @explicit_answers,
|
|
174
|
+
actor_reference: actor_reference,
|
|
175
|
+
tenant_key: tenant_key,
|
|
176
|
+
subject_key: subject_key,
|
|
177
|
+
# The protected action may be the very thing that changes the bound
|
|
178
|
+
# subject. An already-committed nonce is verified against its frozen
|
|
179
|
+
# event below; recomputing the pre-action fingerprint first would make
|
|
180
|
+
# a lost-response retry fail precisely because the first attempt worked.
|
|
181
|
+
subject_fingerprint: (subject_fingerprint unless replay_candidate),
|
|
182
|
+
represented_party: @acting_for,
|
|
183
|
+
prospective_actor: @prospective_actor,
|
|
184
|
+
registration_flow_id: @registration_flow_id,
|
|
185
|
+
explicit_capture_channel: @explicit_capture_channel,
|
|
186
|
+
represented_party_creation_flow_id: @represented_party_creation_flow_id,
|
|
187
|
+
creating_represented_party: @creating_represented_party
|
|
188
|
+
).verify!(for_replay: replay_candidate.present?)
|
|
189
|
+
manifest = verified.manifest
|
|
190
|
+
revision = verified.revision
|
|
191
|
+
answers = verified.answers
|
|
192
|
+
@capture_channel = verified.capture_channel
|
|
193
|
+
@frozen_statement_snapshots = verified.statement_snapshots
|
|
194
|
+
@verified_document_versions_by_id = verified.document_versions_by_id
|
|
195
|
+
@verified_manifest = manifest
|
|
196
|
+
|
|
197
|
+
existing = replay_candidate || find_existing_event(idempotency_key_for(manifest))
|
|
198
|
+
return replay(existing, answers, manifest) if existing
|
|
199
|
+
|
|
200
|
+
request_evidence = resolve_request_evidence
|
|
201
|
+
|
|
202
|
+
event = nil
|
|
203
|
+
pending = nil
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
run_in_transaction do
|
|
207
|
+
verify_represented_party_authority! unless @creating_represented_party
|
|
208
|
+
lock_statement_identities!(manifest)
|
|
209
|
+
|
|
210
|
+
event = append_event!(manifest, revision, answers, request_evidence)
|
|
211
|
+
pending = event.track_pending_receipt(
|
|
212
|
+
PendingReceipt.new(event, wait_for_outer_transaction: @joined_existing_transaction)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@protected_action_result = block.call(pending)
|
|
216
|
+
|
|
217
|
+
complete_represented_party_creation!(pending) if @creating_represented_party
|
|
218
|
+
|
|
219
|
+
record_protected_outcome!(event)
|
|
220
|
+
event.finalize_integrity!
|
|
221
|
+
update_projections!(event)
|
|
222
|
+
consume_one_time_authorizations!(event)
|
|
223
|
+
mark_presentation_accepted!(manifest)
|
|
224
|
+
end
|
|
225
|
+
rescue IdempotentReplay => error
|
|
226
|
+
return replay(error.event, answers, manifest)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The after-commit hook is NOT invoked here. When this capture joined a
|
|
230
|
+
# caller's transaction, "here" is still inside it, and a notification sent
|
|
231
|
+
# from inside a transaction that later rolls back announces something that
|
|
232
|
+
# never happened. The hook is registered as an `after_commit` callback on
|
|
233
|
+
# the event instead, so Rails fires it on the real outermost commit.
|
|
234
|
+
pending.committed? ? pending.receipt : pending
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Joins the caller's transaction when there is one. `requires_new: false` is
|
|
238
|
+
# the whole guarantee: a host that already opened a transaction around its
|
|
239
|
+
# domain work gets its evidence committed by the same COMMIT, not by a
|
|
240
|
+
# nested one that could succeed while the outer one rolls back.
|
|
241
|
+
def run_in_transaction(&)
|
|
242
|
+
::ActiveRecord::Base.transaction(requires_new: false, &)
|
|
243
|
+
rescue ::ActiveRecord::Deadlocked, ::ActiveRecord::SerializationFailure => error
|
|
244
|
+
raise RetryableTransactionError,
|
|
245
|
+
"The capture hit a #{error.class.name.demodulize.underscore.humanize.downcase}. Clickwrap " \
|
|
246
|
+
"does not retry automatically here because it cannot prove your protected action is " \
|
|
247
|
+
"safe to run twice. Retry the whole operation if it is."
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# --- Idempotency ----------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
# The presentation nonce is the idempotency key. It is server-generated,
|
|
253
|
+
# issued once per render, and travels inside the signed token — so a
|
|
254
|
+
# double-click, a retried request, and a replayed token all land on the same
|
|
255
|
+
# key, and the unique index decides who wins.
|
|
256
|
+
def idempotency_key_for(manifest)
|
|
257
|
+
@explicit_idempotency_key || manifest.nonce
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def find_existing_event(key)
|
|
261
|
+
Event.find_by(policy_key: policy.key, idempotency_key: key)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A signed manifest is safe to inspect before the full verification pass;
|
|
265
|
+
# `Submission#manifest` has already verified its server signature. This
|
|
266
|
+
# lookup authorizes no action. It only selects the stricter historical
|
|
267
|
+
# replay path, whose context and exact answers are checked against the
|
|
268
|
+
# committed event before a receipt is returned.
|
|
269
|
+
def find_replay_candidate
|
|
270
|
+
manifest = submission&.manifest
|
|
271
|
+
key = @explicit_idempotency_key || manifest&.nonce
|
|
272
|
+
key.present? ? find_existing_event(key) : nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A repeated identical submit returns the original receipt without running
|
|
276
|
+
# the protected action again. A repeat with different answers is a replay
|
|
277
|
+
# attempt, not a retry, and gets a stable failure rather than a second
|
|
278
|
+
# event.
|
|
279
|
+
def replay(event, answers, manifest)
|
|
280
|
+
verify_replay_context!(event, manifest)
|
|
281
|
+
|
|
282
|
+
recorded = event.statements.to_h { |statement| [statement.statement_key, statement.answer] }
|
|
283
|
+
submitted = answers.transform_values { |value| value&.to_s }
|
|
284
|
+
comparable = recorded.transform_values { |value| value&.to_s }
|
|
285
|
+
|
|
286
|
+
unless comparable == submitted.slice(*comparable.keys)
|
|
287
|
+
raise ReplayRejected,
|
|
288
|
+
"This presentation was already used to record event #{event.id}, and the answers " \
|
|
289
|
+
"submitted now differ from the ones recorded then. Render a new presentation."
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
Receipt.new(event)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def verify_replay_context!(event, manifest)
|
|
296
|
+
expected_actor = manifest.registration_flow_id.present? ? event.actor_reference : actor_reference
|
|
297
|
+
matches = event.policy_revision&.revision_digest == manifest.revision_digest &&
|
|
298
|
+
event.presentation_manifest_digest == manifest.digest &&
|
|
299
|
+
event.actor_reference == expected_actor &&
|
|
300
|
+
event.tenant_key.to_s == tenant_key.to_s &&
|
|
301
|
+
event.subject_key.to_s == subject_key.to_s &&
|
|
302
|
+
event.subject_fingerprint.to_s == manifest.subject_fingerprint.to_s &&
|
|
303
|
+
event.capture_channel == capture_channel &&
|
|
304
|
+
replay_represented_party_matches?(event, manifest)
|
|
305
|
+
|
|
306
|
+
return if matches && event.digest_verified?
|
|
307
|
+
|
|
308
|
+
raise ReplayRejected,
|
|
309
|
+
"This idempotency key was already used with a different actor, tenant, subject, " \
|
|
310
|
+
"policy revision, presentation, or capture channel. Render a new presentation."
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def replay_represented_party_matches?(event, manifest)
|
|
314
|
+
if manifest.represented_party_will_be_created_by_protected_action?
|
|
315
|
+
return event.represented_party_reference.present? &&
|
|
316
|
+
event.authority_source.present? &&
|
|
317
|
+
event.authority_role.present? &&
|
|
318
|
+
event.authority_verified_at.present?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
event.represented_party_reference.to_s == Reference.represented_party(@acting_for).to_s
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# --- Locking --------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
# A one-time authorization is consumed inside the transaction that uses it,
|
|
327
|
+
# so the row is locked before anything else happens. Without this, two
|
|
328
|
+
# concurrent submits could both read an unconsumed authorization and both
|
|
329
|
+
# proceed — which for a withdrawal means two debits.
|
|
330
|
+
def lock_statement_identities!(manifest)
|
|
331
|
+
StatementIdentityLock.acquire_for_actor!(actor_reference)
|
|
332
|
+
|
|
333
|
+
identities = @frozen_statement_snapshots.values.map do |statement|
|
|
334
|
+
StatementState.identity_for(
|
|
335
|
+
policy_key: policy.key,
|
|
336
|
+
statement_key: statement["key"],
|
|
337
|
+
actor_reference: actor_reference,
|
|
338
|
+
tenant_key: tenant_key,
|
|
339
|
+
subject_key: subject_key,
|
|
340
|
+
represented_party_reference: represented_party_identity_reference
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
identities.sort_by { |identity| identity.fetch(:identity_digest) }.each do |identity|
|
|
345
|
+
StatementIdentityLock.acquire!(identity.fetch(:identity_digest))
|
|
346
|
+
statement = @frozen_statement_snapshots.fetch(identity.fetch(:statement_key).to_s)
|
|
347
|
+
verify_one_time_statement_is_available!(StatementState.find_by(identity), manifest) if statement["one_time"]
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def verify_one_time_statement_is_available!(state, manifest)
|
|
352
|
+
return if state.nil? || !state.one_time?
|
|
353
|
+
|
|
354
|
+
current_event = state.current_event
|
|
355
|
+
presentation_is_newer = current_event && manifest.issued_at > current_event.recorded_at_by_server
|
|
356
|
+
|
|
357
|
+
# A terminal authorization may be deliberately recreated only from a
|
|
358
|
+
# presentation rendered after that terminal state existed. The outbox
|
|
359
|
+
# path has one additional, equally deliberate case: a newer presentation
|
|
360
|
+
# may supersede an authorization whose provider outcome is still pending.
|
|
361
|
+
# That is how a host can abandon attempt A and create attempt B without a
|
|
362
|
+
# late success for A ever consuming B. Distinct presentations rendered
|
|
363
|
+
# before either write still serialize on the identity lock and the loser
|
|
364
|
+
# is rejected, so this exception does not reopen the first-capture race.
|
|
365
|
+
return if presentation_is_newer &&
|
|
366
|
+
(!state.satisfies? || !@consume_one_time_authorizations)
|
|
367
|
+
|
|
368
|
+
raise OneTimeAuthorizationConflict,
|
|
369
|
+
"This presentation can no longer create a one-time authorization because " \
|
|
370
|
+
"#{state.statement_key} is already #{state.state} for the same actor, tenant, subject, " \
|
|
371
|
+
"and represented party. Recheck the protected action and render a new presentation " \
|
|
372
|
+
"after the current authorization state is known."
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# --- Writing --------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def append_event!(manifest, revision, answers, request_evidence)
|
|
378
|
+
built = EventBuilder.new(
|
|
379
|
+
policy: policy,
|
|
380
|
+
manifest: manifest,
|
|
381
|
+
revision: revision,
|
|
382
|
+
statement_snapshots: @frozen_statement_snapshots,
|
|
383
|
+
answers: answers,
|
|
384
|
+
document_versions_by_id: @verified_document_versions_by_id,
|
|
385
|
+
request_evidence: request_evidence,
|
|
386
|
+
event_type: @event_type,
|
|
387
|
+
root_event_id: @root_event_id,
|
|
388
|
+
predecessor_event_id: @predecessor_event_id,
|
|
389
|
+
actor: actor,
|
|
390
|
+
actor_reference: actor_reference,
|
|
391
|
+
actor_snapshot: actor_snapshot,
|
|
392
|
+
represented_party: (@acting_for unless @creating_represented_party),
|
|
393
|
+
authority_decision: @authority_decision,
|
|
394
|
+
tenant_key: tenant_key,
|
|
395
|
+
subject: subject,
|
|
396
|
+
subject_key: subject_key,
|
|
397
|
+
subject_fingerprint: subject_fingerprint,
|
|
398
|
+
capture_channel: capture_channel,
|
|
399
|
+
authentication_context: authentication_context,
|
|
400
|
+
attribution_method: attribution_method,
|
|
401
|
+
idempotency_key: idempotency_key_for(manifest),
|
|
402
|
+
http_request_id: http_request_id,
|
|
403
|
+
http_route_name: http_route_name,
|
|
404
|
+
reason: @reason,
|
|
405
|
+
statement_action_overrides: @statement_action_overrides
|
|
406
|
+
).build
|
|
407
|
+
|
|
408
|
+
save_event_with_idempotency(built.event)
|
|
409
|
+
attach_request_evidence(built.event, built.request_evidence_annex)
|
|
410
|
+
built.event
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# The unique index on (policy_key, idempotency_key) is the guarantee, not
|
|
414
|
+
# this rescue — but the rescue has to run inside its own savepoint, because
|
|
415
|
+
# PostgreSQL aborts the entire transaction on a unique violation and the
|
|
416
|
+
# caller's domain work is in that transaction too.
|
|
417
|
+
def save_event_with_idempotency(event)
|
|
418
|
+
::ActiveRecord::Base.transaction(requires_new: true) { event.save! }
|
|
419
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
420
|
+
existing = find_existing_event(event.idempotency_key)
|
|
421
|
+
raise EventWriteFailed, "The evidence event could not be written." unless existing
|
|
422
|
+
|
|
423
|
+
raise IdempotentReplay, existing
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def attach_request_evidence(event, annex)
|
|
427
|
+
return if annex.nil?
|
|
428
|
+
|
|
429
|
+
annex.save!
|
|
430
|
+
event.attach_request_evidence!(annex)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def resolve_request_evidence
|
|
434
|
+
return nil if http_request.nil? && !policy.request_evidence.records_anything?
|
|
435
|
+
|
|
436
|
+
RequestEvidenceExtractor.new(
|
|
437
|
+
policy: policy,
|
|
438
|
+
http_request: http_request,
|
|
439
|
+
capture_channel: capture_channel
|
|
440
|
+
).extract
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def verify_represented_party_authority!
|
|
444
|
+
return if @acting_for.nil?
|
|
445
|
+
|
|
446
|
+
decision = AuthorityVerifier.verify!(
|
|
447
|
+
policy: policy,
|
|
448
|
+
actor: actor,
|
|
449
|
+
represented_party: @acting_for,
|
|
450
|
+
tenant: tenant,
|
|
451
|
+
authentication_context: authentication_context
|
|
452
|
+
)
|
|
453
|
+
details = decision.details.merge(
|
|
454
|
+
"authority_at_presentation" => @verified_manifest.authority_at_presentation
|
|
455
|
+
)
|
|
456
|
+
details["represented_party_was_created_by_protected_action"] = true if
|
|
457
|
+
@creating_represented_party
|
|
458
|
+
@authority_decision = AuthorityDecision.new(
|
|
459
|
+
authorized: true,
|
|
460
|
+
source: decision.source,
|
|
461
|
+
role: decision.role,
|
|
462
|
+
verified_at: decision.verified_at,
|
|
463
|
+
details: details
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def complete_represented_party_creation!(pending)
|
|
468
|
+
verify_represented_party_authority!
|
|
469
|
+
|
|
470
|
+
represented_party_type = if @acting_for.class.respond_to?(:polymorphic_name)
|
|
471
|
+
@acting_for.class.polymorphic_name
|
|
472
|
+
else
|
|
473
|
+
@acting_for.class.name
|
|
474
|
+
end
|
|
475
|
+
pending.event.update_columns(
|
|
476
|
+
represented_party_type: represented_party_type,
|
|
477
|
+
represented_party_id: @acting_for.id,
|
|
478
|
+
represented_party_reference: Reference.represented_party(@acting_for),
|
|
479
|
+
authority_source: @authority_decision.source,
|
|
480
|
+
authority_role: @authority_decision.role,
|
|
481
|
+
authority_verified_at: @authority_decision.verified_at,
|
|
482
|
+
authority_details: @authority_decision.details
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# --- After the write ------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
# An exact post-action reference is host-configured, never inferred. A block
|
|
489
|
+
# that returned without raising tells us the block returned without raising;
|
|
490
|
+
# it does not tell us what the host method meant, and guessing would put a
|
|
491
|
+
# claim in a receipt that nobody made.
|
|
492
|
+
def record_protected_outcome!(event)
|
|
493
|
+
return unless @protected_action && @record_protected_outcome
|
|
494
|
+
|
|
495
|
+
statement = policy.protected_outcome_statement
|
|
496
|
+
return unless statement
|
|
497
|
+
|
|
498
|
+
frozen = @frozen_statement_snapshots.fetch(statement.key)
|
|
499
|
+
unless frozen["protected_outcome_version"] == statement.protected_outcome_version
|
|
500
|
+
raise PresentationInvalid,
|
|
501
|
+
"The protected-outcome recorder changed after this presentation was issued. " \
|
|
502
|
+
"Re-render it against the current policy revision."
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
outcome = statement.record_protected_outcome_with.call(@protected_action_result)
|
|
506
|
+
event.update_columns(protected_outcome: ProtectedOutcome.validate!(outcome))
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def update_projections!(event)
|
|
510
|
+
CurrentState.apply!(event)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def consume_one_time_authorizations!(event)
|
|
514
|
+
return unless @protected_action && @consume_one_time_authorizations
|
|
515
|
+
return unless event.statements.any?(&:one_time?)
|
|
516
|
+
|
|
517
|
+
Lifecycle.consume_authorization!(event: event, because: "Consumed by the protected action")
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# `config.actor_class_name` decides which record the installer wires up and
|
|
521
|
+
# which class every "who is acting" error message names. From here it also
|
|
522
|
+
# decides which records may actually appear in evidence AS the actor —
|
|
523
|
+
# without this check the setting was a label, and a wrong one attributed
|
|
524
|
+
# evidence to the wrong kind of record silently, for as long as the
|
|
525
|
+
# evidence is kept. That is the exact consequence the installer warns
|
|
526
|
+
# about, so it had better be true.
|
|
527
|
+
#
|
|
528
|
+
# Deliberately narrow. A system actor and an anonymous actor are their own
|
|
529
|
+
# kinds and say so in the receipt; a literal String or Symbol reference is
|
|
530
|
+
# an actor from another system this application owns no class for; and nil
|
|
531
|
+
# is "nobody yet", which the registration path binds later.
|
|
532
|
+
def validate_actor_class!
|
|
533
|
+
candidates = [actor, @prospective_actor].compact.reject do |candidate|
|
|
534
|
+
candidate.is_a?(SystemActor) || candidate.is_a?(AnonymousActor) ||
|
|
535
|
+
candidate.is_a?(String) || candidate.is_a?(Symbol)
|
|
536
|
+
end
|
|
537
|
+
return if candidates.empty?
|
|
538
|
+
|
|
539
|
+
# Resolved only once there is something to check, so an application whose
|
|
540
|
+
# every actor is anonymous or system-owned never has to name a class it
|
|
541
|
+
# does not have.
|
|
542
|
+
expected = expected_actor_class
|
|
543
|
+
|
|
544
|
+
candidates.each do |candidate|
|
|
545
|
+
next if candidate.is_a?(expected)
|
|
546
|
+
|
|
547
|
+
raise ConfigurationError,
|
|
548
|
+
"Clickwrap was asked to record #{candidate.class.name} as the actor, but " \
|
|
549
|
+
"`config.actor_class_name` says the records that can act are " \
|
|
550
|
+
"#{Clickwrap.config.actor_class_name}. One of the two is wrong, and evidence " \
|
|
551
|
+
"attributed to the wrong kind of record stays wrong for as long as it is kept. " \
|
|
552
|
+
"Either pass a #{Clickwrap.config.actor_class_name}, or set " \
|
|
553
|
+
"`config.actor_class_name` in config/initializers/clickwrap.rb to the class that " \
|
|
554
|
+
"really acts. (An organization or other party accepted FOR belongs in " \
|
|
555
|
+
"`acting_for:`, not `actor:`.)"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def expected_actor_class
|
|
560
|
+
Clickwrap.config.actor_class
|
|
561
|
+
rescue NameError => error
|
|
562
|
+
raise ConfigurationError,
|
|
563
|
+
"`config.actor_class_name` is set to " \
|
|
564
|
+
"#{Clickwrap.config.actor_class_name.inspect}, which does not name a loadable " \
|
|
565
|
+
"class (#{error.message}). Clickwrap checks every recorded actor against it, so it " \
|
|
566
|
+
"has to resolve."
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Only a policy that retains pre-submit presentations ever wrote a row for
|
|
570
|
+
# this nonce, so only that policy has one to mark — and on a default
|
|
571
|
+
# install the table does not exist at all, which is what makes the guard
|
|
572
|
+
# load-bearing rather than an optimization. The event builder has always
|
|
573
|
+
# asked the same question before looking; this path did not.
|
|
574
|
+
def mark_presentation_accepted!(manifest)
|
|
575
|
+
return unless policy.persist_presentations?
|
|
576
|
+
|
|
577
|
+
Presentation.find_by(nonce: manifest.nonce)&.mark_accepted!
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def rebind_actor_after_registration!(pending)
|
|
581
|
+
@actor = @prospective_actor
|
|
582
|
+
@actor_reference = nil
|
|
583
|
+
return if actor.nil? || !actor.persisted?
|
|
584
|
+
|
|
585
|
+
pending.event.update_columns(
|
|
586
|
+
actor_type: actor.class.name,
|
|
587
|
+
actor_id: actor.id,
|
|
588
|
+
actor_reference: actor_reference,
|
|
589
|
+
actor_snapshot: actor_snapshot
|
|
590
|
+
)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# --- Derived values -------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def actor_reference
|
|
596
|
+
@actor_reference ||= actor ? Reference.actor(actor) : "registration/#{@registration_flow_id}"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def actor_snapshot
|
|
600
|
+
return {} if actor.nil?
|
|
601
|
+
|
|
602
|
+
Clickwrap.config.snapshot_actor_with.call(actor) || {}
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def tenant_key = Reference.tenant(tenant)
|
|
606
|
+
|
|
607
|
+
def represented_party_identity_reference
|
|
608
|
+
return "represented_party_creation/#{@represented_party_creation_flow_id}" if @creating_represented_party
|
|
609
|
+
|
|
610
|
+
Reference.represented_party(@acting_for)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def subject_key = StatementState.subject_key_for(subject)
|
|
614
|
+
|
|
615
|
+
def subject_fingerprint
|
|
616
|
+
return @subject_fingerprint if defined?(@subject_fingerprint)
|
|
617
|
+
|
|
618
|
+
@subject_fingerprint = SubjectFingerprint.for(policy, subject)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def authentication_context
|
|
622
|
+
@authentication_context = (@authentication_context || {}).to_h.symbolize_keys
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def attribution_method
|
|
626
|
+
@attribution_method ||
|
|
627
|
+
if actor.nil? && @prospective_actor
|
|
628
|
+
"account_registration"
|
|
629
|
+
elsif actor.is_a?(SystemActor) then "system_process"
|
|
630
|
+
elsif actor.is_a?(AnonymousActor) then "anonymous_identifier"
|
|
631
|
+
elsif authentication_context[:method].present? then "authenticated_session"
|
|
632
|
+
else "unknown"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def http_request_id
|
|
637
|
+
return nil unless http_request.respond_to?(:request_id)
|
|
638
|
+
|
|
639
|
+
http_request.request_id
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def http_route_name
|
|
643
|
+
return nil unless http_request.respond_to?(:path_parameters)
|
|
644
|
+
|
|
645
|
+
controller = http_request.path_parameters[:controller]
|
|
646
|
+
action = http_request.path_parameters[:action]
|
|
647
|
+
[controller, action].compact.join("#").presence
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|