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,502 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Retention
|
|
5
|
+
# Builds a reviewable disposition plan. Deletes nothing.
|
|
6
|
+
#
|
|
7
|
+
# plan = Clickwrap::Retention::Planner.new(created_by: current_operator).call
|
|
8
|
+
# plan.summary # => counts by policy, by part, plus held and unresolved
|
|
9
|
+
# plan.id # => the id `clickwrap:retention:apply PLAN=...` needs
|
|
10
|
+
#
|
|
11
|
+
# Disposition is always two steps here: plan, review, then apply. This is
|
|
12
|
+
# the first step, and it is deliberately read-only — an operator can run it
|
|
13
|
+
# on a Friday afternoon without consequence, and the numbers it reports are
|
|
14
|
+
# the numbers the applier will re-derive.
|
|
15
|
+
#
|
|
16
|
+
# Three properties of the report matter more than the totals:
|
|
17
|
+
#
|
|
18
|
+
# * Held records are counted SEPARATELY rather than silently dropped, so
|
|
19
|
+
# an operator who expected 4,000 items and sees 12 can tell that a legal
|
|
20
|
+
# hold is working rather than that the query is broken.
|
|
21
|
+
#
|
|
22
|
+
# * A host-event rule that resolves to nil is UNRESOLVED, never due. "Five
|
|
23
|
+
# years, or three years after this contract is liquidated, whichever is
|
|
24
|
+
# later" has not started its clock until liquidation happens, and a job
|
|
25
|
+
# that treats "we cannot say yet" as "delete it now" destroys regulated
|
|
26
|
+
# evidence early. That failure mode is specific, foreseeable, and the
|
|
27
|
+
# reason this class reports a third category instead of two.
|
|
28
|
+
#
|
|
29
|
+
# * Nothing here decides a retention period. The host and its counsel
|
|
30
|
+
# chose the rules; this reads them back and says what they currently
|
|
31
|
+
# imply.
|
|
32
|
+
class Planner
|
|
33
|
+
KIND = "retention"
|
|
34
|
+
|
|
35
|
+
# Parts of the evidence a plan can cover. The first four are the parts of
|
|
36
|
+
# an event, named exactly as the retention DSL names them; the fifth is a
|
|
37
|
+
# persisted presentation, which is not evidence of an act and ages out on
|
|
38
|
+
# its own schedule.
|
|
39
|
+
PARTS = (RetentionClass::PARTS + %i[presentation]).freeze
|
|
40
|
+
ANNEX_PARTS = %i[ip_address browser_user_agent ip_geolocation].freeze
|
|
41
|
+
|
|
42
|
+
# How many held and unresolved examples travel in the summary. The counts
|
|
43
|
+
# are always complete; the examples exist so an operator can see what kind
|
|
44
|
+
# of thing is being held without opening a console, and are capped so a
|
|
45
|
+
# plan row stays a plan row rather than a data export.
|
|
46
|
+
EXAMPLE_LIMIT = 25
|
|
47
|
+
|
|
48
|
+
# What a rule currently says about one part, with "we cannot say yet"
|
|
49
|
+
# kept as a first-class answer rather than folded into a date.
|
|
50
|
+
Eligibility = Data.define(:eligible_at, :rule, :unresolved_reason) do
|
|
51
|
+
def initialize(eligible_at: nil, rule: nil, unresolved_reason: nil)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resolved? = !eligible_at.nil?
|
|
56
|
+
def unresolved? = eligible_at.nil?
|
|
57
|
+
def due?(at) = resolved? && eligible_at <= at
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One line of a plan. `eligibility` records which question the applier
|
|
61
|
+
# must re-ask: a retention plan re-checks whether the rule still says the
|
|
62
|
+
# item is due, while an actor-request plan re-checks that the item still
|
|
63
|
+
# exists and is not held, because whether an erasure request outranks a
|
|
64
|
+
# retention duty is a decision a human made, not one this gem can make.
|
|
65
|
+
Item = Data.define(:part, :status, :event_id, :record_id, :policy_key, :actor_reference,
|
|
66
|
+
:retention_class_key, :eligible_at, :rule, :detail, :eligibility) do
|
|
67
|
+
def initialize(part:, status:, event_id: nil, record_id: nil, policy_key: nil,
|
|
68
|
+
actor_reference: nil, retention_class_key: nil, eligible_at: nil,
|
|
69
|
+
rule: nil, detail: nil, eligibility: "retention")
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_plan_entry
|
|
74
|
+
{
|
|
75
|
+
"part" => part.to_s,
|
|
76
|
+
"status" => status.to_s,
|
|
77
|
+
"event_id" => event_id,
|
|
78
|
+
"record_id" => record_id&.to_s,
|
|
79
|
+
"policy_key" => policy_key,
|
|
80
|
+
"actor_reference" => actor_reference,
|
|
81
|
+
"retention_class" => retention_class_key,
|
|
82
|
+
"eligible_at" => eligible_at && Receipt.format_time(eligible_at),
|
|
83
|
+
"rule" => rule,
|
|
84
|
+
"detail" => detail,
|
|
85
|
+
"eligibility" => eligibility
|
|
86
|
+
}.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.from_plan_entry(entry)
|
|
90
|
+
entry = entry.to_h
|
|
91
|
+
new(
|
|
92
|
+
part: entry["part"].to_s.to_sym,
|
|
93
|
+
status: entry["status"].to_s.to_sym,
|
|
94
|
+
event_id: entry["event_id"],
|
|
95
|
+
record_id: entry["record_id"],
|
|
96
|
+
policy_key: entry["policy_key"],
|
|
97
|
+
actor_reference: entry["actor_reference"],
|
|
98
|
+
retention_class_key: entry["retention_class"],
|
|
99
|
+
rule: entry["rule"],
|
|
100
|
+
detail: entry["detail"],
|
|
101
|
+
eligibility: entry["eligibility"] || "retention"
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class << self
|
|
107
|
+
# What the core event's rule currently says. Shared with the applier so
|
|
108
|
+
# planning and applying can never disagree about what "due" means.
|
|
109
|
+
#
|
|
110
|
+
# The order matters. A schedule computed at capture (`retain_core_event_until`)
|
|
111
|
+
# wins, because that is what the receipt already told the world. Only
|
|
112
|
+
# when there is none does this fall back to the rule the class names
|
|
113
|
+
# today. That fallback exists for records written by an older Clickwrap
|
|
114
|
+
# version; current writers freeze a schedule onto captures, imports,
|
|
115
|
+
# exemptions, and lifecycle events when each event is written.
|
|
116
|
+
def core_event_eligibility(event)
|
|
117
|
+
return Eligibility.new(eligible_at: event.retain_core_event_until, rule: "recorded_schedule") if
|
|
118
|
+
event.retain_core_event_until.present?
|
|
119
|
+
|
|
120
|
+
return resolve_host_event(event.retention_rule_name, event) if event.retention_rule_name.present?
|
|
121
|
+
|
|
122
|
+
retention_class = Clickwrap.retention_classes[event.retention_class_key.to_s]
|
|
123
|
+
|
|
124
|
+
if retention_class.nil?
|
|
125
|
+
return Eligibility.new(
|
|
126
|
+
rule: "retention_class:#{event.retention_class_key}",
|
|
127
|
+
unresolved_reason: "Retention class #{event.retention_class_key.inspect} is no longer " \
|
|
128
|
+
"defined, so nothing can say when this event is due."
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
rule = retention_class.rule_for(:core_event)
|
|
133
|
+
return Eligibility.new(rule: nil, unresolved_reason: "No core-event rule is defined.") if rule.nil?
|
|
134
|
+
return resolve_host_event(rule.host_event_name, event) if rule.host_event?
|
|
135
|
+
|
|
136
|
+
Eligibility.new(eligible_at: event.recorded_at_by_server + rule.duration,
|
|
137
|
+
rule: "duration:#{rule.duration.to_i}s")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# What one annex field's rule currently says. The row carries its own
|
|
141
|
+
# schedule or its own named rule, recorded when the value was captured,
|
|
142
|
+
# so this reads the row rather than today's policy: the person whose IP
|
|
143
|
+
# address it is was told the period that applied then.
|
|
144
|
+
def annex_eligibility(annex, part)
|
|
145
|
+
delete_after = annex.public_send(:"#{part}_delete_after")
|
|
146
|
+
return Eligibility.new(eligible_at: delete_after, rule: "recorded_schedule") if delete_after.present?
|
|
147
|
+
|
|
148
|
+
rule_name = annex.public_send(:"#{part}_retain_until_rule")
|
|
149
|
+
return resolve_host_event(rule_name, annex.event) if rule_name.present?
|
|
150
|
+
|
|
151
|
+
Eligibility.new(
|
|
152
|
+
rule: nil,
|
|
153
|
+
unresolved_reason: "No deletion rule is recorded for #{part} on this row."
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A host calculation may legitimately return nil ("the triggering event
|
|
158
|
+
# has not happened"), may be unregistered, and may raise — a retention
|
|
159
|
+
# calculation runs the host's own domain code. All three are reported as
|
|
160
|
+
# unresolved with the reason attached, because a disposition run that
|
|
161
|
+
# crashes on one row, or that guesses a date to keep going, is worse
|
|
162
|
+
# than one that says which rows it could not evaluate.
|
|
163
|
+
def resolve_host_event(name, event)
|
|
164
|
+
return Eligibility.new(rule: nil, unresolved_reason: "No rule name recorded.") if name.blank?
|
|
165
|
+
|
|
166
|
+
rule = "host_event:#{name}"
|
|
167
|
+
resolved = Clickwrap.configuration.resolve_retention_time(name, event)
|
|
168
|
+
|
|
169
|
+
if resolved.nil?
|
|
170
|
+
return Eligibility.new(
|
|
171
|
+
rule: rule,
|
|
172
|
+
unresolved_reason: "The host calculation #{name} has not resolved yet, so the " \
|
|
173
|
+
"triggering event has not happened and the clock has not started."
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
Eligibility.new(eligible_at: resolved, rule: rule)
|
|
178
|
+
rescue StandardError => error
|
|
179
|
+
Eligibility.new(
|
|
180
|
+
rule: "host_event:#{name}",
|
|
181
|
+
unresolved_reason: "The host calculation #{name} could not be evaluated (#{error.class})."
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def initialize(at: Clickwrap.now, policy_key: nil, actor_reference: nil, created_by: nil, because: nil)
|
|
187
|
+
@at = at
|
|
188
|
+
@policy_key = policy_key&.to_s
|
|
189
|
+
@actor_reference = reference_for(actor_reference)
|
|
190
|
+
@created_by = created_by
|
|
191
|
+
@because = because
|
|
192
|
+
@items = []
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
attr_reader :at, :policy_key, :actor_reference, :created_by, :because, :items
|
|
196
|
+
|
|
197
|
+
# Builds and persists the plan. Nothing is deleted, nothing is marked, and
|
|
198
|
+
# no state changes anywhere else.
|
|
199
|
+
def call
|
|
200
|
+
SchemaRequirements.require!(:retention_ops)
|
|
201
|
+
|
|
202
|
+
@items = []
|
|
203
|
+
|
|
204
|
+
plan_core_events
|
|
205
|
+
plan_request_evidence
|
|
206
|
+
plan_presentations
|
|
207
|
+
|
|
208
|
+
DispositionPlan.create!(
|
|
209
|
+
kind: KIND,
|
|
210
|
+
disposition_scope: scope_document,
|
|
211
|
+
summary: summary_document,
|
|
212
|
+
item_count: due_items.length,
|
|
213
|
+
created_by_reference: reference_for(created_by),
|
|
214
|
+
reason: because
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def due_items = items.select { |item| item.status == :due }
|
|
219
|
+
def held_items = items.select { |item| item.status == :held }
|
|
220
|
+
def unresolved_items = items.select { |item| item.status == :unresolved }
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# --- The core event -------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
# Three queries rather than one scan of the table.
|
|
227
|
+
#
|
|
228
|
+
# The first uses the schedule written at capture, which is indexed. The
|
|
229
|
+
# second catches rows that never got one — an appended lifecycle event has
|
|
230
|
+
# its class but no computed date — by pushing the class's duration into
|
|
231
|
+
# the WHERE clause instead of loading every event to subtract in Ruby. The
|
|
232
|
+
# third is the only unavoidable scan, and it is bounded to the classes
|
|
233
|
+
# that actually use a host-event rule, because those are precisely the
|
|
234
|
+
# rows whose answer cannot be computed in SQL.
|
|
235
|
+
def plan_core_events
|
|
236
|
+
seen = Set.new
|
|
237
|
+
|
|
238
|
+
core_event_scopes.each do |scope|
|
|
239
|
+
scope.find_each do |event|
|
|
240
|
+
next unless seen.add?(event.id)
|
|
241
|
+
|
|
242
|
+
record_core_event(event)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def core_event_scopes
|
|
248
|
+
scopes = [base_events.where.not(retain_core_event_until: nil).where(retain_core_event_until: ..at)]
|
|
249
|
+
|
|
250
|
+
Clickwrap.retention_classes.each do |retention_class|
|
|
251
|
+
rule = retention_class.rule_for(:core_event)
|
|
252
|
+
next if rule.nil?
|
|
253
|
+
|
|
254
|
+
unscheduled = base_events.where(retain_core_event_until: nil, retention_class_key: retention_class.key)
|
|
255
|
+
|
|
256
|
+
scopes << if rule.duration?
|
|
257
|
+
unscheduled.where(recorded_at_by_server: ...(at - rule.duration))
|
|
258
|
+
else
|
|
259
|
+
unscheduled
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
scopes << base_events.where(retain_core_event_until: nil).where.not(retention_rule_name: nil)
|
|
264
|
+
scopes
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def record_core_event(event)
|
|
268
|
+
eligibility = self.class.core_event_eligibility(event)
|
|
269
|
+
return if eligibility.resolved? && !eligibility.due?(at)
|
|
270
|
+
|
|
271
|
+
record(
|
|
272
|
+
part: :core_event,
|
|
273
|
+
status: status_for(eligibility, held: hold_index.held?(event)),
|
|
274
|
+
event: event,
|
|
275
|
+
record_id: event.id,
|
|
276
|
+
eligibility: eligibility
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# --- The optional request-evidence annex ----------------------------------
|
|
281
|
+
|
|
282
|
+
# Same reasoning as presentations: an installation can have the retention
|
|
283
|
+
# tables and not the annex, because they are separate capabilities behind
|
|
284
|
+
# separate flags. Nothing was ever recorded, so nothing is due.
|
|
285
|
+
def plan_request_evidence
|
|
286
|
+
return unless SchemaRequirements.available?(:request_evidence)
|
|
287
|
+
|
|
288
|
+
ANNEX_PARTS.each do |part|
|
|
289
|
+
seen = Set.new
|
|
290
|
+
|
|
291
|
+
annex_scopes(part).each do |scope|
|
|
292
|
+
scope.find_each do |annex|
|
|
293
|
+
next unless seen.add?(annex.id)
|
|
294
|
+
next if annex.event.nil?
|
|
295
|
+
|
|
296
|
+
record_annex_field(annex, part)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def annex_scopes(part)
|
|
303
|
+
[
|
|
304
|
+
base_annexes.public_send(:"with_#{part}_due", at),
|
|
305
|
+
base_annexes.where("#{part}_deleted_at": nil, "#{part}_delete_after": nil)
|
|
306
|
+
.where.not("#{part}_retain_until_rule": nil)
|
|
307
|
+
]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def record_annex_field(annex, part)
|
|
311
|
+
eligibility = self.class.annex_eligibility(annex, part)
|
|
312
|
+
return if eligibility.resolved? && !eligibility.due?(at)
|
|
313
|
+
|
|
314
|
+
record(
|
|
315
|
+
part: part,
|
|
316
|
+
status: status_for(eligibility, held: hold_index.held?(annex.event)),
|
|
317
|
+
event: annex.event,
|
|
318
|
+
record_id: annex.id,
|
|
319
|
+
eligibility: eligibility
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# --- Persisted presentations ---------------------------------------------
|
|
324
|
+
|
|
325
|
+
# A presentation row is not evidence of an act: it is the manifest the
|
|
326
|
+
# server offered. The one attached to a capture belongs to that event and
|
|
327
|
+
# is never touched here — `where.missing(:events)` is what keeps a
|
|
328
|
+
# retention run from deleting the manifest a receipt cites. What is left
|
|
329
|
+
# is the pre-submit rows a policy chose to retain, and expired offers
|
|
330
|
+
# nobody ever submitted.
|
|
331
|
+
# An installation can have the retention tables without the persisted
|
|
332
|
+
# presentations one — they are separate capabilities behind separate
|
|
333
|
+
# flags. Nothing was ever retained here, so there is nothing to dispose
|
|
334
|
+
# of, and asking the database would only raise about a table this
|
|
335
|
+
# installation deliberately does not have.
|
|
336
|
+
def plan_presentations
|
|
337
|
+
return unless SchemaRequirements.available?(:persisted_presentations)
|
|
338
|
+
|
|
339
|
+
seen = Set.new
|
|
340
|
+
|
|
341
|
+
presentation_scopes.each do |scope|
|
|
342
|
+
scope.where.missing(:events).find_each do |presentation|
|
|
343
|
+
next unless seen.add?(presentation.id)
|
|
344
|
+
|
|
345
|
+
record(
|
|
346
|
+
part: :presentation,
|
|
347
|
+
status: :due,
|
|
348
|
+
event: nil,
|
|
349
|
+
record_id: presentation.id,
|
|
350
|
+
policy_key_override: presentation.policy_key,
|
|
351
|
+
actor_reference_override: presentation.actor_reference,
|
|
352
|
+
eligibility: Eligibility.new(
|
|
353
|
+
eligible_at: presentation.retain_until || presentation.expires_at,
|
|
354
|
+
rule: presentation.retain_until ? "recorded_schedule" : "presentation_expiry"
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def presentation_scopes
|
|
362
|
+
scope = Presentation.all
|
|
363
|
+
scope = scope.where(policy_key: policy_key) if policy_key
|
|
364
|
+
scope = scope.where(actor_reference: actor_reference) if actor_reference
|
|
365
|
+
|
|
366
|
+
[
|
|
367
|
+
scope.due_for_disposition(at),
|
|
368
|
+
scope.pending.where(retain_until: nil).expired_at(at)
|
|
369
|
+
]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# --- Assembling the plan --------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def base_events
|
|
375
|
+
# A disposition event is the retained tombstone that makes an earlier
|
|
376
|
+
# deletion explainable. Planning it for disposition would destroy that
|
|
377
|
+
# explanation and produce an endless disposition-of-disposition chain.
|
|
378
|
+
scope = Event.not_disposed.where.not(event_type: "disposition")
|
|
379
|
+
scope = scope.for_policy(policy_key) if policy_key
|
|
380
|
+
scope = scope.for_actor(actor_reference) if actor_reference
|
|
381
|
+
scope
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# `preload` rather than `includes`, because this relation also joins the
|
|
385
|
+
# same table to filter on it, and a single query doing both would have to
|
|
386
|
+
# disambiguate the alias. Two queries and no ambiguity is the better
|
|
387
|
+
# trade for a batch job.
|
|
388
|
+
def base_annexes
|
|
389
|
+
scope = RequestEvidence.preload(:event).joins(:event)
|
|
390
|
+
scope = scope.where(clickwrap_events: { policy_key: policy_key }) if policy_key
|
|
391
|
+
scope = scope.where(clickwrap_events: { actor_reference: actor_reference }) if actor_reference
|
|
392
|
+
scope
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def hold_index
|
|
396
|
+
@hold_index ||= HoldIndex.load
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def status_for(eligibility, held:)
|
|
400
|
+
return :unresolved if eligibility.unresolved?
|
|
401
|
+
return :held if held
|
|
402
|
+
|
|
403
|
+
:due
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def record(part:, status:, event:, record_id:, eligibility:,
|
|
407
|
+
policy_key_override: nil, actor_reference_override: nil)
|
|
408
|
+
@items << Item.new(
|
|
409
|
+
part: part,
|
|
410
|
+
status: status,
|
|
411
|
+
event_id: event&.id,
|
|
412
|
+
record_id: record_id,
|
|
413
|
+
policy_key: policy_key_override || event&.policy_key,
|
|
414
|
+
actor_reference: actor_reference_override || event&.actor_reference,
|
|
415
|
+
retention_class_key: event&.retention_class_key,
|
|
416
|
+
eligible_at: eligibility.eligible_at,
|
|
417
|
+
rule: eligibility.rule,
|
|
418
|
+
detail: eligibility.unresolved_reason
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# The exact set an operator reviewed. The applier reads `items` back and
|
|
423
|
+
# re-checks every one of them; the plan is the record of what was agreed
|
|
424
|
+
# to, not an instruction the applier follows blindly.
|
|
425
|
+
def scope_document
|
|
426
|
+
{
|
|
427
|
+
"kind" => KIND,
|
|
428
|
+
"at" => Receipt.format_time(at),
|
|
429
|
+
"policy_key" => policy_key,
|
|
430
|
+
"actor_reference" => actor_reference,
|
|
431
|
+
"items" => due_items.map(&:to_plan_entry)
|
|
432
|
+
}.compact
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def summary_document
|
|
436
|
+
{
|
|
437
|
+
"generated_at" => Receipt.format_time(Clickwrap.now),
|
|
438
|
+
"evaluated_at" => Receipt.format_time(at),
|
|
439
|
+
"due" => due_items.length,
|
|
440
|
+
"held" => held_items.length,
|
|
441
|
+
"unresolved" => unresolved_items.length,
|
|
442
|
+
"by_part" => counts_by(&:part),
|
|
443
|
+
"by_policy" => counts_by { |item| item.policy_key || "(none)" },
|
|
444
|
+
"held_examples" => held_items.first(EXAMPLE_LIMIT).map(&:to_plan_entry),
|
|
445
|
+
"unresolved_examples" => unresolved_items.first(EXAMPLE_LIMIT).map(&:to_plan_entry),
|
|
446
|
+
"means" => "What each rule currently says is due, held, or not yet resolvable. Nothing " \
|
|
447
|
+
"has been deleted, and Clickwrap did not choose any of these periods."
|
|
448
|
+
}
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Counts are always broken out three ways, and a zero is printed rather
|
|
452
|
+
# than omitted: "0 held" and "held is missing from the report" are
|
|
453
|
+
# different statements, and only one of them is reassuring.
|
|
454
|
+
def counts_by
|
|
455
|
+
items.group_by { |item| yield(item).to_s }.transform_values do |group|
|
|
456
|
+
{
|
|
457
|
+
"due" => group.count { |item| item.status == :due },
|
|
458
|
+
"held" => group.count { |item| item.status == :held },
|
|
459
|
+
"unresolved" => group.count { |item| item.status == :unresolved }
|
|
460
|
+
}
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def reference_for(actor)
|
|
465
|
+
Reference.actor(actor)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Every legal hold currently in effect, loaded once.
|
|
469
|
+
#
|
|
470
|
+
# Holds are few and disposition runs touch many rows, so the alternative —
|
|
471
|
+
# three existence queries per candidate — would make the safety check the
|
|
472
|
+
# expensive part of the job, and an expensive safety check is one someone
|
|
473
|
+
# eventually turns off.
|
|
474
|
+
class HoldIndex
|
|
475
|
+
def self.load
|
|
476
|
+
holds = LegalHold.in_effect.to_a
|
|
477
|
+
|
|
478
|
+
new(
|
|
479
|
+
event_ids: holds.select { |hold| hold.hold_scope == "event" }.map(&:event_id).compact.to_set,
|
|
480
|
+
actor_references: holds.select { |hold| hold.hold_scope == "actor" }.map(&:actor_reference).compact.to_set,
|
|
481
|
+
policy_keys: holds.select { |hold| hold.hold_scope == "policy" }.map(&:policy_key).compact.to_set
|
|
482
|
+
)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def initialize(event_ids:, actor_references:, policy_keys:)
|
|
486
|
+
@event_ids = event_ids
|
|
487
|
+
@actor_references = actor_references
|
|
488
|
+
@policy_keys = policy_keys
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def held?(event)
|
|
492
|
+
return false if event.nil?
|
|
493
|
+
|
|
494
|
+
event.on_legal_hold? ||
|
|
495
|
+
@event_ids.include?(event.id) ||
|
|
496
|
+
@actor_references.include?(event.actor_reference) ||
|
|
497
|
+
@policy_keys.include?(event.policy_key)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# An application-defined retention class: how long each part of an event is
|
|
5
|
+
# kept, and what triggers the clock.
|
|
6
|
+
#
|
|
7
|
+
# Clickwrap.retention :ordinary_agreement_evidence do
|
|
8
|
+
# retain_core_event_for 6.years
|
|
9
|
+
# delete_recorded_ip_address_after 90.days
|
|
10
|
+
# delete_recorded_browser_user_agent_after 90.days
|
|
11
|
+
# delete_recorded_ip_geolocation_after 90.days
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Clickwrap does not choose these periods and cannot tell you whether yours
|
|
15
|
+
# are right. What it does is make a reviewed decision executable and
|
|
16
|
+
# auditable, keep the core event's schedule separate from the optional
|
|
17
|
+
# personal request evidence, and delegate event-based or "later of" rules to
|
|
18
|
+
# a named host calculation. The host owns that calculation because a fixed
|
|
19
|
+
# duration cannot express "five years, or three years after this contract is
|
|
20
|
+
# liquidated, whichever is later" without application domain state.
|
|
21
|
+
class RetentionClass
|
|
22
|
+
PARTS = %i[core_event ip_address browser_user_agent ip_geolocation].freeze
|
|
23
|
+
|
|
24
|
+
# A rule is either a duration from the event's server-recorded time, or the
|
|
25
|
+
# name of a host-registered calculation that may depend on domain state and
|
|
26
|
+
# may not be resolvable yet.
|
|
27
|
+
Rule = Data.define(:part, :duration, :host_event_name) do
|
|
28
|
+
def initialize(part:, duration: nil, host_event_name: nil)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def duration? = !duration.nil?
|
|
33
|
+
def host_event? = !host_event_name.nil?
|
|
34
|
+
|
|
35
|
+
def to_snapshot
|
|
36
|
+
{ "duration_seconds" => duration&.to_i, "host_event" => host_event_name&.to_s }.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :key, :rules
|
|
41
|
+
|
|
42
|
+
def initialize(key:, rules:)
|
|
43
|
+
@key = key.to_s
|
|
44
|
+
@rules = rules.freeze
|
|
45
|
+
|
|
46
|
+
validate!
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rule_for(part) = rules[part.to_sym]
|
|
51
|
+
|
|
52
|
+
def to_snapshot
|
|
53
|
+
{
|
|
54
|
+
"key" => key,
|
|
55
|
+
"rules" => rules.to_h { |part, rule| [part.to_s, rule.to_snapshot] }
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate!
|
|
62
|
+
unknown = rules.keys - PARTS
|
|
63
|
+
unless unknown.empty?
|
|
64
|
+
raise DefinitionError,
|
|
65
|
+
"Retention class #{key} declares rules for #{unknown.join(", ")}, which are not " \
|
|
66
|
+
"parts of an event. Choose from: #{PARTS.join(", ")}."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
rules.each_value do |rule|
|
|
70
|
+
if rule.duration? == rule.host_event?
|
|
71
|
+
raise DefinitionError,
|
|
72
|
+
"Retention class #{key} must give #{rule.part} exactly one schedule: a " \
|
|
73
|
+
"duration or a named host calculation, not both or neither."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if rule.host_event? && rule.host_event_name.to_s.strip.empty?
|
|
77
|
+
raise DefinitionError,
|
|
78
|
+
"Retention class #{key} gives #{rule.part} a blank host calculation name. " \
|
|
79
|
+
"Name the calculation registered with `config.calculate_retention_time_for`."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
next unless rule.duration? && rule.duration.to_i <= 0
|
|
83
|
+
|
|
84
|
+
raise DefinitionError,
|
|
85
|
+
"Retention class #{key} keeps #{rule.part} for #{rule.duration.inspect}, which is " \
|
|
86
|
+
"not a period."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return if rules.key?(:core_event)
|
|
90
|
+
|
|
91
|
+
raise DefinitionError,
|
|
92
|
+
"Retention class #{key} never says how long to keep the core event. Use " \
|
|
93
|
+
"`retain_core_event_for 6.years` or `retain_core_event_until :your_host_event`. " \
|
|
94
|
+
"Clickwrap has no forever default, and it will not pick a period for you."
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Detects scaffolding language that must never be mistaken for a reviewed
|
|
5
|
+
# operational reason. This deliberately does not reject the lowercase word
|
|
6
|
+
# "todo": it is an ordinary Spanish word. It catches conventional developer
|
|
7
|
+
# markers and generated English placeholders instead.
|
|
8
|
+
module ReviewedText
|
|
9
|
+
PLACEHOLDER_PATTERNS = [
|
|
10
|
+
/\A(?:TODO|TBD|FIXME)(?:\b|:)/,
|
|
11
|
+
/replace (?:this|with|me)/i,
|
|
12
|
+
/your reviewed (?:purpose|reason|decision)/i,
|
|
13
|
+
/placeholder/i,
|
|
14
|
+
/\A\.{3}\z/
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def placeholder?(value)
|
|
20
|
+
text = value.to_s.strip
|
|
21
|
+
text.present? && PLACEHOLDER_PATTERNS.any? { |pattern| pattern.match?(text) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def present_and_reviewed?(value)
|
|
25
|
+
value.to_s.strip.present? && !placeholder?(value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|