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,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
|