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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# One normalization path for every stable reference Clickwrap writes or
|
|
5
|
+
# queries. Keeping this in one place is security-relevant: a capture written
|
|
6
|
+
# under a GlobalID must not become unreachable because a lifecycle or actor
|
|
7
|
+
# proxy later queried the same tenant with `to_s`.
|
|
8
|
+
module Reference
|
|
9
|
+
class << self
|
|
10
|
+
def actor(actor)
|
|
11
|
+
return nil if actor.nil?
|
|
12
|
+
return actor.to_s if actor.is_a?(String) || actor.is_a?(Symbol)
|
|
13
|
+
|
|
14
|
+
Clickwrap.config.identify_actor_with.call(actor).to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tenant(tenant)
|
|
18
|
+
stable(tenant)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def subject(subject)
|
|
22
|
+
stable(subject)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def represented_party(represented_party)
|
|
26
|
+
stable(represented_party)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record(record)
|
|
30
|
+
stable(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def stable(record)
|
|
36
|
+
return "" if record.nil?
|
|
37
|
+
return record.to_s if record.is_a?(String) || record.is_a?(Symbol)
|
|
38
|
+
return record.to_gid.to_s if record.respond_to?(:to_gid)
|
|
39
|
+
|
|
40
|
+
"#{record.class.name}/#{record.id}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The authentication adapters: how an account and the evidence that authorized
|
|
5
|
+
# creating it commit together.
|
|
6
|
+
#
|
|
7
|
+
# Both are thin conveniences over `Clickwrap.register!`, and both are
|
|
8
|
+
# deliberately EXPLICIT — a line you write in your controller, not a hidden
|
|
9
|
+
# `after_create` callback and not a monkey patch. That matters because this is
|
|
10
|
+
# the exact place two real applications got it wrong in the same way: the
|
|
11
|
+
# account was already persisted, the evidence write failed, and the exception
|
|
12
|
+
# was rescued. The result was a live account with no record of what its owner
|
|
13
|
+
# had agreed to, and nothing anywhere said so.
|
|
14
|
+
#
|
|
15
|
+
# Signup is also modeled honestly. At first render there is no persisted
|
|
16
|
+
# actor, so the presentation binds to a short-lived registration flow rather
|
|
17
|
+
# than to a fictional authenticated user, and the receipt records
|
|
18
|
+
# `account_registration` attribution instead of implying a session that did
|
|
19
|
+
# not exist.
|
|
20
|
+
module Registration
|
|
21
|
+
# Everything that counts as a REFUSAL of one registration attempt — a
|
|
22
|
+
# person on a stale render, an unticked control, a validation the account
|
|
23
|
+
# failed — as opposed to an infrastructure failure (EventWriteFailed and
|
|
24
|
+
# friends), which is never dressed up as validation and always escapes.
|
|
25
|
+
REFUSALS = [
|
|
26
|
+
Clickwrap::SubmissionInvalid,
|
|
27
|
+
Clickwrap::PresentationInvalid,
|
|
28
|
+
Clickwrap::AnswerInvalid,
|
|
29
|
+
Clickwrap::RegistrationFailed,
|
|
30
|
+
ActiveRecord::RecordInvalid
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def self.included(base)
|
|
34
|
+
base.extend(ClassMethods)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Translates one refusal into the same human sentences everywhere: inline
|
|
38
|
+
# beside the control it belongs to (through the controller's
|
|
39
|
+
# clickwrap_errors) and once on the resource's :base so the page's error
|
|
40
|
+
# rollup announces it. The Devise adapter and the hand-rolled-door helper
|
|
41
|
+
# (`register_with_clickwrap` without the bang) both come through here, so
|
|
42
|
+
# every door in an application refuses in identical language by
|
|
43
|
+
# construction — a door cannot forget a rescue it never writes.
|
|
44
|
+
def self.absorb_refusal(error, resource:, clickwrap_errors:)
|
|
45
|
+
case error
|
|
46
|
+
when Clickwrap::AnswerInvalid
|
|
47
|
+
if error.statement_key.present?
|
|
48
|
+
clickwrap_errors[error.statement_key.to_s] = I18n.t("clickwrap.errors.required_statement")
|
|
49
|
+
end
|
|
50
|
+
resource.errors.add(:base, I18n.t("clickwrap.errors.required_statement")) if resource.errors.empty?
|
|
51
|
+
when Clickwrap::SubmissionInvalid, Clickwrap::PresentationInvalid
|
|
52
|
+
# A missing, stale, expired, or swapped presentation is a person on an
|
|
53
|
+
# old render (or a cached form with no presentation at all) — not an
|
|
54
|
+
# application error, and never a raw 500 in front of a person.
|
|
55
|
+
resource.errors.add(:base, I18n.t("clickwrap.errors.presentation_no_longer_valid")) if resource.errors.empty?
|
|
56
|
+
when Clickwrap::RegistrationFailed, ActiveRecord::RecordInvalid
|
|
57
|
+
resource.errors.add(:base, error.message) if resource.errors.empty?
|
|
58
|
+
else
|
|
59
|
+
raise error
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
error
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The optional Devise controller macro. Nothing happens unless a host calls
|
|
66
|
+
# it, and merely loading Clickwrap never requires Devise.
|
|
67
|
+
module ClassMethods
|
|
68
|
+
# Devise:
|
|
69
|
+
#
|
|
70
|
+
# class Users::RegistrationsController < Devise::RegistrationsController
|
|
71
|
+
# clickwraps_registration_with :signup
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# Wraps the resource save so the account and its evidence share one
|
|
75
|
+
# transaction. Devise's own `create` action remains the implementation of
|
|
76
|
+
# the flow; Clickwrap decorates only the resource's one `save` call.
|
|
77
|
+
def clickwraps_registration_with(policy_key, **options)
|
|
78
|
+
after_save = options.delete(:after_account_is_saved_inside_transaction)
|
|
79
|
+
unless after_save.nil? || after_save.is_a?(Symbol) || after_save.is_a?(String) ||
|
|
80
|
+
after_save.respond_to?(:call)
|
|
81
|
+
raise DefinitionError,
|
|
82
|
+
"`after_account_is_saved_inside_transaction:` must name a controller method " \
|
|
83
|
+
"or be callable. It receives `account:` and `pending_receipt:`."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
prepend Clickwrap::Registration::DeviseAdapter
|
|
87
|
+
|
|
88
|
+
self.clickwrap_registration_policy = policy_key
|
|
89
|
+
self.clickwrap_registration_options = options
|
|
90
|
+
self.clickwrap_after_registration_account_is_saved = after_save
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The primitive both adapters compose. A host with its own registration
|
|
95
|
+
# service can call this directly and get the same guarantees.
|
|
96
|
+
def self.perform(policy_key, prospective_actor:, http_request: nil, submission: nil,
|
|
97
|
+
tenant: nil, locale: nil, registration_flow_id: nil, &block)
|
|
98
|
+
raise ArgumentError, "register_with_clickwrap needs a block that persists the account" unless block
|
|
99
|
+
|
|
100
|
+
Clickwrap.register!(
|
|
101
|
+
policy_key,
|
|
102
|
+
prospective_actor: prospective_actor,
|
|
103
|
+
http_request: http_request,
|
|
104
|
+
submission: submission,
|
|
105
|
+
tenant: tenant,
|
|
106
|
+
locale: locale,
|
|
107
|
+
registration_flow_id: registration_flow_id,
|
|
108
|
+
&block
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Devise integration.
|
|
113
|
+
#
|
|
114
|
+
# It overrides exactly one thing — how the resource is saved — and leaves
|
|
115
|
+
# everything else about Devise's registration flow alone. Anything more
|
|
116
|
+
# would be this gem taking ownership of a controller it does not own.
|
|
117
|
+
module DeviseAdapter
|
|
118
|
+
extend ActiveSupport::Concern
|
|
119
|
+
|
|
120
|
+
prepended do
|
|
121
|
+
class_attribute :clickwrap_registration_policy, instance_writer: false
|
|
122
|
+
class_attribute :clickwrap_registration_options, instance_writer: false, default: {}
|
|
123
|
+
class_attribute :clickwrap_after_registration_account_is_saved,
|
|
124
|
+
instance_writer: false,
|
|
125
|
+
default: nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Devise's public registration action owns all response, sign-in, flash,
|
|
131
|
+
# inactive-account, callback, and block-yield behavior. Its only database
|
|
132
|
+
# decision is `resource.save`. Decorating that one instance method keeps
|
|
133
|
+
# those semantics on Devise's side of the boundary and avoids copying a
|
|
134
|
+
# version-specific controller action into this gem.
|
|
135
|
+
def build_resource(*arguments, **keywords, &)
|
|
136
|
+
super.tap { install_clickwrap_save_on(resource) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def install_clickwrap_save_on(resource)
|
|
140
|
+
return if resource.instance_variable_defined?(:@clickwrap_save_installation)
|
|
141
|
+
|
|
142
|
+
controller = self
|
|
143
|
+
singleton_class = resource.singleton_class
|
|
144
|
+
original_was_singleton = singleton_class.method_defined?(:save, false)
|
|
145
|
+
original_visibility = method_visibility(singleton_class, :save)
|
|
146
|
+
|
|
147
|
+
singleton_class.send(:alias_method, :clickwrap_save_without_evidence, :save)
|
|
148
|
+
singleton_class.send(:remove_method, :save) if original_was_singleton
|
|
149
|
+
resource.instance_variable_set(
|
|
150
|
+
:@clickwrap_save_installation,
|
|
151
|
+
{ original_was_singleton: original_was_singleton, original_visibility: original_visibility }
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
resource.define_singleton_method(:save) do |*arguments, **keywords, &block|
|
|
155
|
+
controller.send(
|
|
156
|
+
:clickwrap_save_registration_resource,
|
|
157
|
+
self,
|
|
158
|
+
-> { clickwrap_save_without_evidence(*arguments, **keywords, &block) }
|
|
159
|
+
)
|
|
160
|
+
ensure
|
|
161
|
+
controller.send(:restore_registration_resource_save, self)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def restore_registration_resource_save(resource)
|
|
166
|
+
installation = resource.remove_instance_variable(:@clickwrap_save_installation)
|
|
167
|
+
singleton_class = resource.singleton_class
|
|
168
|
+
singleton_class.send(:remove_method, :save)
|
|
169
|
+
|
|
170
|
+
if installation.fetch(:original_was_singleton)
|
|
171
|
+
singleton_class.send(:alias_method, :save, :clickwrap_save_without_evidence)
|
|
172
|
+
singleton_class.send(installation.fetch(:original_visibility), :save)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
singleton_class.send(:remove_method, :clickwrap_save_without_evidence)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def method_visibility(singleton_class, method_name)
|
|
179
|
+
return :private if singleton_class.private_method_defined?(method_name)
|
|
180
|
+
return :protected if singleton_class.protected_method_defined?(method_name)
|
|
181
|
+
|
|
182
|
+
:public
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def clickwrap_save_registration_resource(resource, original_save)
|
|
186
|
+
result = Clickwrap::Registration.perform(
|
|
187
|
+
clickwrap_registration_policy,
|
|
188
|
+
prospective_actor: resource,
|
|
189
|
+
http_request: request,
|
|
190
|
+
submission: clickwrap_submission,
|
|
191
|
+
# Policy-aware on purpose: a signup policy declaring
|
|
192
|
+
# `tenant_is :not_applicable` must not inherit whatever ambient
|
|
193
|
+
# organization happens to be current in the session.
|
|
194
|
+
tenant: clickwrap_current_tenant(clickwrap_registration_policy),
|
|
195
|
+
registration_flow_id: clickwrap_registration_flow_id(clickwrap_registration_policy),
|
|
196
|
+
**clickwrap_registration_options
|
|
197
|
+
) do |pending_receipt|
|
|
198
|
+
saved = original_save.call
|
|
199
|
+
run_after_registration_account_is_saved(resource, pending_receipt) if saved && resource.persisted?
|
|
200
|
+
saved
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
clear_clickwrap_registration_flow_when_committed(result, clickwrap_registration_policy)
|
|
204
|
+
|
|
205
|
+
resource.persisted?
|
|
206
|
+
rescue *Clickwrap::Registration::REFUSALS => error
|
|
207
|
+
# The one shared translation of refusals into human sentences —
|
|
208
|
+
# inline beside the control and once on :base — so this adapter and
|
|
209
|
+
# every hand-rolled door refuse in identical language.
|
|
210
|
+
Clickwrap::Registration.absorb_refusal(
|
|
211
|
+
error,
|
|
212
|
+
resource: resource,
|
|
213
|
+
clickwrap_errors: clickwrap_errors
|
|
214
|
+
)
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# No local tenant resolution here: ControllerHelpers#clickwrap_current_tenant
|
|
219
|
+
# (installed on every host controller) is the one policy-aware path. A
|
|
220
|
+
# zero-arity override in this PREPENDED module would shadow it for every
|
|
221
|
+
# call site in the host — the exact bug class this comment guards against.
|
|
222
|
+
|
|
223
|
+
def run_after_registration_account_is_saved(account, pending_receipt)
|
|
224
|
+
callback = clickwrap_after_registration_account_is_saved
|
|
225
|
+
return if callback.nil?
|
|
226
|
+
|
|
227
|
+
arguments = { account: account, pending_receipt: pending_receipt }
|
|
228
|
+
if callback.respond_to?(:call)
|
|
229
|
+
callback.call(**arguments)
|
|
230
|
+
else
|
|
231
|
+
__send__(callback, **arguments)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A small thread-safe registry for compiled documents, policies, and retention
|
|
5
|
+
# classes.
|
|
6
|
+
#
|
|
7
|
+
# Definitions are declared once at boot and read on every request, so writes
|
|
8
|
+
# take a lock and reads do not. A reload clears the complete registry before
|
|
9
|
+
# loading the declaration files again. Seeing the same key twice inside one
|
|
10
|
+
# load is therefore always ambiguous and is refused instead of letting file
|
|
11
|
+
# order silently decide which policy governs a production action.
|
|
12
|
+
class Registry
|
|
13
|
+
def initialize(kind)
|
|
14
|
+
@kind = kind
|
|
15
|
+
@entries = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :kind
|
|
20
|
+
|
|
21
|
+
def register(key, definition)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
if @entries.key?(key)
|
|
24
|
+
raise DefinitionError,
|
|
25
|
+
"The #{kind} key #{key.inspect} is declared more than once. Give every " \
|
|
26
|
+
"#{kind} one stable key; Clickwrap will not let load order silently replace " \
|
|
27
|
+
"a server-owned definition."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@entries[key] = definition
|
|
31
|
+
end
|
|
32
|
+
definition
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fetch(key, &fallback)
|
|
36
|
+
entry = @entries[key]
|
|
37
|
+
return entry if entry
|
|
38
|
+
return fallback.call if fallback
|
|
39
|
+
|
|
40
|
+
raise NotDefinedError, "No #{kind} registered for #{key.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def [](key) = @entries[key]
|
|
44
|
+
def key?(key) = @entries.key?(key)
|
|
45
|
+
def keys = @entries.keys
|
|
46
|
+
def values = @entries.values
|
|
47
|
+
def size = @entries.size
|
|
48
|
+
def empty? = @entries.empty?
|
|
49
|
+
def each(&) = @entries.each_value(&)
|
|
50
|
+
def clear = @mutex.synchronize { @entries.clear }
|
|
51
|
+
|
|
52
|
+
include Enumerable
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A short-lived, signed handoff from a host gate to the standalone capture
|
|
5
|
+
# screen. It binds the exact actor, tenant, policy, subject, represented party,
|
|
6
|
+
# subject fingerprint, and local return path. The browser transports this
|
|
7
|
+
# context; it never chooses it.
|
|
8
|
+
class RemediationToken
|
|
9
|
+
PURPOSE = "clickwrap/remediation"
|
|
10
|
+
SCHEMA = "clickwrap.remediation.v1"
|
|
11
|
+
|
|
12
|
+
Context = Data.define(:policy, :actor_reference, :tenant_reference, :subject,
|
|
13
|
+
:represented_party, :return_to, :attributes)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def issue(policy:, actor:, tenant: nil, subject: nil, represented_party: nil,
|
|
17
|
+
return_to: nil, issued_at: nil)
|
|
18
|
+
issued_at ||= Clickwrap.now
|
|
19
|
+
expires_at = issued_at + Clickwrap.config.remediation_token_valid_for
|
|
20
|
+
|
|
21
|
+
attributes = {
|
|
22
|
+
"schema" => SCHEMA,
|
|
23
|
+
"policy" => policy.key,
|
|
24
|
+
"actor_reference" => Reference.actor(actor),
|
|
25
|
+
"tenant_reference" => Reference.tenant(tenant),
|
|
26
|
+
"subject" => record_binding(subject, "subject", expires_at: expires_at),
|
|
27
|
+
"subject_fingerprint" => SubjectFingerprint.for(policy, subject),
|
|
28
|
+
"represented_party" => record_binding(
|
|
29
|
+
represented_party, "represented_party", expires_at: expires_at
|
|
30
|
+
),
|
|
31
|
+
"return_to" => return_to,
|
|
32
|
+
"issued_at" => Receipt.format_time(issued_at),
|
|
33
|
+
"expires_at" => Receipt.format_time(expires_at),
|
|
34
|
+
"nonce" => SecureRandom.uuid
|
|
35
|
+
}.compact
|
|
36
|
+
|
|
37
|
+
verifier.generate(attributes, purpose: PURPOSE, expires_at: expires_at)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve!(token, policy:, actor:)
|
|
41
|
+
attributes = verifier.verified(token.to_s, purpose: PURPOSE)
|
|
42
|
+
raise RemediationInvalid, "The remediation token is missing, expired, or invalid." unless attributes
|
|
43
|
+
|
|
44
|
+
unless attributes["schema"] == SCHEMA && attributes["policy"] == policy.key
|
|
45
|
+
raise RemediationInvalid, "The remediation token belongs to a different policy."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
unless secure_equal?(attributes["actor_reference"], Reference.actor(actor))
|
|
49
|
+
raise RemediationInvalid, "The remediation token belongs to a different actor."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The tenant is CARRIED, not compared: the gate resolved it server-side
|
|
53
|
+
# and signed it, and the engine's own routes have no ambient tenant to
|
|
54
|
+
# compare against — a comparison here permanently 404'd every
|
|
55
|
+
# remediation issued from a tenant-scoped page. The signature is the
|
|
56
|
+
# authority; the resolved context hands the tenant back to
|
|
57
|
+
# presentation and capture exactly like the subject.
|
|
58
|
+
|
|
59
|
+
subject = resolve_record(attributes["subject"], "subject")
|
|
60
|
+
represented_party = resolve_record(attributes["represented_party"], "represented_party")
|
|
61
|
+
expected_fingerprint = SubjectFingerprint.for(policy, subject)
|
|
62
|
+
|
|
63
|
+
unless secure_equal?(attributes["subject_fingerprint"], expected_fingerprint)
|
|
64
|
+
raise RemediationInvalid,
|
|
65
|
+
"The remediation subject changed after this route was issued. Return to the " \
|
|
66
|
+
"blocked action and start again against its current state."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Context.new(
|
|
70
|
+
policy: policy,
|
|
71
|
+
actor_reference: attributes["actor_reference"],
|
|
72
|
+
tenant_reference: attributes["tenant_reference"],
|
|
73
|
+
subject: subject,
|
|
74
|
+
represented_party: represented_party,
|
|
75
|
+
return_to: attributes["return_to"],
|
|
76
|
+
attributes: attributes.freeze
|
|
77
|
+
)
|
|
78
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
79
|
+
raise RemediationInvalid, "The remediation token is missing, expired, or invalid."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reset_verifier! = @verifier = nil
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def record_binding(record, name, expires_at:)
|
|
87
|
+
return nil if record.nil?
|
|
88
|
+
|
|
89
|
+
display_name = name.tr("_", " ")
|
|
90
|
+
|
|
91
|
+
unless record.respond_to?(:to_sgid)
|
|
92
|
+
raise DefinitionError,
|
|
93
|
+
"A remediation #{display_name} must support Signed Global ID (`to_sgid`) so the capture " \
|
|
94
|
+
"screen can resolve the exact server-owned record without trusting browser parameters."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
"reference" => Reference.record(record),
|
|
99
|
+
"type" => record.class.name,
|
|
100
|
+
"signed_global_id" => record.to_sgid(
|
|
101
|
+
expires_at: expires_at,
|
|
102
|
+
for: "#{PURPOSE}/#{name}"
|
|
103
|
+
).to_s
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def resolve_record(binding, name)
|
|
108
|
+
return nil if binding.nil?
|
|
109
|
+
|
|
110
|
+
display_name = name.tr("_", " ")
|
|
111
|
+
raise RemediationInvalid, "The remediation #{display_name} binding is malformed." unless binding.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
record = if defined?(::GlobalID::Locator)
|
|
114
|
+
::GlobalID::Locator.locate_signed(
|
|
115
|
+
binding["signed_global_id"], for: "#{PURPOSE}/#{name}"
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
unless record && binding["type"] == record.class.name &&
|
|
120
|
+
secure_equal?(binding["reference"], Reference.record(record))
|
|
121
|
+
raise RemediationInvalid,
|
|
122
|
+
"The remediation #{display_name} no longer exists or no longer matches the signed route."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
record
|
|
126
|
+
rescue ActiveRecord::RecordNotFound
|
|
127
|
+
raise RemediationInvalid,
|
|
128
|
+
"The remediation #{name.tr("_", " ")} no longer exists or no longer matches the signed route."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def secure_equal?(left, right)
|
|
132
|
+
Digest.secure_compare?(left.to_s, right.to_s)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def verifier
|
|
136
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(
|
|
137
|
+
signing_secret,
|
|
138
|
+
digest: "SHA256",
|
|
139
|
+
serializer: JSON
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def signing_secret
|
|
144
|
+
if defined?(::Rails) && ::Rails.application&.key_generator
|
|
145
|
+
::Rails.application.key_generator.generate_key("clickwrap/remediation-token", 32)
|
|
146
|
+
else
|
|
147
|
+
ENV.fetch("CLICKWRAP_REMEDIATION_SECRET") do
|
|
148
|
+
raise ConfigurationError,
|
|
149
|
+
"Clickwrap needs CLICKWRAP_REMEDIATION_SECRET outside Rails to sign remediation routes."
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|