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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -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 +138 -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 +184 -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 +716 -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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Clickwrap
6
+ module Services
7
+ # The policy DSL compiles each declaration immediately, while references may
8
+ # legitimately point at a declaration in a later file. This second pass runs
9
+ # after every file has loaded and resolves those names as one graph. A typo
10
+ # therefore fails boot, never the first production capture.
11
+ class ValidatePolicyReferences
12
+ def self.call = new.call
13
+
14
+ def call
15
+ Clickwrap.policies.each do |policy|
16
+ validate_documents!(policy)
17
+ retention_class = validate_retention_class!(policy)
18
+ validate_request_evidence_retention!(policy, retention_class)
19
+ validate_host_calculations!(policy, retention_class)
20
+ validate_authority_adapter!(policy)
21
+ validate_ip_geolocation_resolver!(policy)
22
+ end
23
+
24
+ true
25
+ end
26
+
27
+ # Routes are not knowable while Rails is still drawing them. The engine
28
+ # invokes this method from `after_routes_loaded`; non-Rails users still get
29
+ # syntax validation from Statement itself.
30
+ def self.validate_remediation_paths!
31
+ return true unless defined?(::Rails) && ::Rails.respond_to?(:application)
32
+ return true unless ::Rails.application
33
+
34
+ Clickwrap.policies.each do |policy|
35
+ policy.consent_statements.each do |statement|
36
+ validate_remediation_path!(policy, statement)
37
+ end
38
+ end
39
+
40
+ true
41
+ end
42
+
43
+ class << self
44
+ private
45
+
46
+ def validate_remediation_path!(policy, statement)
47
+ value = statement.withdrawal_path.to_s
48
+ uri = URI.parse(value)
49
+ return if %w[http https].include?(uri.scheme) && uri.host.present?
50
+
51
+ unless uri.scheme.nil? && value.start_with?("/")
52
+ raise DefinitionError,
53
+ "Consent statement #{statement.key} in policy #{policy.key} has withdrawal " \
54
+ "path #{value.inspect}. Use an absolute application path beginning with `/`, " \
55
+ "or a complete `https://` URL."
56
+ end
57
+
58
+ ::Rails.application.routes.recognize_path(uri.path, method: :get)
59
+ rescue URI::InvalidURIError
60
+ raise DefinitionError,
61
+ "Consent statement #{statement.key} in policy #{policy.key} has malformed " \
62
+ "withdrawal path #{value.inspect}. Give it the exact page a person can visit."
63
+ rescue ActionController::RoutingError
64
+ raise DefinitionError,
65
+ "Consent statement #{statement.key} in policy #{policy.key} points at " \
66
+ "#{value.inspect}, but the host has no GET route for that path. Add the route or " \
67
+ "correct `withdrawal_path:` so consent is not easier to give than to withdraw."
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def validate_documents!(policy)
74
+ policy.document_keys.each do |document_key|
75
+ definitions = Clickwrap.documents.values.select { |definition| definition.key == document_key }
76
+ if definitions.empty?
77
+ raise DefinitionError,
78
+ "Policy #{policy.key} presents document #{document_key.inspect}, but no " \
79
+ "`Clickwrap.document #{document_key.to_sym.inspect}, ...` declaration exists. " \
80
+ "(A statement's document defaults to its own key; if this statement is about " \
81
+ "an operational fact with no published document — the statement text itself " \
82
+ "is the whole notice — say so with `document: nil`.)"
83
+ end
84
+
85
+ next if policy.locales.nil?
86
+
87
+ available = definitions.map(&:locale).uniq
88
+ missing = policy.locales - available
89
+ next if missing.empty?
90
+
91
+ raise DefinitionError,
92
+ "Policy #{policy.key} can be presented in #{missing.join(", ")}, but document " \
93
+ "#{document_key.inspect} has no declaration in #{missing.join(", ")}. Declare " \
94
+ "one immutable version per permitted locale."
95
+ end
96
+ end
97
+
98
+ def validate_retention_class!(policy)
99
+ Clickwrap.retention_classes[policy.retention_class_key] ||
100
+ raise(
101
+ DefinitionError,
102
+ "Policy #{policy.key} says `retain_with #{policy.retention_class_key.to_sym.inspect}`, " \
103
+ "but no retention class with that key is declared. Define it with " \
104
+ "`Clickwrap.retention #{policy.retention_class_key.to_sym.inspect} do ... end`."
105
+ )
106
+ end
107
+
108
+ def validate_request_evidence_retention!(policy, retention_class)
109
+ RequestEvidencePolicy::FIELD_CATEGORIES.each do |category|
110
+ setting = policy.request_evidence.setting_for(category)
111
+ next unless setting.record?
112
+ next if setting.delete_after || setting.retain_until || retention_class.rule_for(category)
113
+
114
+ raise DefinitionError,
115
+ "Policy #{policy.key} records #{category}, but neither that policy nor retention " \
116
+ "class #{retention_class.key} says when to dispose of it. Add " \
117
+ "`delete_after:`/`retain_until:` to the policy or the matching plain-English " \
118
+ "request-evidence rule to the retention class."
119
+ end
120
+ end
121
+
122
+ def validate_host_calculations!(policy, retention_class)
123
+ referenced = retention_class.rules.values.filter_map do |rule|
124
+ rule.host_event_name&.to_sym
125
+ end
126
+ referenced.concat(
127
+ RequestEvidencePolicy::FIELD_CATEGORIES.filter_map do |category|
128
+ policy.request_evidence.setting_for(category).retain_until&.to_sym
129
+ end
130
+ )
131
+
132
+ missing = referenced.uniq - Clickwrap.config.retention_time_calculator_names
133
+ return if missing.empty?
134
+
135
+ raise DefinitionError,
136
+ "Policy #{policy.key} refers to unregistered retention calculation" \
137
+ "#{"s" if missing.many?} #{missing.map(&:inspect).join(", ")}. Register every name " \
138
+ "with `config.calculate_retention_time_for` so disposition never waits forever on " \
139
+ "a typo."
140
+ end
141
+
142
+ def validate_authority_adapter!(policy)
143
+ rule = policy.authority_rule
144
+ return unless rule
145
+ return if rule.adapter_name == "host"
146
+ return if Clickwrap.config.represented_party_authority_adapter(rule.adapter_name)
147
+
148
+ raise DefinitionError,
149
+ "Policy #{policy.key} names represented-party authority adapter " \
150
+ "#{rule.adapter_name.inspect}, but it is not registered."
151
+ end
152
+
153
+ def validate_ip_geolocation_resolver!(policy)
154
+ request_evidence = policy.request_evidence
155
+ return unless request_evidence.records_ip_geolocation?
156
+ return if Clickwrap.config.ip_geolocation_resolver_for(
157
+ request_evidence.ip_geolocation_resolver_name
158
+ )
159
+
160
+ raise DefinitionError,
161
+ "Policy #{policy.key} names IP-geolocation resolver " \
162
+ "#{request_evidence.ip_geolocation_resolver_name.inspect}, but it is not configured."
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # One act inside a policy: a single thing the person is asked to do, with the
5
+ # lifecycle its kind actually needs.
6
+ #
7
+ # A policy can contain several statements — "agree to the Terms" and
8
+ # "acknowledge the Privacy Notice" typically appear on the same screen. They
9
+ # produce one event and one receipt, but they never collapse into one
10
+ # meaning: each statement keeps its own kind, documents, assertion, answer,
11
+ # and lifecycle. That distinction is the whole point of the six kinds, and
12
+ # flattening it is exactly the mistake this gem exists to stop.
13
+ class Statement
14
+ attr_reader :key, :kind, :ordinal, :document_keys, :assertion, :label,
15
+ :link_labels, :choices, :purpose_key, :withdrawal_path,
16
+ :valid_for, :requires, :subject_fingerprint_with,
17
+ :subject_fingerprint_version, :record_protected_outcome_with,
18
+ :protected_outcome_version, :options
19
+
20
+ def initialize(key:, kind:, ordinal:, options: {})
21
+ @key = key.to_s
22
+ @kind = kind.to_s
23
+ @ordinal = ordinal
24
+ @options = options
25
+
26
+ @document_keys = Array(options[:document]).map(&:to_s)
27
+ @assertion = LocalizedText.new(options[:statement])
28
+ @label = LocalizedText.new(options[:label])
29
+ @link_labels = build_link_labels(options[:link_label])
30
+ @choices = normalize_choices(options[:choices])
31
+ @purpose_key = (options[:purpose] || key).to_s
32
+ @withdrawal_path = options[:withdrawal_path]
33
+ @valid_for = options[:valid_for]
34
+ @requires = Array(options[:requires]).map(&:to_s)
35
+ @subject_fingerprint_with = options[:subject_fingerprint_with]
36
+ @subject_fingerprint_version = options[:subject_fingerprint_version]&.to_s
37
+ @record_protected_outcome_with = options[:record_protected_outcome_with]
38
+ @protected_outcome_version = options[:protected_outcome_version]&.to_s
39
+
40
+ validate!
41
+ freeze
42
+ end
43
+
44
+ # Optional statements are offered but never required. Leaving an optional
45
+ # consent control unselected creates no grant at all — silence is not a
46
+ # refusal, and the receipt says the option was offered and not taken rather
47
+ # than recording a decision the person did not make.
48
+ def optional? = options.fetch(:optional, false) == true
49
+ def required? = !optional?
50
+
51
+ # A statement that must be answered one way or the other, with real
52
+ # unselected controls for each choice. Use it when the application needs a
53
+ # recorded decision rather than the absence of one.
54
+ def requires_an_explicit_choice? = options.fetch(:require_an_explicit_choice, false) == true
55
+
56
+ def one_time? = options.fetch(:one_time, false) == true
57
+
58
+ # When true, evidence against an older published version no longer
59
+ # satisfies this statement. The application decides which change is
60
+ # material; Clickwrap only enforces the rule it is given.
61
+ def requires_current_version? = options.fetch(:require_current_version, false) == true
62
+
63
+ def subject_bound? = !subject_fingerprint_with.nil?
64
+ def initial_action = Vocabulary.initial_action_for(kind)
65
+ def withdrawable? = Vocabulary.withdrawable?(kind)
66
+ def expirable? = Vocabulary.expirable?(kind)
67
+ def correctable? = Vocabulary.correctable?(kind)
68
+
69
+ def expires_after(from)
70
+ return nil unless valid_for
71
+
72
+ from + valid_for
73
+ end
74
+
75
+ # Resolves every human-facing string for one locale. Called when the
76
+ # presentation is built; the resolved text is what gets stored.
77
+ def resolve_copy(locale:)
78
+ {
79
+ "assertion" => assertion.resolve(locale:).first,
80
+ "label" => label.resolve(locale:).first,
81
+ "link_labels" => link_labels.transform_values { |text| text.resolve(locale:).first }
82
+ }
83
+ end
84
+
85
+ def to_snapshot
86
+ {
87
+ "key" => key,
88
+ "kind" => kind,
89
+ "ordinal" => ordinal,
90
+ "documents" => document_keys,
91
+ "assertion" => assertion.to_snapshot,
92
+ "label" => label.to_snapshot,
93
+ "link_labels" => link_labels.transform_values(&:to_snapshot),
94
+ "choices" => choices,
95
+ "required" => required?,
96
+ "optional" => optional?,
97
+ "requires_an_explicit_choice" => requires_an_explicit_choice?,
98
+ "requires_current_version" => requires_current_version?,
99
+ "one_time" => one_time?,
100
+ "purpose_key" => purpose_key,
101
+ "withdrawal_path" => withdrawal_path,
102
+ "valid_for_seconds" => valid_for&.to_i,
103
+ "requires" => requires,
104
+ "subject_fingerprint_with" => subject_fingerprint_with ? "configured" : nil,
105
+ "subject_fingerprint_version" => subject_fingerprint_version,
106
+ "record_protected_outcome_with" => record_protected_outcome_with ? "configured" : nil,
107
+ "protected_outcome_version" => protected_outcome_version
108
+ }.compact
109
+ end
110
+
111
+ private
112
+
113
+ def build_link_labels(declaration)
114
+ case declaration
115
+ when nil then {}
116
+ when String, Symbol then { document_keys.first.to_s => LocalizedText.new(declaration) }
117
+ when Hash then declaration.to_h { |key, value| [key.to_s, LocalizedText.new(value)] }
118
+ else
119
+ raise DefinitionError,
120
+ "`link_label:` on statement #{key} must be text or a hash of document key to text"
121
+ end
122
+ end
123
+
124
+ # Choices are written as { yes: :grant, no: :decline } so the policy states
125
+ # both what the person can pick and what each pick means. A choice whose
126
+ # meaning is not one of the kind's actions would produce an event action
127
+ # nothing downstream can interpret.
128
+ def normalize_choices(declaration)
129
+ return nil if declaration.nil?
130
+
131
+ unless declaration.is_a?(Hash)
132
+ raise DefinitionError,
133
+ "`choices:` on statement #{key} must be a hash of choice name to meaning, " \
134
+ "for example { yes: :grant, no: :decline }"
135
+ end
136
+
137
+ declaration.to_h { |choice, meaning| [choice.to_s, meaning.to_s] }.freeze
138
+ end
139
+
140
+ def validate!
141
+ unless Vocabulary.kind?(kind)
142
+ raise DefinitionError, "#{kind.inspect} is not one of: #{Vocabulary::KINDS.join(", ")}"
143
+ end
144
+
145
+ validate_assertion!
146
+ validate_lifecycle!
147
+ validate_versioned_callbacks!
148
+ validate_choices!
149
+ validate_consent!
150
+ end
151
+
152
+ def validate_assertion!
153
+ return if assertion.present?
154
+
155
+ raise DefinitionError,
156
+ "Statement #{key} has no assertion text. Give it `statement:` with the exact " \
157
+ "first-person sentence the server should include in its offer, because that is what " \
158
+ "the receipt records."
159
+ end
160
+
161
+ def validate_lifecycle!
162
+ if valid_for && !expirable?
163
+ raise DefinitionError,
164
+ "Statement #{key} is #{kind}, which does not expire, but it declares " \
165
+ "`valid_for:`. Agreements and attestations are superseded by new versions or " \
166
+ "corrections instead."
167
+ end
168
+
169
+ if one_time? && !Vocabulary.one_time_allowed?(kind)
170
+ raise DefinitionError,
171
+ "Statement #{key} is #{kind}, but `one_time:` only makes sense for an " \
172
+ "authorization bound to a single protected action."
173
+ end
174
+
175
+ return unless Vocabulary.one_time_allowed?(kind) && one_time? && valid_for.nil?
176
+
177
+ raise DefinitionError,
178
+ "One-time authorization #{key} has no `valid_for:`. An authorization that never " \
179
+ "expires and is never consumed would stay usable indefinitely; say how long it is " \
180
+ "good for."
181
+ end
182
+
183
+ def validate_choices!
184
+ return if choices.nil?
185
+
186
+ # Choices belong to the initial act. Lifecycle actions such as consumed,
187
+ # revoked, superseded, or corrected are server-authored transitions and
188
+ # must never become meanings a browser can choose in a capture.
189
+ permitted = [initial_action]
190
+ permitted.push("grant", "decline") if kind == "consent"
191
+ unknown = choices.values.reject { |meaning| permitted.include?(meaning) }
192
+ return if unknown.empty?
193
+
194
+ raise DefinitionError,
195
+ "Statement #{key} maps a choice to #{unknown.join(", ")}, which is not something a " \
196
+ "#{kind} can record. Use one of: #{permitted.uniq.join(", ")}."
197
+ end
198
+
199
+ # Proc bodies cannot be serialized into a frozen policy revision. Requiring
200
+ # a human-chosen version makes a behavior change visible in the revision
201
+ # digest and lets capture reject an old presentation whose executable
202
+ # callback no longer exists under the same meaning.
203
+ def validate_versioned_callbacks!
204
+ if subject_fingerprint_with && !subject_fingerprint_with.respond_to?(:call)
205
+ raise DefinitionError,
206
+ "Statement #{key} gives `subject_fingerprint_with:` a " \
207
+ "#{subject_fingerprint_with.class}; it must be callable."
208
+ end
209
+
210
+ if subject_fingerprint_with && subject_fingerprint_version.to_s.strip.empty?
211
+ raise DefinitionError,
212
+ "Statement #{key} configures `subject_fingerprint_with:` but gives no " \
213
+ "`subject_fingerprint_version:`. Name the callback contract (for example " \
214
+ '"covered-orders-v1") and change that name whenever its behavior changes.'
215
+ end
216
+
217
+ if record_protected_outcome_with && !record_protected_outcome_with.respond_to?(:call)
218
+ raise DefinitionError,
219
+ "Statement #{key} gives `record_protected_outcome_with:` a " \
220
+ "#{record_protected_outcome_with.class}; it must be callable."
221
+ end
222
+
223
+ return unless record_protected_outcome_with && protected_outcome_version.to_s.strip.empty?
224
+
225
+ raise DefinitionError,
226
+ "Statement #{key} configures `record_protected_outcome_with:` but gives no " \
227
+ "`protected_outcome_version:`. Name the callback contract and change that name " \
228
+ "whenever the recorded outcome shape or meaning changes."
229
+ end
230
+
231
+ def validate_consent!
232
+ return unless kind == "consent"
233
+
234
+ if withdrawal_path.nil?
235
+ raise DefinitionError,
236
+ "Consent statement #{key} has no `withdrawal_path:`. Consent that cannot be " \
237
+ "withdrawn as easily as it was given is not something Clickwrap will record as " \
238
+ "consent. Point it at the page where someone can change their mind."
239
+ end
240
+
241
+ return unless requires_an_explicit_choice? && choices.nil?
242
+
243
+ raise DefinitionError,
244
+ "Consent statement #{key} requires an explicit choice but declares no `choices:`. " \
245
+ "Give it the options the person will actually see."
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # One implementation of the aggregate subject fingerprint used by
5
+ # presentation, remediation, capture, and verification. A security binding
6
+ # implemented four nearly-identical ways eventually becomes four different
7
+ # bindings; this is deliberately the only path.
8
+ module SubjectFingerprint
9
+ module_function
10
+
11
+ def for(policy, subject)
12
+ fingerprints = policy.statements.filter_map do |statement|
13
+ next unless statement.subject_bound?
14
+
15
+ [statement.key, for_statement(statement, subject)]
16
+ end.to_h
17
+
18
+ fingerprints.empty? ? nil : Digest.digest_canonical(fingerprints)
19
+ end
20
+
21
+ def for_statement(statement, subject)
22
+ return nil if subject.nil?
23
+
24
+ value = statement.subject_fingerprint_with.call(subject)
25
+ value.nil? ? nil : Digest.digest(value.to_s)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # What came back from the browser: a signed presentation token and the
5
+ # answers to the statements that token declared.
6
+ #
7
+ # Nothing else. A submission cannot carry a policy key, a document version, a
8
+ # validity date, a retention rule, a subject, an IP address, a user-agent, or
9
+ # a geolocation, because every one of those is a decision the server makes and
10
+ # a form field is not a safe place to keep one. Unknown keys are rejected
11
+ # rather than ignored, so an attempt to smuggle one in fails loudly instead of
12
+ # silently doing nothing and looking like it worked.
13
+ class Submission
14
+ ENVELOPE_KEY = :clickwrap_submission
15
+ ENVELOPE_KEYS = %w[presentation_token answers].freeze
16
+
17
+ # Names a client must never be able to set. They exist as a check rather
18
+ # than a filter: a request containing one is a request worth failing.
19
+ REFUSED_KEYS = %w[
20
+ policy policy_key policy_revision revision document document_version version
21
+ valid_from valid_until expires_at retention retain_until subject subject_id
22
+ actor actor_id tenant ip_address remote_ip browser_user_agent user_agent
23
+ ip_geolocation geolocation latitude longitude resolver recorded_at
24
+ server_observed_ip_address capture_channel authentication_method
25
+ ].freeze
26
+
27
+ # The longest an answer can be: generous for any declared choice name,
28
+ # far too short for smuggled prose or a payload.
29
+ MAX_ANSWER_LENGTH = 120
30
+
31
+ attr_reader :presentation_token, :answers
32
+
33
+ def initialize(presentation_token:, answers: {})
34
+ @presentation_token = presentation_token
35
+ @answers = normalize_answers(answers)
36
+ freeze
37
+ end
38
+
39
+ class << self
40
+ # Reads the generated envelope out of controller params.
41
+ def from_params(params, key: ENVELOPE_KEY)
42
+ envelope = extract_envelope(params, key)
43
+
44
+ raise SubmissionInvalid, missing_envelope_message(key) if envelope.nil?
45
+
46
+ new(
47
+ presentation_token: envelope["presentation_token"] || envelope[:presentation_token],
48
+ answers: envelope["answers"] || envelope[:answers] || {}
49
+ )
50
+ end
51
+
52
+ def missing_envelope_message(key)
53
+ "The request contains no #{key} parameter. The form helper renders it; a custom form " \
54
+ "must include the signed presentation token from Clickwrap.present."
55
+ end
56
+
57
+ private
58
+
59
+ def extract_envelope(params, key)
60
+ envelope = params[key] || params[key.to_s]
61
+ return nil if envelope.nil?
62
+
63
+ raw = if envelope.respond_to?(:to_unsafe_h)
64
+ # This is an evidence/security parser, not a mass-assignment
65
+ # boundary. `permit` would silently erase unknown fields before
66
+ # we could refuse them, making a forged server-owned field look
67
+ # as though it worked. Read the envelope, validate its complete
68
+ # shape below, and only then select the two values we understand.
69
+ envelope.to_unsafe_h
70
+ elsif envelope.is_a?(Hash)
71
+ envelope.to_h
72
+ end
73
+
74
+ unless raw
75
+ raise SubmissionInvalid,
76
+ "The #{key} parameter must be an object containing a presentation_token and " \
77
+ "an answers object. It was #{envelope.class}; Clickwrap did not try to guess " \
78
+ "how to reinterpret it."
79
+ end
80
+
81
+ normalized_keys = raw.keys.map(&:to_s)
82
+ duplicate_keys = normalized_keys.tally.select { |_, count| count > 1 }.keys
83
+ unknown_keys = normalized_keys.uniq - ENVELOPE_KEYS
84
+ if duplicate_keys.any? || unknown_keys.any?
85
+ problems = []
86
+ problems << "duplicate keys #{duplicate_keys.join(", ")}" if duplicate_keys.any?
87
+ problems << "unknown keys #{unknown_keys.join(", ")}" if unknown_keys.any?
88
+ raise SubmissionInvalid,
89
+ "The #{key} envelope contains #{problems.join(" and ")}. It may contain only " \
90
+ "presentation_token and answers; policy, authority, identity, retention, and " \
91
+ "request-evidence decisions are server-owned."
92
+ end
93
+
94
+ raw
95
+ end
96
+ end
97
+
98
+ # Deliberately not memoized: a Submission is frozen the moment it is built,
99
+ # so an instance variable written on first use would raise. Verifying the
100
+ # token again costs one signature check, which is a small price for an
101
+ # object that cannot be tampered with after construction.
102
+ def manifest
103
+ PresentationManifest.from_token(presentation_token)
104
+ end
105
+
106
+ def answer_for(statement_key) = answers[statement_key.to_s]
107
+
108
+ def answered?(statement_key)
109
+ self.class.affirmative?(answer_for(statement_key))
110
+ end
111
+
112
+ def self.affirmative?(value)
113
+ return false if value.nil?
114
+
115
+ !%w[0 false off no].include?(value.to_s.downcase) && value.to_s != ""
116
+ end
117
+
118
+ def to_h
119
+ { "presentation_token" => presentation_token, "answers" => answers }
120
+ end
121
+
122
+ private
123
+
124
+ # Answers are scalars keyed by statement. A nested structure would be a way
125
+ # to smuggle something structured past a check that expected a checkbox.
126
+ def normalize_answers(raw)
127
+ hash = if raw.respond_to?(:to_unsafe_h)
128
+ raw.to_unsafe_h
129
+ elsif raw.is_a?(Hash)
130
+ raw.to_h
131
+ else
132
+ raise SubmissionInvalid,
133
+ "The clickwrap answers parameter must be an object keyed by statement. It " \
134
+ "was #{raw.class}; each answer must be a single checkbox state or declared choice."
135
+ end
136
+
137
+ hash.to_h do |key, value|
138
+ name = key.to_s
139
+
140
+ if REFUSED_KEYS.include?(name)
141
+ raise SubmissionInvalid,
142
+ "The submitted answers include #{name.inspect}, which is a server-owned " \
143
+ "decision. Clickwrap resolves the policy, document versions, validity, subject, " \
144
+ "retention, and request evidence itself; a browser cannot choose any of them."
145
+ end
146
+
147
+ unless value.is_a?(String) || value.is_a?(Symbol) || [true, false, nil].include?(value) ||
148
+ value.is_a?(Numeric)
149
+ raise SubmissionInvalid,
150
+ "The answer for #{name.inspect} is a #{value.class}. Answers are single values: " \
151
+ "a checkbox state or the name of one declared choice."
152
+ end
153
+
154
+ # Answers are the one client-authored value that becomes digested
155
+ # evidence, so their domain is bounded: a checkbox state or a declared
156
+ # choice name is never longer than this. Refused, never truncated —
157
+ # evidence is recorded exactly or not at all.
158
+ if value.to_s.length > MAX_ANSWER_LENGTH
159
+ raise SubmissionInvalid,
160
+ "The answer for #{name.inspect} is #{value.to_s.length} characters. An answer " \
161
+ "is a checkbox state or the name of one declared choice, never free text; " \
162
+ "anything over #{MAX_ANSWER_LENGTH} characters is refused rather than recorded."
163
+ end
164
+
165
+ [name, value.nil? ? nil : value.to_s]
166
+ end.freeze
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A named non-human actor, for seeds, imports, migrations, and background
5
+ # processes. Recording one is how a system-created record says out loud that
6
+ # no person did this, instead of leaving a gap that later reads like a human
7
+ # action nobody can find.
8
+ class SystemActor
9
+ attr_reader :name
10
+
11
+ def initialize(name)
12
+ @name = name.to_s
13
+
14
+ if @name.strip.empty?
15
+ raise ArgumentError,
16
+ "A system actor needs a name describing what created the record, for example " \
17
+ "\"database_seed\" or \"crm_import_2026_08\"."
18
+ end
19
+
20
+ freeze
21
+ end
22
+
23
+ def clickwrap_actor_reference = "system/#{name}"
24
+ def id = name
25
+ def to_s = clickwrap_actor_reference
26
+ def persisted? = false
27
+ def ==(other) = other.is_a?(self.class) && other.name == name
28
+ alias eql? ==
29
+ def hash = [self.class, name].hash
30
+ end
31
+ end