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,758 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
|
|
5
|
+
module Clickwrap
|
|
6
|
+
# What a host controller gets for free: the submission envelope, the two
|
|
7
|
+
# capture verbs, the authentication description, and the `requires_clickwrap`
|
|
8
|
+
# gate.
|
|
9
|
+
#
|
|
10
|
+
# These are thin on purpose. Every one of them forwards to the same public
|
|
11
|
+
# service API a background job, a console, or a native API endpoint would
|
|
12
|
+
# call; the controller layer only supplies the two things it is the only one
|
|
13
|
+
# holding — the current HTTP request and the parsed submission envelope.
|
|
14
|
+
# Nothing here decides anything about the evidence itself.
|
|
15
|
+
module ControllerHelpers
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
# The view-layer DSL does not exist on ActionController::API, and an
|
|
20
|
+
# API-only host that bundles this gem still loads every controller in
|
|
21
|
+
# production. Guarding keeps such a host bootable; an HTML host gets the
|
|
22
|
+
# helpers. (Same idiom as the sessions gem.)
|
|
23
|
+
if respond_to?(:helper_method)
|
|
24
|
+
helper_method :clickwrap_errors, :present_clickwrap, :clickwrap_document_version_path_for_presentation
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class_methods do
|
|
29
|
+
# The controller gate:
|
|
30
|
+
#
|
|
31
|
+
# class BillingController < ApplicationController
|
|
32
|
+
# requires_clickwrap :current_terms, only: :show
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# An HTML or Turbo visitor is redirected to the mounted capture screen and
|
|
36
|
+
# returned to where they were going. A JSON/API client gets a structured
|
|
37
|
+
# `clickwrap_required` response with the endpoint that can satisfy it.
|
|
38
|
+
#
|
|
39
|
+
# A gate with nowhere to send people is a dead end, so this refuses to
|
|
40
|
+
# compile without either the engine mounted or an explicit
|
|
41
|
+
# `remediation_path:` the host owns. Everything else in `**options` is
|
|
42
|
+
# passed straight through to `before_action` (`only:`, `except:`, `if:`).
|
|
43
|
+
def requires_clickwrap(policy_key, remediation_path: nil, subject_with: nil,
|
|
44
|
+
acting_for_with: nil, **before_action_options)
|
|
45
|
+
ControllerHelpers.validate_gate_resolver!(:subject_with, subject_with)
|
|
46
|
+
ControllerHelpers.validate_gate_resolver!(:acting_for_with, acting_for_with)
|
|
47
|
+
# The dead-end check is REGISTERED here, not run here.
|
|
48
|
+
#
|
|
49
|
+
# Rails eager-loads controllers before it draws the route set, so a
|
|
50
|
+
# class body in production can run while `Rails.application.routes` is
|
|
51
|
+
# still empty — and a check that ran now would fail on a perfectly
|
|
52
|
+
# well-configured application just because it looked too early. So the
|
|
53
|
+
# gate records itself, an `after_initialize` sweep checks every
|
|
54
|
+
# registered gate once the routes exist, and the request path checks
|
|
55
|
+
# again as a backstop for a controller that was autoloaded later.
|
|
56
|
+
#
|
|
57
|
+
# The developer still gets the same sentence at boot. It just comes from
|
|
58
|
+
# a moment when the answer is knowable.
|
|
59
|
+
ControllerHelpers.register_gate(
|
|
60
|
+
policy_key,
|
|
61
|
+
remediation_path: remediation_path,
|
|
62
|
+
subject_with: subject_with,
|
|
63
|
+
acting_for_with: acting_for_with,
|
|
64
|
+
gate: "#{name || "an anonymous controller"}.requires_clickwrap"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
before_action(**before_action_options) do
|
|
68
|
+
clickwrap_gate!(policy_key, remediation_path: remediation_path,
|
|
69
|
+
subject_with: subject_with,
|
|
70
|
+
acting_for_with: acting_for_with)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Gate = Data.define(:policy_key, :remediation_path, :subject_with,
|
|
76
|
+
:acting_for_with, :gate)
|
|
77
|
+
|
|
78
|
+
# Gates register themselves from controller class bodies, and Rails is free
|
|
79
|
+
# to autoload — and, in development, reload — controllers from more than one
|
|
80
|
+
# thread. Two of them writing a bare Hash at the same time is the kind of
|
|
81
|
+
# corruption that shows up once, in somebody else's production, as a gate
|
|
82
|
+
# that quietly stopped being registered. Registry and Identifier already
|
|
83
|
+
# take a lock for exactly this; so does this.
|
|
84
|
+
#
|
|
85
|
+
# Writes take the lock and reads do not: reads happen on every gated
|
|
86
|
+
# request, and a snapshot taken a microsecond before a reload is a snapshot
|
|
87
|
+
# of a valid state either way.
|
|
88
|
+
REGISTRY_MUTEX = Mutex.new
|
|
89
|
+
|
|
90
|
+
# Created here rather than lazily, so there is no `||=` on a read path for
|
|
91
|
+
# two threads to race: both would build a collection and one would silently
|
|
92
|
+
# lose whatever it had already put in.
|
|
93
|
+
@registered_gates = {}
|
|
94
|
+
@verified_gates = Set.new
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
# The current actor, through the host's configured controller method.
|
|
98
|
+
# Shared by the host-facing helper and the engine's own controllers so
|
|
99
|
+
# there is exactly one answer to "who is acting" in the whole web layer.
|
|
100
|
+
def resolve_current_actor(controller)
|
|
101
|
+
method_name = Clickwrap.config.current_actor_method_name
|
|
102
|
+
|
|
103
|
+
unless controller.respond_to?(method_name, true)
|
|
104
|
+
raise ConfigurationError,
|
|
105
|
+
"Clickwrap can't find ##{method_name} on #{controller.class.name}. Set " \
|
|
106
|
+
"`config.current_actor_method_name` in config/initializers/clickwrap.rb to the " \
|
|
107
|
+
"controller method that returns the signed-in " \
|
|
108
|
+
"#{Clickwrap.config.actor_class_name} (:current_user by default, which is what " \
|
|
109
|
+
"Devise and the Rails authentication generator both provide)."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
controller.send(method_name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resolves ambient tenant context through the policy that will bind it.
|
|
116
|
+
# This is shared by form rendering, custom presentations, captures, gates,
|
|
117
|
+
# and remediation tokens so they cannot disagree about whether a current
|
|
118
|
+
# organization belongs in this evidence identity.
|
|
119
|
+
def resolve_current_tenant(controller, policy)
|
|
120
|
+
candidate = Clickwrap.config.find_current_tenant_with.call(controller)
|
|
121
|
+
policy.tenant_from_controller(candidate)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Remembers a declared gate so it can be checked once the route set
|
|
125
|
+
# exists. Re-declaring the same gate (a development reload) replaces the
|
|
126
|
+
# entry rather than accumulating duplicates.
|
|
127
|
+
def register_gate(policy_key, remediation_path:, gate:, subject_with: nil,
|
|
128
|
+
acting_for_with: nil)
|
|
129
|
+
entry = Gate.new(
|
|
130
|
+
policy_key: policy_key.to_s,
|
|
131
|
+
remediation_path: remediation_path,
|
|
132
|
+
subject_with: subject_with,
|
|
133
|
+
acting_for_with: acting_for_with,
|
|
134
|
+
gate: gate
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
REGISTRY_MUTEX.synchronize { registered_gates[[gate, policy_key.to_s]] = entry }
|
|
138
|
+
entry
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
attr_reader :registered_gates
|
|
142
|
+
|
|
143
|
+
# Run from the engine's `after_initialize`, when the host's routes are
|
|
144
|
+
# drawn and the answer is actually knowable. Iterates a snapshot, so a
|
|
145
|
+
# controller autoloading on another thread mid-sweep cannot make this
|
|
146
|
+
# raise about the collection instead of about a gate.
|
|
147
|
+
def verify_registered_gates!
|
|
148
|
+
REGISTRY_MUTEX.synchronize { registered_gates.values }.each do |entry|
|
|
149
|
+
verify_remediation_is_possible!(
|
|
150
|
+
entry.policy_key,
|
|
151
|
+
remediation_path: entry.remediation_path,
|
|
152
|
+
subject_with: entry.subject_with,
|
|
153
|
+
acting_for_with: entry.acting_for_with,
|
|
154
|
+
gate: entry.gate
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def verify_remediation_is_possible!(policy_key, remediation_path:, gate:,
|
|
160
|
+
subject_with: nil, acting_for_with: nil)
|
|
161
|
+
if subject_with && !Clickwrap.config.remediation_subject_authorization_configured?
|
|
162
|
+
raise ConfigurationError,
|
|
163
|
+
"#{gate} :#{policy_key} resolves a subject with `subject_with:`, but the host " \
|
|
164
|
+
"has not configured `authorize_clickwrap_remediation_subject_with`. That " \
|
|
165
|
+
"server-side callback must decide whether the current actor may complete this " \
|
|
166
|
+
"policy for the resolved subject."
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if acting_for_with && !Clickwrap.config.remediation_represented_party_authorization_configured?
|
|
170
|
+
raise ConfigurationError,
|
|
171
|
+
"#{gate} :#{policy_key} resolves a represented party with `acting_for_with:`, " \
|
|
172
|
+
"but the host has not configured " \
|
|
173
|
+
"`authorize_clickwrap_remediation_represented_party_with`."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return true if remediation_path
|
|
177
|
+
return true unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
178
|
+
return true if load_host_routes == :unavailable
|
|
179
|
+
|
|
180
|
+
# Only a success is remembered, and only to keep a gated request from
|
|
181
|
+
# rescanning the route set every time. A mount that later disappears
|
|
182
|
+
# would surface as an ordinary routing error, which is loud enough.
|
|
183
|
+
return true if verified_gates.include?(policy_key.to_s)
|
|
184
|
+
|
|
185
|
+
if engine_is_mounted?
|
|
186
|
+
REGISTRY_MUTEX.synchronize { verified_gates << policy_key.to_s }
|
|
187
|
+
return true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
raise ConfigurationError,
|
|
191
|
+
"#{gate} :#{policy_key} would have no way to be satisfied. A required gate needs " \
|
|
192
|
+
"somewhere to send the person it stops, and Clickwrap::Engine is not mounted, so " \
|
|
193
|
+
"there is no capture screen to redirect them to. Either mount it:\n\n " \
|
|
194
|
+
"mount Clickwrap::Engine => \"/agreements\"\n\n" \
|
|
195
|
+
"or point this gate at a page you own:\n\n " \
|
|
196
|
+
"requires_clickwrap :#{policy_key}, remediation_path: \"/support/agreements\"\n\n" \
|
|
197
|
+
"A gate that blocks an action with no route to unblocking it is a dead end, and " \
|
|
198
|
+
"Clickwrap will not compile one."
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Rails names a mounted engine's URL-helper proxy after the engine's
|
|
202
|
+
# railtie name, so `mounted_helpers` defining `clickwrap` is the same fact
|
|
203
|
+
# as "this application mounted us" — and it is a fact Rails maintains
|
|
204
|
+
# rather than one inferred by walking route objects, whose wrapping has
|
|
205
|
+
# changed shape more than once across versions. The route scan stays as a
|
|
206
|
+
# second opinion for anything unusual.
|
|
207
|
+
def engine_is_mounted?
|
|
208
|
+
helpers = ::Rails.application.routes.mounted_helpers
|
|
209
|
+
return true if helpers&.method_defined?(:clickwrap)
|
|
210
|
+
|
|
211
|
+
::Rails.application.routes.routes.any? { |route| mounts_clickwrap_engine?(route) }
|
|
212
|
+
rescue StandardError
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Memoized successes only, so the worst a lost race can cost is one extra
|
|
217
|
+
# route scan — but the collection itself is still written under the lock.
|
|
218
|
+
attr_reader :verified_gates
|
|
219
|
+
|
|
220
|
+
# The one refusal this gem cannot afford to soften. Both paths that build
|
|
221
|
+
# a document link — the presenter's own fallback and a controller with no
|
|
222
|
+
# `clickwrap` mounted-helper proxy — end at the engine's prefix-less URL
|
|
223
|
+
# helpers, which answer with a path that resolves to nothing on an
|
|
224
|
+
# application that never mounted the engine.
|
|
225
|
+
#
|
|
226
|
+
# That path does not merely render badly. It is signed into the
|
|
227
|
+
# presentation manifest, digested, and recorded as the exact document the
|
|
228
|
+
# person was offered, so the evidence would cite a 404 for as long as it
|
|
229
|
+
# is kept. Nothing downstream can detect that later: the digest is over
|
|
230
|
+
# the wrong link, and it is perfectly valid.
|
|
231
|
+
def assert_engine_can_resolve_document_links!(version = nil)
|
|
232
|
+
return if engine_is_mounted?
|
|
233
|
+
|
|
234
|
+
subject = version ? "document version #{version.id}" : "this document"
|
|
235
|
+
|
|
236
|
+
raise ConfigurationError,
|
|
237
|
+
"Clickwrap will not sign a document link that resolves to nothing. The link for " \
|
|
238
|
+
"#{subject} can only be built from Clickwrap::Engine's own routes, and this " \
|
|
239
|
+
"application does not mount the engine — so the URL would 404, and it would be " \
|
|
240
|
+
"signed into the presentation manifest and kept as the exact document that was " \
|
|
241
|
+
"offered. Mount the engine:\n\n " \
|
|
242
|
+
"mount Clickwrap::Engine => \"/agreements\"\n\n" \
|
|
243
|
+
"or route the documents yourself and bind that route into every presentation:\n\n " \
|
|
244
|
+
"form.clickwrap :signup, document_version_path_with: " \
|
|
245
|
+
"->(version) { legal_document_path(version.id) }"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def validate_gate_resolver!(name, resolver)
|
|
249
|
+
return if resolver.nil? || resolver.is_a?(Symbol) || resolver.respond_to?(:call)
|
|
250
|
+
|
|
251
|
+
raise ConfigurationError,
|
|
252
|
+
"#{name} must be a controller method name (Symbol) or a callable, got #{resolver.inspect}."
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def load_host_routes
|
|
258
|
+
return :loaded if ::Rails.application.routes.routes.any?
|
|
259
|
+
return :unavailable unless ::Rails.application.respond_to?(:reload_routes_unless_loaded)
|
|
260
|
+
|
|
261
|
+
::Rails.application.reload_routes_unless_loaded
|
|
262
|
+
|
|
263
|
+
# Asking is not the same as getting. From Rails 7.2 on,
|
|
264
|
+
# `reload_routes_unless_loaded` is a no-op until the application has
|
|
265
|
+
# finished initializing — and every moment a gate is checked at boot
|
|
266
|
+
# (an eager-loaded class body, `after_initialize`) is before that, because
|
|
267
|
+
# `:set_routes_reloader_hook` runs last. Reporting `:loaded` there would
|
|
268
|
+
# tell a host that mounts the engine that the engine is not mounted, and
|
|
269
|
+
# refuse to boot over it. So report what is actually there and let the
|
|
270
|
+
# check defer to the first request, exactly as documented above.
|
|
271
|
+
::Rails.application.routes.routes.any? ? :loaded : :unavailable
|
|
272
|
+
rescue StandardError
|
|
273
|
+
:unavailable
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# `mount Clickwrap::Engine => "/agreements"` stores the engine behind a
|
|
277
|
+
# constraints wrapper, and a host is free to rename the mount with `as:`,
|
|
278
|
+
# so the reliable question is "does any route dispatch to this engine",
|
|
279
|
+
# not "does a particular URL helper exist".
|
|
280
|
+
def mounts_clickwrap_engine?(route)
|
|
281
|
+
target = route.app
|
|
282
|
+
candidates = [target]
|
|
283
|
+
candidates << target.app if target.respond_to?(:app)
|
|
284
|
+
|
|
285
|
+
candidates.any? { |candidate| candidate == Clickwrap::Engine || candidate.is_a?(Clickwrap::Engine) }
|
|
286
|
+
rescue StandardError
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The submission envelope this request carried: a signed presentation token
|
|
292
|
+
# and the answers the manifest declared. Memoized because reading it twice
|
|
293
|
+
# in one action should not parse it twice.
|
|
294
|
+
def clickwrap_submission
|
|
295
|
+
@clickwrap_submission ||= Submission.from_params(params)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Server-side validation errors from the last capture attempt in this
|
|
299
|
+
# request, keyed by statement. The reference views read this to re-render a
|
|
300
|
+
# failed submission with the message beside the control it belongs to, with
|
|
301
|
+
# no JavaScript involved.
|
|
302
|
+
def clickwrap_errors
|
|
303
|
+
@clickwrap_errors ||= {}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# `Clickwrap.capture!` with the two things only a controller has.
|
|
307
|
+
def capture_clickwrap!(policy_key, **options)
|
|
308
|
+
Clickwrap.capture!(policy_key, **clickwrap_capture_options(policy_key, options))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# `Clickwrap.capture_and!` with the same defaults. The block runs inside the
|
|
312
|
+
# same database transaction as the evidence write: if either fails, neither
|
|
313
|
+
# happened.
|
|
314
|
+
def capture_clickwrap_and!(policy_key, **options, &)
|
|
315
|
+
Clickwrap.capture_and!(policy_key, **clickwrap_capture_options(policy_key, options), &)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# The refusal-absorbing halves of the pair above, shaped like `save` next
|
|
319
|
+
# to `save!`: a refused submission — stale presentation, unticked control,
|
|
320
|
+
# over-long answer — returns false instead of raising, with the
|
|
321
|
+
# per-statement message already in `clickwrap_errors` (the reference views
|
|
322
|
+
# re-render it beside the control) and the whole refusal in
|
|
323
|
+
# `clickwrap_refusal`, whose `user_facing_message` is a complete sentence
|
|
324
|
+
# fit to put in front of a person:
|
|
325
|
+
#
|
|
326
|
+
# receipt = capture_clickwrap_and(:api_access) { current_user.enable_api_access! }
|
|
327
|
+
# unless receipt
|
|
328
|
+
# flash.now[:alert] = clickwrap_refusal.user_facing_message
|
|
329
|
+
# return render :new, status: :unprocessable_entity
|
|
330
|
+
# end
|
|
331
|
+
#
|
|
332
|
+
# Only REFUSALS are absorbed. Infrastructure failures escape, and so do
|
|
333
|
+
# lifecycle conflicts (ReplayRejected, OneTimeAuthorizationConflict):
|
|
334
|
+
# "this was already done" needs a domain answer — usually "treat it as
|
|
335
|
+
# done" — that no generic rescue can supply honestly.
|
|
336
|
+
#
|
|
337
|
+
# (The TEST helper of the same name, Clickwrap::TestHelpers#capture_clickwrap,
|
|
338
|
+
# deliberately follows the opposite convention: it is a factory verb that
|
|
339
|
+
# raises, because in a test a failed capture is a failed test. The two
|
|
340
|
+
# modules never share an object.)
|
|
341
|
+
def capture_clickwrap(policy_key, **)
|
|
342
|
+
capture_clickwrap!(policy_key, **)
|
|
343
|
+
rescue Clickwrap::CaptureRefused => error
|
|
344
|
+
absorb_clickwrap_capture_refusal(error)
|
|
345
|
+
false
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def capture_clickwrap_and(policy_key, **, &)
|
|
349
|
+
capture_clickwrap_and!(policy_key, **, &)
|
|
350
|
+
rescue Clickwrap::CaptureRefused => error
|
|
351
|
+
absorb_clickwrap_capture_refusal(error)
|
|
352
|
+
false
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The refusal the last non-bang helper in this request absorbed, or nil.
|
|
356
|
+
def clickwrap_refusal
|
|
357
|
+
@clickwrap_refusal
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# `Clickwrap.authorize_external_action!` with the request, submission,
|
|
361
|
+
# actor, tenant, and authentication context this controller already knows.
|
|
362
|
+
# The optional block is strictly local compatibility/domain work: it runs
|
|
363
|
+
# once, inside the transaction that saves the evidence event and pending
|
|
364
|
+
# outbox row, and receives `pending_action:` and `pending_receipt:`. The
|
|
365
|
+
# provider call happens only after this helper returns.
|
|
366
|
+
def authorize_clickwrap_external_action!(policy_key, **options, &)
|
|
367
|
+
Clickwrap.authorize_external_action!(
|
|
368
|
+
policy_key,
|
|
369
|
+
**clickwrap_capture_options(policy_key, options),
|
|
370
|
+
&
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# `Clickwrap.present` with the same actor, tenant, and locale defaults the
|
|
375
|
+
# controller capture helpers use. Custom views should call this instead of
|
|
376
|
+
# manually repeating ambient context, which keeps GET and POST binding
|
|
377
|
+
# identical by construction.
|
|
378
|
+
def present_clickwrap(policy_key, **options)
|
|
379
|
+
resolved = options.dup
|
|
380
|
+
resolved[:actor] = clickwrap_current_actor unless resolved.key?(:actor)
|
|
381
|
+
resolved[:tenant] = clickwrap_current_tenant(policy_key) unless resolved.key?(:tenant)
|
|
382
|
+
resolved[:locale] = I18n.locale unless resolved.key?(:locale)
|
|
383
|
+
resolved[:default_document_version_path_with] ||= lambda do |version, declared_link|
|
|
384
|
+
clickwrap_document_version_path_for_presentation(version, declared_link: declared_link)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
Clickwrap.present(policy_key, **resolved)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The exact URL offered beside a Clickwrap control. A document declared with
|
|
391
|
+
# `link:` is read on the host's own page and that path is used as declared;
|
|
392
|
+
# everything else gets the mounted engine route for the exact published
|
|
393
|
+
# version. Either way, a Hotwire Native request under
|
|
394
|
+
# `config.hotwire_native_document_links = { open_in: :external_browser, … }`
|
|
395
|
+
# gets the same path absolutized against the canonical host, so the
|
|
396
|
+
# document opens outside the WebView instead of destroying the screen the
|
|
397
|
+
# form is on. A host that needs another reviewed routing layer can still
|
|
398
|
+
# override this one method. Whatever this returns is both rendered and
|
|
399
|
+
# signed into the presentation manifest, so the evidence never claims a
|
|
400
|
+
# different target from the link.
|
|
401
|
+
#
|
|
402
|
+
# `declared_link:` is passed by the presenter, which is the only thing that
|
|
403
|
+
# knows which document this version belongs to; a controller calling this
|
|
404
|
+
# directly for an engine link simply omits it.
|
|
405
|
+
def clickwrap_document_version_path_for_presentation(version, declared_link: nil)
|
|
406
|
+
path = declared_link.presence || begin
|
|
407
|
+
ControllerHelpers.assert_engine_can_resolve_document_links!(version) unless respond_to?(:clickwrap)
|
|
408
|
+
|
|
409
|
+
clickwrap_engine_routes.document_version_path(version.id)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
native_links = Clickwrap.config.hotwire_native_document_links
|
|
413
|
+
if native_links && clickwrap_hotwire_native_request? &&
|
|
414
|
+
Clickwrap.config.hotwire_native_document_link_mode(self) == :external_browser
|
|
415
|
+
"#{Clickwrap.config.hotwire_native_canonical_host}#{path}"
|
|
416
|
+
else
|
|
417
|
+
path
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# False whenever the host has no Hotwire Native integration at all — the
|
|
422
|
+
# predicate is turbo-rails' own, and its absence means no native app.
|
|
423
|
+
def clickwrap_hotwire_native_request?
|
|
424
|
+
respond_to?(:hotwire_native_app?, true) && send(:hotwire_native_app?)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Signup, for Rails' own authentication generator or any hand-rolled
|
|
428
|
+
# registration door. The pair works exactly like `save` and `save!`:
|
|
429
|
+
#
|
|
430
|
+
# # Absorbs refusals: a stale presentation, an unticked control, or a
|
|
431
|
+
# # failed validation paints the same human sentences the Devise adapter
|
|
432
|
+
# # uses — inline via clickwrap_errors and once on the record's :base —
|
|
433
|
+
# # and returns false, ready for `render :new, status: :unprocessable_entity`.
|
|
434
|
+
# unless register_with_clickwrap(:signup, user: @user) { @user.save! }
|
|
435
|
+
# return render :new, status: :unprocessable_entity
|
|
436
|
+
# end
|
|
437
|
+
#
|
|
438
|
+
# # Raises on refusal, for flows that handle the exceptions themselves:
|
|
439
|
+
# register_with_clickwrap!(:signup, user: @user) { @user.save! }
|
|
440
|
+
#
|
|
441
|
+
# Either way, the account and the evidence that authorized creating it
|
|
442
|
+
# commit together, and an infrastructure failure (EventWriteFailed) always
|
|
443
|
+
# escapes from BOTH forms — a broken database is not a refusal to dress up
|
|
444
|
+
# as validation, and the sign-in, the welcome email, and the redirect that
|
|
445
|
+
# would normally follow simply do not happen. That is the difference
|
|
446
|
+
# between a refused signup and a live account nobody can explain.
|
|
447
|
+
#
|
|
448
|
+
# `user:` is the record the door is about to create. It is spelled `user:`
|
|
449
|
+
# because that is what it is called in every signup controller ever
|
|
450
|
+
# written; pass your actor here whatever its class is actually named.
|
|
451
|
+
def register_with_clickwrap!(policy_key, user:, **options, &)
|
|
452
|
+
refuse_removed_prospective_actor_keyword!(options)
|
|
453
|
+
|
|
454
|
+
result = Clickwrap::Registration.perform(
|
|
455
|
+
policy_key,
|
|
456
|
+
prospective_actor: user,
|
|
457
|
+
http_request: request,
|
|
458
|
+
submission: clickwrap_submission,
|
|
459
|
+
tenant: clickwrap_current_tenant(policy_key),
|
|
460
|
+
registration_flow_id: clickwrap_registration_flow_id(policy_key),
|
|
461
|
+
**options,
|
|
462
|
+
&
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
clear_clickwrap_registration_flow_when_committed(result, policy_key)
|
|
466
|
+
result
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Explicitly refused rather than quietly swallowed by the `**` forward,
|
|
470
|
+
# which would pass it straight through to Registration.perform and let a
|
|
471
|
+
# second spelling of the same argument go on working invisibly. One record
|
|
472
|
+
# is being created here; it gets one name.
|
|
473
|
+
def refuse_removed_prospective_actor_keyword!(options)
|
|
474
|
+
return unless options.key?(:prospective_actor)
|
|
475
|
+
|
|
476
|
+
raise ArgumentError,
|
|
477
|
+
"register_with_clickwrap does not take `prospective_actor:`. The record the door is " \
|
|
478
|
+
"about to create is `user:` — pass your actor there whatever its class is named."
|
|
479
|
+
end
|
|
480
|
+
private :refuse_removed_prospective_actor_keyword!
|
|
481
|
+
|
|
482
|
+
def register_with_clickwrap(policy_key, user:, **, &)
|
|
483
|
+
register_with_clickwrap!(policy_key, user: user, **, &)
|
|
484
|
+
rescue *Clickwrap::Registration::REFUSALS => error
|
|
485
|
+
@clickwrap_refusal = Clickwrap::Registration.absorb_refusal(
|
|
486
|
+
error,
|
|
487
|
+
resource: user,
|
|
488
|
+
clickwrap_errors: clickwrap_errors
|
|
489
|
+
)
|
|
490
|
+
false
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Creates a new represented party (for example, an organization) and its
|
|
494
|
+
# authority evidence as one transaction. The form can pass the same new
|
|
495
|
+
# record as `acting_for:`; this helper owns the server-side browser-flow
|
|
496
|
+
# binding and clears it only after durable commit.
|
|
497
|
+
def create_represented_party_with_clickwrap(policy_key, represented_party:, **options, &)
|
|
498
|
+
resolved = clickwrap_capture_options(policy_key, options)
|
|
499
|
+
result = Clickwrap.create_represented_party!(
|
|
500
|
+
policy_key,
|
|
501
|
+
represented_party: represented_party,
|
|
502
|
+
represented_party_creation_flow_id:
|
|
503
|
+
clickwrap_represented_party_creation_flow_id(policy_key),
|
|
504
|
+
**resolved,
|
|
505
|
+
&
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
clear_clickwrap_represented_party_creation_flow_when_committed(result, policy_key)
|
|
509
|
+
result
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Whatever the host chose to record about how this request was
|
|
513
|
+
# authenticated. Clickwrap does not inspect the session itself: what counts
|
|
514
|
+
# as an authentication context is the host's decision, and the default is an
|
|
515
|
+
# empty hash rather than a guess.
|
|
516
|
+
def clickwrap_authentication_context
|
|
517
|
+
Clickwrap.config.describe_authentication_with.call(self)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Resolves and re-authorizes the signed context handed to a custom
|
|
521
|
+
# `remediation_path:`. The returned object exposes `subject`,
|
|
522
|
+
# `represented_party`, and `return_to`; pass the first two to both
|
|
523
|
+
# presentation and capture. No browser-owned id needs to be permitted.
|
|
524
|
+
def resolve_clickwrap_remediation!(policy_key, token: params[:remediation_token])
|
|
525
|
+
context = RemediationToken.resolve!(
|
|
526
|
+
token,
|
|
527
|
+
policy: Clickwrap.policy!(policy_key),
|
|
528
|
+
actor: clickwrap_current_actor
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
authorize_clickwrap_remediation_context!(
|
|
532
|
+
policy_key,
|
|
533
|
+
actor: clickwrap_current_actor,
|
|
534
|
+
subject: context.subject,
|
|
535
|
+
represented_party: context.represented_party
|
|
536
|
+
)
|
|
537
|
+
context
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
private
|
|
541
|
+
|
|
542
|
+
def absorb_clickwrap_capture_refusal(error)
|
|
543
|
+
@clickwrap_refusal = error
|
|
544
|
+
if error.is_a?(Clickwrap::AnswerInvalid) && error.statement_key.present?
|
|
545
|
+
clickwrap_errors[error.statement_key.to_s] = I18n.t("clickwrap.errors.required_statement")
|
|
546
|
+
end
|
|
547
|
+
error
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def clickwrap_registration_flow_id(policy_key)
|
|
551
|
+
unless respond_to?(:session)
|
|
552
|
+
raise ConfigurationError,
|
|
553
|
+
"Registration-flow binding needs a controller session. API registrations must " \
|
|
554
|
+
"create their own server-side registration_flow_id and pass it to both " \
|
|
555
|
+
"Clickwrap.present and Clickwrap.register!."
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
flows = (session[:clickwrap_registration_flows] ||= {})
|
|
559
|
+
flows[policy_key.to_s] ||= SecureRandom.uuid
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def clickwrap_represented_party_creation_flow_id(policy_key)
|
|
563
|
+
unless respond_to?(:session)
|
|
564
|
+
raise ConfigurationError,
|
|
565
|
+
"Represented-party creation needs a controller session. API clients must create " \
|
|
566
|
+
"their own server-side represented_party_creation_flow_id and pass it to both " \
|
|
567
|
+
"Clickwrap.present and Clickwrap.create_represented_party!."
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
flows = (session[:clickwrap_represented_party_creation_flows] ||= {})
|
|
571
|
+
flows[policy_key.to_s] ||= SecureRandom.uuid
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def clear_clickwrap_registration_flow_id(policy_key)
|
|
575
|
+
session[:clickwrap_registration_flows]&.delete(policy_key.to_s)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def clear_clickwrap_registration_flow_when_committed(result, policy_key)
|
|
579
|
+
if result.respond_to?(:when_durably_committed)
|
|
580
|
+
result.when_durably_committed { clear_clickwrap_registration_flow_id(policy_key) }
|
|
581
|
+
elsif result.committed?
|
|
582
|
+
clear_clickwrap_registration_flow_id(policy_key)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def clear_clickwrap_represented_party_creation_flow_when_committed(result, policy_key)
|
|
587
|
+
clear = lambda do
|
|
588
|
+
session[:clickwrap_represented_party_creation_flows]&.delete(policy_key.to_s)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if result.respond_to?(:when_durably_committed)
|
|
592
|
+
result.when_durably_committed(&clear)
|
|
593
|
+
elsif result.committed?
|
|
594
|
+
clear.call
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def clickwrap_current_actor
|
|
599
|
+
@clickwrap_current_actor ||= ControllerHelpers.resolve_current_actor(self)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def clickwrap_current_tenant(policy_key = nil)
|
|
603
|
+
return Clickwrap.config.find_current_tenant_with.call(self) if policy_key.nil?
|
|
604
|
+
|
|
605
|
+
ControllerHelpers.resolve_current_tenant(self, Clickwrap.policy!(policy_key))
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def clickwrap_capture_options(policy_key, options)
|
|
609
|
+
resolved = options.dup
|
|
610
|
+
resolved[:http_request] = request unless resolved.key?(:http_request)
|
|
611
|
+
resolved[:submission] = clickwrap_submission unless resolved.key?(:submission)
|
|
612
|
+
resolved[:actor] = clickwrap_current_actor unless resolved.key?(:actor)
|
|
613
|
+
resolved[:tenant] = clickwrap_current_tenant(policy_key) unless resolved.key?(:tenant)
|
|
614
|
+
resolved[:authentication_context] = clickwrap_authentication_context unless
|
|
615
|
+
resolved.key?(:authentication_context)
|
|
616
|
+
resolved
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# The gate itself. It answers the question "is this actor current for this
|
|
620
|
+
# policy" through the ordinary public verification API — the same one a
|
|
621
|
+
# service object at the domain boundary would call — and remediates when the
|
|
622
|
+
# answer is no.
|
|
623
|
+
#
|
|
624
|
+
# Controller gates improve the flow. They are not the security boundary:
|
|
625
|
+
# anything consequential should still call `Clickwrap.require!` where the
|
|
626
|
+
# action actually happens.
|
|
627
|
+
def clickwrap_gate!(policy_key, remediation_path: nil, subject_with: nil, acting_for_with: nil)
|
|
628
|
+
# A gate inherited from the host's ApplicationController must never gate
|
|
629
|
+
# Clickwrap's own remediation, receipt, withdrawal, or document screens.
|
|
630
|
+
# Those screens are how the person satisfies the gate; redirecting them
|
|
631
|
+
# back to themselves creates a loop and can make the document they must
|
|
632
|
+
# review unreachable. Domain enforcement remains the host's
|
|
633
|
+
# `Clickwrap.require!` call, not this navigation convenience.
|
|
634
|
+
return if clickwrap_engine_controller?
|
|
635
|
+
|
|
636
|
+
ControllerHelpers.verify_remediation_is_possible!(
|
|
637
|
+
policy_key,
|
|
638
|
+
remediation_path: remediation_path,
|
|
639
|
+
subject_with: subject_with,
|
|
640
|
+
acting_for_with: acting_for_with,
|
|
641
|
+
gate: "#{self.class.name}.requires_clickwrap"
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
actor = clickwrap_current_actor
|
|
645
|
+
unless actor
|
|
646
|
+
if clickwrap_prefers_a_structured_response?
|
|
647
|
+
render json: { error: "clickwrap_actor_required", policy: policy_key.to_s }, status: :unauthorized
|
|
648
|
+
else
|
|
649
|
+
head :unauthorized
|
|
650
|
+
end
|
|
651
|
+
return
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
subject = resolve_clickwrap_gate_value(subject_with)
|
|
655
|
+
represented_party = resolve_clickwrap_gate_value(acting_for_with)
|
|
656
|
+
tenant = clickwrap_current_tenant(policy_key)
|
|
657
|
+
|
|
658
|
+
return if Clickwrap.current?(policy_key, actor: actor, tenant: tenant, subject: subject,
|
|
659
|
+
acting_for: represented_party)
|
|
660
|
+
|
|
661
|
+
authorize_clickwrap_remediation_context!(policy_key, actor: actor, subject: subject,
|
|
662
|
+
represented_party: represented_party)
|
|
663
|
+
clickwrap_require_remediation(
|
|
664
|
+
policy_key,
|
|
665
|
+
remediation_path: remediation_path,
|
|
666
|
+
actor: actor,
|
|
667
|
+
tenant: tenant,
|
|
668
|
+
subject: subject,
|
|
669
|
+
represented_party: represented_party
|
|
670
|
+
)
|
|
671
|
+
rescue RemediationNotAuthorized
|
|
672
|
+
# A denial must not disclose that the resolved subject or represented
|
|
673
|
+
# party exists. This is the same not-found posture the standalone screen
|
|
674
|
+
# uses for a swapped, expired, or otherwise invalid signed handoff.
|
|
675
|
+
head :not_found
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def clickwrap_require_remediation(policy_key, remediation_path:, actor:, tenant:, subject:,
|
|
679
|
+
represented_party:)
|
|
680
|
+
destination = remediation_path || clickwrap_capture_url_for(policy_key)
|
|
681
|
+
token = RemediationToken.issue(
|
|
682
|
+
policy: Clickwrap.policy!(policy_key),
|
|
683
|
+
actor: actor,
|
|
684
|
+
tenant: tenant,
|
|
685
|
+
subject: subject,
|
|
686
|
+
represented_party: represented_party,
|
|
687
|
+
return_to: request.fullpath
|
|
688
|
+
)
|
|
689
|
+
destination = clickwrap_remediation_destination(destination, token)
|
|
690
|
+
|
|
691
|
+
if clickwrap_prefers_a_structured_response?
|
|
692
|
+
render json: {
|
|
693
|
+
error: "clickwrap_required",
|
|
694
|
+
policy: policy_key.to_s,
|
|
695
|
+
presentation_url: destination
|
|
696
|
+
},
|
|
697
|
+
status: :forbidden
|
|
698
|
+
else
|
|
699
|
+
redirect_to destination, allow_other_host: false
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def clickwrap_capture_url_for(policy_key)
|
|
704
|
+
clickwrap_engine_routes.capture_path(policy_key)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def clickwrap_engine_controller?
|
|
708
|
+
defined?(Clickwrap::ApplicationController) && is_a?(Clickwrap::ApplicationController)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# The mounted proxy when the host mounted the engine (it carries the mount
|
|
712
|
+
# prefix), the engine's own prefix-less helpers otherwise.
|
|
713
|
+
def clickwrap_engine_routes
|
|
714
|
+
respond_to?(:clickwrap) ? clickwrap : Clickwrap::Engine.routes.url_helpers
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Where to come back to once the policy is satisfied. Only this request's
|
|
718
|
+
# own path travels — never a client-supplied URL — so the gate cannot be
|
|
719
|
+
# turned into an open redirect.
|
|
720
|
+
def clickwrap_remediation_destination(destination, token)
|
|
721
|
+
separator = destination.include?("?") ? "&" : "?"
|
|
722
|
+
"#{destination}#{separator}remediation_token=#{CGI.escape(token)}"
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def resolve_clickwrap_gate_value(resolver)
|
|
726
|
+
return nil if resolver.nil?
|
|
727
|
+
return send(resolver) if resolver.is_a?(Symbol)
|
|
728
|
+
return instance_exec(&resolver) if resolver.arity.zero?
|
|
729
|
+
|
|
730
|
+
resolver.call(self)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def authorize_clickwrap_remediation_context!(policy_key, actor:, subject:, represented_party:)
|
|
734
|
+
policy = Clickwrap.policy!(policy_key)
|
|
735
|
+
subject_allowed = Clickwrap.config.authorize_clickwrap_remediation_subject_with.call(
|
|
736
|
+
actor: actor, subject: subject, policy: policy, controller: self
|
|
737
|
+
)
|
|
738
|
+
party_allowed = Clickwrap.config.authorize_clickwrap_remediation_represented_party_with.call(
|
|
739
|
+
actor: actor, represented_party: represented_party, policy: policy, controller: self
|
|
740
|
+
)
|
|
741
|
+
return true if subject_allowed == true && party_allowed == true
|
|
742
|
+
|
|
743
|
+
raise RemediationNotAuthorized,
|
|
744
|
+
"The host did not authorize this actor to remediate the policy for the resolved context."
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# HTML and Turbo get a redirect they can follow; everything else gets a
|
|
748
|
+
# response it can branch on. An API-only controller never has an HTML
|
|
749
|
+
# rendering path, so it always gets the structured form.
|
|
750
|
+
def clickwrap_prefers_a_structured_response?
|
|
751
|
+
return true if defined?(::ActionController::API) && is_a?(::ActionController::API)
|
|
752
|
+
return true if request.format.json?
|
|
753
|
+
|
|
754
|
+
symbol = request.format.symbol.to_s
|
|
755
|
+
!(request.format.html? || symbol.include?("turbo"))
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
end
|