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,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# What one policy is allowed to record about the HTTP request that carried a
|
|
5
|
+
# capture.
|
|
6
|
+
#
|
|
7
|
+
# Everything here is off unless a policy or the initializer names it. That is
|
|
8
|
+
# not squeamishness about useful data: it is that high-quality evidence is
|
|
9
|
+
# purpose-specific. An IP address is personal data, and keeping it on your own
|
|
10
|
+
# infrastructure does not remove the duty to have a reason for it, protect it,
|
|
11
|
+
# and stop keeping it eventually. So each field is enabled by name, with a
|
|
12
|
+
# plain-English purpose and a retention decision attached, and the policy that
|
|
13
|
+
# enables it is the server's, never the browser's.
|
|
14
|
+
#
|
|
15
|
+
# None of these fields is identity or physical location. An IP address is a
|
|
16
|
+
# network observation. IP geolocation is a provider's estimate about that
|
|
17
|
+
# address. The raw User-Agent header is whatever the client chose to send.
|
|
18
|
+
class RequestEvidencePolicy
|
|
19
|
+
FIELD_CATEGORIES = %i[ip_address browser_user_agent ip_geolocation].freeze
|
|
20
|
+
|
|
21
|
+
# One category's settings: whether to record it, why, how long, and what
|
|
22
|
+
# happens when it cannot be resolved.
|
|
23
|
+
Setting = Data.define(
|
|
24
|
+
:record, :encrypted, :delete_after, :retain_until, :fail_if_unavailable,
|
|
25
|
+
:because, :legal_basis_reference, :data_protection_impact_assessment_reference
|
|
26
|
+
) do
|
|
27
|
+
def initialize(record: false, encrypted: nil, delete_after: nil, retain_until: nil,
|
|
28
|
+
fail_if_unavailable: false, because: nil, legal_basis_reference: nil,
|
|
29
|
+
data_protection_impact_assessment_reference: nil)
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record? = record == true
|
|
34
|
+
def encrypted? = encrypted == true
|
|
35
|
+
def fail_if_unavailable? = fail_if_unavailable == true
|
|
36
|
+
|
|
37
|
+
def to_snapshot
|
|
38
|
+
{
|
|
39
|
+
"record" => record?,
|
|
40
|
+
"encrypted" => encrypted?,
|
|
41
|
+
"delete_after_seconds" => delete_after&.to_i,
|
|
42
|
+
"retain_until_rule" => retain_until&.to_s,
|
|
43
|
+
"fail_if_unavailable" => fail_if_unavailable?,
|
|
44
|
+
"because" => because,
|
|
45
|
+
"legal_basis_reference" => legal_basis_reference,
|
|
46
|
+
"data_protection_impact_assessment_reference" => data_protection_impact_assessment_reference
|
|
47
|
+
}.compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
NOT_RECORDED = Setting.new(record: false).freeze
|
|
52
|
+
|
|
53
|
+
attr_reader :ip_address, :browser_user_agent, :ip_geolocation,
|
|
54
|
+
:ip_geolocation_fields, :ip_geolocation_resolver_name,
|
|
55
|
+
:review_configuration_on, :policy_key, :retention_class_key,
|
|
56
|
+
:trusted_proxy_configuration_digest
|
|
57
|
+
|
|
58
|
+
def initialize(policy_key:, retention_class_key: nil, ip_address: nil,
|
|
59
|
+
browser_user_agent: nil, ip_geolocation: nil,
|
|
60
|
+
ip_geolocation_fields: {}, ip_geolocation_resolver_name: nil,
|
|
61
|
+
trusted_proxy_configuration_digest: nil, review_configuration_on: nil)
|
|
62
|
+
@policy_key = policy_key
|
|
63
|
+
@retention_class_key = retention_class_key&.to_s
|
|
64
|
+
@ip_address = normalized_setting(:ip_address, ip_address || NOT_RECORDED)
|
|
65
|
+
@browser_user_agent = normalized_setting(:browser_user_agent, browser_user_agent || NOT_RECORDED)
|
|
66
|
+
@ip_geolocation = normalized_setting(:ip_geolocation, ip_geolocation || NOT_RECORDED)
|
|
67
|
+
@ip_geolocation_fields = normalize_geolocation_fields(ip_geolocation_fields)
|
|
68
|
+
@ip_geolocation_resolver_name = ip_geolocation_resolver_name&.to_sym
|
|
69
|
+
@trusted_proxy_configuration_digest = trusted_proxy_configuration_digest&.to_s
|
|
70
|
+
@review_configuration_on = review_configuration_on
|
|
71
|
+
|
|
72
|
+
validate!
|
|
73
|
+
freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def records_ip_address? = ip_address.record?
|
|
77
|
+
def records_browser_user_agent? = browser_user_agent.record?
|
|
78
|
+
def records_ip_geolocation? = ip_geolocation.record?
|
|
79
|
+
|
|
80
|
+
def records_ip_geolocation_field?(field)
|
|
81
|
+
ip_geolocation_fields.fetch(field.to_s, false)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def enabled_ip_geolocation_fields
|
|
85
|
+
ip_geolocation_fields.select { |_, enabled| enabled }.keys
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def records_anything? = records_ip_address? || records_browser_user_agent? || records_ip_geolocation?
|
|
89
|
+
|
|
90
|
+
def setting_for(category)
|
|
91
|
+
case category.to_sym
|
|
92
|
+
when :ip_address then ip_address
|
|
93
|
+
when :browser_user_agent then browser_user_agent
|
|
94
|
+
when :ip_geolocation then ip_geolocation
|
|
95
|
+
else raise ArgumentError, "Unknown request-evidence category #{category.inspect}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The exact field allowlist stored beside any recorded request evidence, so
|
|
100
|
+
# a reader years later can tell what the server was authorized to keep —
|
|
101
|
+
# not merely what happens to be present.
|
|
102
|
+
def authorized_fields_manifest
|
|
103
|
+
{
|
|
104
|
+
"ip_address" => records_ip_address?,
|
|
105
|
+
"browser_user_agent" => records_browser_user_agent?,
|
|
106
|
+
"ip_geolocation" => ip_geolocation_fields.dup
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def to_snapshot
|
|
111
|
+
{
|
|
112
|
+
"ip_address" => ip_address.to_snapshot,
|
|
113
|
+
"browser_user_agent" => browser_user_agent.to_snapshot,
|
|
114
|
+
"ip_geolocation" => ip_geolocation.to_snapshot.merge(
|
|
115
|
+
"fields" => ip_geolocation_fields,
|
|
116
|
+
"resolver" => ip_geolocation_resolver_name&.to_s
|
|
117
|
+
).compact,
|
|
118
|
+
"trusted_proxy_configuration_digest" => trusted_proxy_configuration_digest,
|
|
119
|
+
"review_configuration_on" => review_configuration_on&.to_s
|
|
120
|
+
}.compact
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def normalize_geolocation_fields(declaration)
|
|
126
|
+
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
|
|
127
|
+
[field, declaration.transform_keys(&:to_s).fetch(field, false) == true]
|
|
128
|
+
end.freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate!
|
|
132
|
+
FIELD_CATEGORIES.each { |category| validate_category!(category) }
|
|
133
|
+
validate_geolocation_coherence!
|
|
134
|
+
validate_named_resolver!
|
|
135
|
+
validate_trusted_proxy_configuration_digest!
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def normalized_setting(category, setting)
|
|
139
|
+
return setting unless setting.record?
|
|
140
|
+
|
|
141
|
+
configured = case category
|
|
142
|
+
when :ip_address then Clickwrap.config.encrypt_recorded_ip_addresses
|
|
143
|
+
when :browser_user_agent then Clickwrap.config.encrypt_recorded_browser_user_agents
|
|
144
|
+
else Clickwrap.config.encrypt_recorded_ip_geolocation
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if !setting.encrypted.nil? && setting.encrypted != configured
|
|
148
|
+
raise DefinitionError,
|
|
149
|
+
"Policy #{policy_key} says `encrypted: #{setting.encrypted}` for #{category}, but " \
|
|
150
|
+
"the application-wide storage setting is #{configured}. Active Record encryption " \
|
|
151
|
+
"is configured per column, not per row, so Clickwrap refuses to record a policy " \
|
|
152
|
+
"snapshot that would misdescribe storage. Change the matching " \
|
|
153
|
+
"`config.encrypt_recorded_*` setting, or omit `encrypted:` to inherit it."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Setting.new(**setting.to_h, encrypted: configured)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_named_resolver!
|
|
160
|
+
return unless records_ip_geolocation?
|
|
161
|
+
|
|
162
|
+
resolver = Clickwrap.config.ip_geolocation_resolver_for(ip_geolocation_resolver_name)
|
|
163
|
+
unless resolver
|
|
164
|
+
raise DefinitionError,
|
|
165
|
+
"Policy #{policy_key} records IP geolocation using resolver " \
|
|
166
|
+
"#{ip_geolocation_resolver_name.inspect}, but no resolver is registered under " \
|
|
167
|
+
"that name. Configure `config.ip_geolocation_resolver` for " \
|
|
168
|
+
"`:application_default`, or register the named resolver with " \
|
|
169
|
+
"`config.register_ip_geolocation_resolver`. Registered resolvers: " \
|
|
170
|
+
"#{Clickwrap.config.ip_geolocation_resolver_names.join(", ").presence || "(none)"}."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
capabilities = Array(resolver.capabilities).map(&:to_s)
|
|
174
|
+
impossible = enabled_ip_geolocation_fields - capabilities
|
|
175
|
+
return if impossible.empty?
|
|
176
|
+
|
|
177
|
+
raise DefinitionError,
|
|
178
|
+
"Policy #{policy_key} asks IP-geolocation resolver " \
|
|
179
|
+
"#{ip_geolocation_resolver_name.inspect} for #{impossible.join(", ")}, but that " \
|
|
180
|
+
"resolver says it cannot supply #{impossible.many? ? "those fields" : "that field"}. " \
|
|
181
|
+
"Choose fields the resolver supports or configure a resolver whose `capabilities` " \
|
|
182
|
+
"include them."
|
|
183
|
+
rescue NotImplementedError => error
|
|
184
|
+
raise DefinitionError,
|
|
185
|
+
"The IP-geolocation resolver for policy #{policy_key} cannot describe its " \
|
|
186
|
+
"capabilities: #{error.message}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_category!(category)
|
|
190
|
+
setting = setting_for(category)
|
|
191
|
+
return unless setting.record?
|
|
192
|
+
|
|
193
|
+
if setting.because.to_s.strip.empty?
|
|
194
|
+
raise DefinitionError,
|
|
195
|
+
"Policy #{policy_key} records #{category} but gives no `because:`. Say in one " \
|
|
196
|
+
"plain sentence why this policy needs it right now. \"We might need it someday\" " \
|
|
197
|
+
"is not a purpose, and a privacy notice mentioning the field is not one either."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if ReviewedText.placeholder?(setting.because)
|
|
201
|
+
raise DefinitionError,
|
|
202
|
+
"Policy #{policy_key} records #{category}, but its `because:` is still " \
|
|
203
|
+
"scaffolding text (#{setting.because.inspect}). Replace it with the " \
|
|
204
|
+
"application's reviewed, present-tense reason; Clickwrap never treats a TODO " \
|
|
205
|
+
"as a data-collection purpose."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil?
|
|
209
|
+
raise DefinitionError,
|
|
210
|
+
"Policy #{policy_key} records #{category} but never says when to delete it. " \
|
|
211
|
+
"Give it `delete_after:` with a duration, or `retain_until:` naming a host event " \
|
|
212
|
+
"rule, or attach a retention class with a rule for this category. Clickwrap has " \
|
|
213
|
+
"no keep-forever default."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
return unless setting.delete_after && setting.delete_after.to_i <= 0
|
|
217
|
+
|
|
218
|
+
raise DefinitionError,
|
|
219
|
+
"Policy #{policy_key} sets `delete_after:` for #{category} to " \
|
|
220
|
+
"#{setting.delete_after.inspect}, which is not a period in the future."
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def validate_geolocation_coherence!
|
|
224
|
+
enabled = enabled_ip_geolocation_fields
|
|
225
|
+
|
|
226
|
+
if ip_geolocation.record? && enabled.empty?
|
|
227
|
+
raise DefinitionError,
|
|
228
|
+
"Policy #{policy_key} calls `record_ip_geolocation` but enables no field. " \
|
|
229
|
+
"Name the fields you actually need, for example `country: true`."
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if ip_geolocation.record? &&
|
|
233
|
+
records_ip_geolocation_field?(:latitude_and_longitude) &&
|
|
234
|
+
!records_ip_geolocation_field?(:accuracy_radius_in_kilometers)
|
|
235
|
+
raise DefinitionError,
|
|
236
|
+
"Policy #{policy_key} records IP-geolocation latitude and longitude but does not " \
|
|
237
|
+
"record `accuracy_radius_in_kilometers:`. Provider-derived coordinates without " \
|
|
238
|
+
"their uncertainty look more precise than they are. Set both fields to true, or " \
|
|
239
|
+
"turn latitude and longitude off. Clickwrap will not enable another field silently."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
return if ip_geolocation.record? || enabled.empty?
|
|
243
|
+
|
|
244
|
+
raise DefinitionError,
|
|
245
|
+
"Policy #{policy_key} enables the IP-geolocation fields #{enabled.join(", ")} " \
|
|
246
|
+
"without recording IP geolocation."
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_trusted_proxy_configuration_digest!
|
|
250
|
+
return unless records_ip_address? || records_ip_geolocation?
|
|
251
|
+
return unless trusted_proxy_configuration_digest.to_s.strip.empty?
|
|
252
|
+
|
|
253
|
+
raise DefinitionError,
|
|
254
|
+
"Policy #{policy_key} records an IP address or derives IP geolocation, but " \
|
|
255
|
+
"`config.trusted_proxy_configuration_digest` is blank. Review the deployment's " \
|
|
256
|
+
"trusted-proxy topology, digest that reviewed configuration, and set the prefixed " \
|
|
257
|
+
"digest (for example `sha256:...`). This records which proxy decision produced the " \
|
|
258
|
+
"address; it does not claim that the decision was correct."
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Retention
|
|
5
|
+
# Applies a reviewed disposition plan.
|
|
6
|
+
#
|
|
7
|
+
# result = Clickwrap::Retention::Applier.new(plan, applied_by: current_operator).call
|
|
8
|
+
# result.counts # => {"applied" => 812, "skipped_held" => 4, ...}
|
|
9
|
+
#
|
|
10
|
+
# The plan said what would happen. This re-asks every question before it
|
|
11
|
+
# does anything, because between the review and the run someone may have
|
|
12
|
+
# placed a legal hold, changed a retention class, released a hold, deleted a
|
|
13
|
+
# field by hand, or applied the plan already. When an item's answer has
|
|
14
|
+
# changed, that item stops and is reported — the run never deletes a broader
|
|
15
|
+
# set than the person who reviewed it agreed to, and never quietly deletes a
|
|
16
|
+
# narrower one either.
|
|
17
|
+
#
|
|
18
|
+
# Each item is its own transaction: the deletion and the event that
|
|
19
|
+
# documents it commit together, and a failure on item 900 does not undo the
|
|
20
|
+
# 899 dispositions that already succeeded and were already recorded.
|
|
21
|
+
class Applier
|
|
22
|
+
# One line of the report. The same shape for every outcome, so a task, a
|
|
23
|
+
# test, and a job all read it the same way, and `note` always says in a
|
|
24
|
+
# sentence why the line is where it is.
|
|
25
|
+
Outcome = Data.define(:part, :event_id, :record_id, :policy_key, :note) do
|
|
26
|
+
def initialize(part:, event_id: nil, record_id: nil, policy_key: nil, note: nil)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_report_entry
|
|
31
|
+
{
|
|
32
|
+
"part" => part.to_s,
|
|
33
|
+
"event_id" => event_id,
|
|
34
|
+
"record_id" => record_id&.to_s,
|
|
35
|
+
"policy_key" => policy_key,
|
|
36
|
+
"note" => note
|
|
37
|
+
}.compact
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Result = Data.define(:applied, :skipped_held, :skipped_changed, :errors) do
|
|
42
|
+
def counts
|
|
43
|
+
{
|
|
44
|
+
"applied" => applied.length,
|
|
45
|
+
"skipped_held" => skipped_held.length,
|
|
46
|
+
"skipped_changed" => skipped_changed.length,
|
|
47
|
+
"errors" => errors.length
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
"applied" => applied.map(&:to_report_entry),
|
|
54
|
+
"skipped_held" => skipped_held.map(&:to_report_entry),
|
|
55
|
+
"skipped_changed" => skipped_changed.map(&:to_report_entry),
|
|
56
|
+
"errors" => errors.map(&:to_report_entry),
|
|
57
|
+
"counts" => counts
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def clean? = errors.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(plan, applied_by:, recover_application_if_stale_for: nil,
|
|
65
|
+
because_recovery_is_needed: nil)
|
|
66
|
+
@plan = plan
|
|
67
|
+
@applied_by = applied_by
|
|
68
|
+
@recover_application_if_stale_for = recover_application_if_stale_for
|
|
69
|
+
@because_recovery_is_needed = because_recovery_is_needed
|
|
70
|
+
@at = Clickwrap.now
|
|
71
|
+
@applied = []
|
|
72
|
+
@skipped_held = []
|
|
73
|
+
@skipped_changed = []
|
|
74
|
+
@errors = []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
attr_reader :plan, :applied_by, :at
|
|
78
|
+
|
|
79
|
+
def call
|
|
80
|
+
# First, before anything is read or deleted: is this plan still one
|
|
81
|
+
# somebody may act on? An expired, superseded, or already-applied plan
|
|
82
|
+
# names a set that nobody currently agrees to.
|
|
83
|
+
plan.claim_for_application!(
|
|
84
|
+
by_reference: reference_for(applied_by),
|
|
85
|
+
recover_if_stale_after: @recover_application_if_stale_for,
|
|
86
|
+
because_recovery_is_needed: @because_recovery_is_needed
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
plan_items.each { |item| apply_item(item) }
|
|
90
|
+
|
|
91
|
+
# The plan is single-use whether or not every item succeeded. A plan
|
|
92
|
+
# that could be run twice would be a plan whose second run nobody
|
|
93
|
+
# reviewed; the operator re-plans instead, and the fresh plan shows what
|
|
94
|
+
# is still outstanding.
|
|
95
|
+
result = Result.new(applied: @applied, skipped_held: @skipped_held,
|
|
96
|
+
skipped_changed: @skipped_changed, errors: @errors)
|
|
97
|
+
plan.finish_application!(outcome_summary: result.to_h, had_errors: @errors.any?)
|
|
98
|
+
result
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def plan_items
|
|
104
|
+
Array(plan.disposition_scope.to_h["items"]).map { |entry| Planner::Item.from_plan_entry(entry) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def apply_item(item)
|
|
108
|
+
case item.part
|
|
109
|
+
when :core_event then apply_core_event(item)
|
|
110
|
+
when :ip_address, :browser_user_agent, :ip_geolocation then apply_annex_field(item)
|
|
111
|
+
when :presentation then apply_presentation(item)
|
|
112
|
+
else
|
|
113
|
+
record_changed(item, "#{item.part} is not something this version of Clickwrap disposes of.")
|
|
114
|
+
end
|
|
115
|
+
rescue LegalHoldInEffect => error
|
|
116
|
+
# Belt and braces: the hold was already checked above, so reaching here
|
|
117
|
+
# means one was placed between the check and the write. The hold wins.
|
|
118
|
+
record_held(item, error.message)
|
|
119
|
+
rescue StandardError => error
|
|
120
|
+
@errors << outcome_for(item, "#{error.class}: #{error.message}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- The core event -------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def apply_core_event(item)
|
|
126
|
+
event = Event.find_by(id: item.event_id)
|
|
127
|
+
return record_changed(item, "The event is no longer in the database.") if event.nil?
|
|
128
|
+
return record_changed(item, "The core event was already disposed of.") if event.disposed?
|
|
129
|
+
return record_held(item, hold_note(event)) if Disposition.legal_hold_in_effect?(event)
|
|
130
|
+
|
|
131
|
+
eligibility = Planner.core_event_eligibility(event)
|
|
132
|
+
return record_changed(item, not_due_note(eligibility)) unless still_disposable?(item, eligibility)
|
|
133
|
+
|
|
134
|
+
Disposition.dispose_core_event!(event, because: reason)
|
|
135
|
+
record_applied(item, "The core event was marked disposed of. The row and its history stay.")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- The optional request-evidence annex ----------------------------------
|
|
139
|
+
|
|
140
|
+
def apply_annex_field(item)
|
|
141
|
+
annex = RequestEvidence.find_by(id: item.record_id)
|
|
142
|
+
return record_changed(item, "The request-evidence row is no longer in the database.") if annex.nil?
|
|
143
|
+
|
|
144
|
+
event = annex.event
|
|
145
|
+
return record_changed(item, "The event this evidence belonged to is gone.") if event.nil?
|
|
146
|
+
return record_changed(item, "#{item.part} was already deleted.") if annex.deleted_for?(item.part)
|
|
147
|
+
return record_held(item, hold_note(event)) if Disposition.legal_hold_in_effect?(event)
|
|
148
|
+
|
|
149
|
+
eligibility = Planner.annex_eligibility(annex, item.part)
|
|
150
|
+
return record_changed(item, not_due_note(eligibility)) unless still_disposable?(item, eligibility)
|
|
151
|
+
|
|
152
|
+
deleted = Disposition.delete_field!(event, item.part, because: reason)
|
|
153
|
+
return record_changed(item, "Nothing was recorded for #{item.part}, so nothing was deleted.") if deleted.nil?
|
|
154
|
+
|
|
155
|
+
record_applied(item, "The recorded #{item.part} was deleted and the deletion was recorded.")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- Persisted presentations ---------------------------------------------
|
|
159
|
+
|
|
160
|
+
def apply_presentation(item)
|
|
161
|
+
presentation = Presentation.find_by(id: item.record_id)
|
|
162
|
+
return record_changed(item, "The presentation is no longer in the database.") if presentation.nil?
|
|
163
|
+
|
|
164
|
+
if presentation.events.exists?
|
|
165
|
+
return record_changed(item, "An event now cites this presentation, so it is part of the evidence.")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
due_at = presentation.retain_until || presentation.expires_at
|
|
169
|
+
return record_changed(item, "This presentation is not past its retention date.") if due_at.nil? || due_at > at
|
|
170
|
+
|
|
171
|
+
::ActiveRecord::Base.transaction { presentation.destroy! }
|
|
172
|
+
record_applied(item, "The unsubmitted presentation was deleted.")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --- Re-checking ----------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
# A retention item must still be due under the rule as it stands right
|
|
178
|
+
# now. An actor-request item is checked for holds and for still existing,
|
|
179
|
+
# but not for retention: whether an erasure request outweighs a retention
|
|
180
|
+
# duty is a decision a person made when they created and reviewed that
|
|
181
|
+
# plan, and re-litigating it here would either ignore their decision or
|
|
182
|
+
# pretend Clickwrap had made it.
|
|
183
|
+
def still_disposable?(item, eligibility)
|
|
184
|
+
return true if item.eligibility == "actor_request"
|
|
185
|
+
|
|
186
|
+
eligibility.due?(at)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def not_due_note(eligibility)
|
|
190
|
+
if eligibility.unresolved?
|
|
191
|
+
"The rule no longer resolves to a date: #{eligibility.unresolved_reason}"
|
|
192
|
+
else
|
|
193
|
+
"The rule now says this is not due until #{Receipt.format_time(eligibility.eligible_at)}."
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def hold_note(event)
|
|
198
|
+
"A legal hold is in effect for event #{event.id}, so nothing was deleted."
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# --- Recording ------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def record_applied(item, note) = @applied << outcome_for(item, note)
|
|
204
|
+
def record_held(item, note) = @skipped_held << outcome_for(item, note)
|
|
205
|
+
def record_changed(item, note) = @skipped_changed << outcome_for(item, note)
|
|
206
|
+
|
|
207
|
+
def outcome_for(item, note)
|
|
208
|
+
Outcome.new(part: item.part, event_id: item.event_id, record_id: item.record_id,
|
|
209
|
+
policy_key: item.policy_key, note: note)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The sentence that ends up on every disposition event this run appends.
|
|
213
|
+
# It names the plan, so the deletion and the review that authorized it can
|
|
214
|
+
# be read back together.
|
|
215
|
+
def reason
|
|
216
|
+
[sentence(plan.reason), "Applied disposition plan #{plan.id}."].compact.join(" ")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def sentence(text)
|
|
220
|
+
trimmed = text.to_s.strip
|
|
221
|
+
return nil if trimmed.empty?
|
|
222
|
+
|
|
223
|
+
trimmed.end_with?(".", "!", "?") ? trimmed : "#{trimmed}."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def reference_for(actor)
|
|
227
|
+
Reference.actor(actor)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|