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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +154 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +187 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +729 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. 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