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,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# What the application configured, what one actor's evidence contains, and
|
|
5
|
+
# what a disposition of it would look like.
|
|
6
|
+
#
|
|
7
|
+
# ============================================================================
|
|
8
|
+
# THIS MODULE DESCRIBES A CONFIGURATION. Describing a configuration is not the
|
|
9
|
+
# same as the configuration being lawful, proportionate, or adequately
|
|
10
|
+
# justified. `inventory` reads back the fields this application chose to
|
|
11
|
+
# record, the purposes it wrote down, the references it supplied, and the
|
|
12
|
+
# periods it set. It does not evaluate any of them. A complete inventory with
|
|
13
|
+
# every field filled in is a well-documented configuration, and nothing more;
|
|
14
|
+
# the lawful basis, the necessity, the proportionality, the retention period,
|
|
15
|
+
# and the answer to an erasure request all belong to the host application and
|
|
16
|
+
# its counsel.
|
|
17
|
+
# ============================================================================
|
|
18
|
+
#
|
|
19
|
+
# Clickwrap::Privacy.inventory
|
|
20
|
+
# Clickwrap::Privacy.export_for(user, requested_by: current_operator)
|
|
21
|
+
# Clickwrap::Privacy.plan_disposition_for(user, requested_by: operator, because: "DSAR-2026-41")
|
|
22
|
+
module Privacy
|
|
23
|
+
CATEGORIES = %i[ip_address browser_user_agent ip_geolocation].freeze
|
|
24
|
+
|
|
25
|
+
# Used only to tell a host-supplied callback apart from the gem's own
|
|
26
|
+
# declining default when the inventory reports where a decision is made.
|
|
27
|
+
GEM_LIB_ROOT = File.expand_path("..", __dir__)
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# A structured description of what this application configured: every
|
|
31
|
+
# policy, every personal or request-derived field it enables, the stated
|
|
32
|
+
# purpose, the host-supplied legal-basis and DPIA references, the
|
|
33
|
+
# resolver, the encryption state, the retention rule, the host events that
|
|
34
|
+
# cannot currently be resolved, and the review date.
|
|
35
|
+
def inventory
|
|
36
|
+
{
|
|
37
|
+
"describes" => "configuration",
|
|
38
|
+
"generated_at" => Receipt.format_time(Clickwrap.now),
|
|
39
|
+
"gem_version" => Clickwrap::VERSION,
|
|
40
|
+
"defaults" => default_inventory,
|
|
41
|
+
"access_decisions" => access_decisions,
|
|
42
|
+
"policies" => Clickwrap.policies.values.map { |policy| policy_inventory(policy) },
|
|
43
|
+
"retention_classes" => Clickwrap.retention_classes.values.map { |klass| retention_inventory(klass) },
|
|
44
|
+
"unresolved_host_events" => unresolved_host_events,
|
|
45
|
+
"means" => "The fields this application is configured to record, the purposes it wrote " \
|
|
46
|
+
"down, and the periods it set. Clickwrap does not assess whether any of them " \
|
|
47
|
+
"is lawful, necessary, or long enough."
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One actor's evidence, under exactly the authorization and redaction
|
|
52
|
+
# rules a receipt export uses — the same `because:`, the same host
|
|
53
|
+
# callback, the same field-by-field opt-in, and the same recorded access.
|
|
54
|
+
# There is deliberately no privacy-flavored shortcut that reveals more
|
|
55
|
+
# than `Clickwrap.export_receipt` would.
|
|
56
|
+
def export_for(actor, requested_by:, because: nil, include_ip_address: false,
|
|
57
|
+
include_browser_user_agent: false, include_ip_geolocation: false)
|
|
58
|
+
reference = reference_for!(actor)
|
|
59
|
+
|
|
60
|
+
events = Event.for_actor(reference)
|
|
61
|
+
.includes(:statements, :documents, :policy_revision, :request_evidence)
|
|
62
|
+
.chronological
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
"actor_reference" => reference,
|
|
66
|
+
"generated_at" => Receipt.format_time(Clickwrap.now),
|
|
67
|
+
"receipt_count" => events.size,
|
|
68
|
+
"receipts" => events.map do |event|
|
|
69
|
+
Receipt.export(
|
|
70
|
+
Receipt.new(event),
|
|
71
|
+
requested_by: requested_by,
|
|
72
|
+
because: because,
|
|
73
|
+
include_ip_address: include_ip_address,
|
|
74
|
+
include_browser_user_agent: include_browser_user_agent,
|
|
75
|
+
include_ip_geolocation: include_ip_geolocation
|
|
76
|
+
)
|
|
77
|
+
end,
|
|
78
|
+
"current_statements" => current_statements_for(reference),
|
|
79
|
+
"means" => "Every Clickwrap event recorded against this actor reference, rendered as " \
|
|
80
|
+
"receipts with the same redaction rules that apply everywhere else."
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Creates a reviewable disposition plan for one actor, and NOTHING else.
|
|
85
|
+
#
|
|
86
|
+
# It does not delete anything, does not release a hold, does not decide
|
|
87
|
+
# whether an erasure request overrides a retention duty, a legal claim, or
|
|
88
|
+
# a hold, and does not tell the host which of those apply. What it does is
|
|
89
|
+
# put the whole picture in one reviewable place: what exists, what is
|
|
90
|
+
# still inside its retention period, and what is held — so the person who
|
|
91
|
+
# has to make that decision makes it with the facts in front of them, and
|
|
92
|
+
# so their decision is recorded as a plan somebody else can read.
|
|
93
|
+
def plan_disposition_for(actor, requested_by:, because:)
|
|
94
|
+
require_reason!(because)
|
|
95
|
+
reference = reference_for!(actor)
|
|
96
|
+
|
|
97
|
+
items = actor_items(reference)
|
|
98
|
+
held, due = items.partition { |item| item.status == :held }
|
|
99
|
+
|
|
100
|
+
DispositionPlan.create!(
|
|
101
|
+
kind: "actor_privacy",
|
|
102
|
+
disposition_scope: {
|
|
103
|
+
"kind" => "actor_privacy",
|
|
104
|
+
"at" => Receipt.format_time(Clickwrap.now),
|
|
105
|
+
"actor_reference" => reference,
|
|
106
|
+
"items" => due.map(&:to_plan_entry)
|
|
107
|
+
},
|
|
108
|
+
summary: actor_summary(reference, due, held),
|
|
109
|
+
item_count: due.length,
|
|
110
|
+
created_by_reference: reference_for(requested_by),
|
|
111
|
+
reason: because
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# --- Inventory ------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def default_inventory
|
|
120
|
+
config = Clickwrap.config
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
"records_any_request_evidence_by_default" => config.records_any_request_evidence_by_default?,
|
|
124
|
+
"ip_address" => default_category(config, :ip_address),
|
|
125
|
+
"browser_user_agent" => default_category(config, :browser_user_agent),
|
|
126
|
+
"ip_geolocation" => default_category(config, :ip_geolocation).merge(
|
|
127
|
+
"fields" => config.enabled_default_ip_geolocation_fields,
|
|
128
|
+
"resolver" => describe_resolver(config.ip_geolocation_resolver),
|
|
129
|
+
"fail_capture_when_unavailable" => config.fail_capture_when_ip_geolocation_is_unavailable
|
|
130
|
+
),
|
|
131
|
+
"review_default_request_evidence_configuration_on" =>
|
|
132
|
+
config.review_default_request_evidence_configuration_on&.to_s,
|
|
133
|
+
"ip_address_reader" => describe_callback(config.read_ip_address_from_http_request_with),
|
|
134
|
+
"trusted_proxy_configuration_digest" => config.trusted_proxy_configuration_digest,
|
|
135
|
+
"storing_request_evidence_unencrypted" => config.storing_request_evidence_unencrypted?,
|
|
136
|
+
"reason_for_storing_request_evidence_unencrypted" =>
|
|
137
|
+
config.reason_for_storing_request_evidence_unencrypted
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def default_category(config, category)
|
|
142
|
+
{
|
|
143
|
+
"recorded_by_default" => default_recorded?(config, category),
|
|
144
|
+
"because" => config.public_send(:"reason_for_recording_#{plural_for(category)}_by_default"),
|
|
145
|
+
"legal_basis_reference" =>
|
|
146
|
+
config.public_send(:"legal_basis_reference_for_recording_#{plural_for(category)}_by_default"),
|
|
147
|
+
"encrypted" => config.public_send(:"encrypt_recorded_#{plural_for(category)}"),
|
|
148
|
+
"delete_after_seconds" => config.public_send(:"delete_recorded_#{plural_for(category)}_after")&.to_i
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def default_recorded?(config, category)
|
|
153
|
+
case category
|
|
154
|
+
when :ip_address then config.record_ip_address_by_default
|
|
155
|
+
when :browser_user_agent then config.record_browser_user_agent_by_default
|
|
156
|
+
else config.enabled_default_ip_geolocation_fields.any?
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def policy_inventory(policy)
|
|
161
|
+
request_evidence = policy.request_evidence
|
|
162
|
+
retention_class = Clickwrap.retention_classes[policy.retention_class_key.to_s]
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
"policy" => policy.key,
|
|
166
|
+
"retention_class" => policy.retention_class_key,
|
|
167
|
+
"records_any_request_evidence" => request_evidence.records_anything?,
|
|
168
|
+
"fields" => CATEGORIES.to_h do |category|
|
|
169
|
+
[category.to_s, policy_category(policy, category, retention_class)]
|
|
170
|
+
end,
|
|
171
|
+
"review_request_evidence_configuration_on" => request_evidence.review_configuration_on&.to_s,
|
|
172
|
+
"unresolved_host_events" => unresolved_host_events_for(policy, retention_class),
|
|
173
|
+
"persists_presentations_for_seconds" => policy.persist_presentations_for&.to_i,
|
|
174
|
+
"persists_presentations_because" => policy.persist_presentations_because
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# One line per category per policy, with the purpose and the references
|
|
179
|
+
# the host supplied kept as the host's own words. Clickwrap stores them
|
|
180
|
+
# and prints them back; it never rewrites them into something that reads
|
|
181
|
+
# like an assessment it made.
|
|
182
|
+
def policy_category(policy, category, retention_class)
|
|
183
|
+
setting = policy.request_evidence.setting_for(category)
|
|
184
|
+
rule = retention_class&.rule_for(category)
|
|
185
|
+
|
|
186
|
+
entry = {
|
|
187
|
+
"recorded" => setting.record?,
|
|
188
|
+
"because" => setting.because,
|
|
189
|
+
"legal_basis_reference" => setting.legal_basis_reference,
|
|
190
|
+
"data_protection_impact_assessment_reference" =>
|
|
191
|
+
setting.data_protection_impact_assessment_reference,
|
|
192
|
+
"encrypted" => setting.encrypted?,
|
|
193
|
+
"delete_after_seconds" => setting.delete_after&.to_i,
|
|
194
|
+
"retain_until_rule" => setting.retain_until&.to_s,
|
|
195
|
+
"retention_class_rule" => describe_rule(rule),
|
|
196
|
+
"fail_capture_when_unavailable" => setting.fail_if_unavailable?
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return entry unless category == :ip_geolocation
|
|
200
|
+
|
|
201
|
+
entry.merge(
|
|
202
|
+
"fields" => policy.request_evidence.enabled_ip_geolocation_fields,
|
|
203
|
+
"resolver_named_by_policy" => policy.request_evidence.ip_geolocation_resolver_name&.to_s,
|
|
204
|
+
"resolver" => describe_resolver(Clickwrap.config.ip_geolocation_resolver)
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def retention_inventory(retention_class)
|
|
209
|
+
{
|
|
210
|
+
"retention_class" => retention_class.key,
|
|
211
|
+
"rules" => RetentionClass::PARTS.to_h do |part|
|
|
212
|
+
[part.to_s, describe_rule(retention_class.rule_for(part))]
|
|
213
|
+
end
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A rule reads back as what it is: a fixed period, or the name of a host
|
|
218
|
+
# calculation plus whether that calculation is currently registered. An
|
|
219
|
+
# unregistered name is reported rather than smoothed over — it is the
|
|
220
|
+
# difference between "not due yet" and "nothing can ever say when".
|
|
221
|
+
def describe_rule(rule)
|
|
222
|
+
return nil if rule.nil?
|
|
223
|
+
return { "kind" => "duration", "seconds" => rule.duration.to_i } if rule.duration?
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
"kind" => "host_event",
|
|
227
|
+
"host_event" => rule.host_event_name.to_s,
|
|
228
|
+
"calculation_is_registered" => registered_host_events.include?(rule.host_event_name.to_sym)
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def unresolved_host_events
|
|
233
|
+
names = Clickwrap.retention_classes.values.flat_map do |retention_class|
|
|
234
|
+
RetentionClass::PARTS.filter_map do |part|
|
|
235
|
+
rule = retention_class.rule_for(part)
|
|
236
|
+
rule&.host_event? ? rule.host_event_name.to_sym : nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
(names.uniq - registered_host_events).map(&:to_s).sort
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def unresolved_host_events_for(policy, retention_class)
|
|
244
|
+
from_policy = CATEGORIES.filter_map do |category|
|
|
245
|
+
retain_until = policy.request_evidence.setting_for(category).retain_until
|
|
246
|
+
retain_until&.to_sym
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
from_class = RetentionClass::PARTS.filter_map do |part|
|
|
250
|
+
rule = retention_class&.rule_for(part)
|
|
251
|
+
rule&.host_event? ? rule.host_event_name.to_sym : nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
((from_policy + from_class).uniq - registered_host_events).map(&:to_s).sort
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def registered_host_events = Clickwrap.config.retention_time_calculator_names
|
|
258
|
+
|
|
259
|
+
def access_decisions
|
|
260
|
+
config = Clickwrap.config
|
|
261
|
+
|
|
262
|
+
{
|
|
263
|
+
"authorize_receipt_access_with" => describe_callback(config.authorize_receipt_access_with),
|
|
264
|
+
"authorize_unredacted_request_evidence_access_with" =>
|
|
265
|
+
describe_callback(config.authorize_unredacted_request_evidence_access_with),
|
|
266
|
+
"identify_actor_with" => describe_callback(config.identify_actor_with),
|
|
267
|
+
"snapshot_actor_with" => describe_callback(config.snapshot_actor_with)
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Where the decision lives, and whether it is still Clickwrap's own
|
|
272
|
+
# declining default. A file and line number is the most useful thing an
|
|
273
|
+
# inventory can say about a callback: it points at the code somebody has
|
|
274
|
+
# to read to know what the application actually permits.
|
|
275
|
+
def describe_callback(callable)
|
|
276
|
+
location = callable.respond_to?(:source_location) ? callable.source_location : nil
|
|
277
|
+
return { "defined_at" => "unknown" } if location.nil?
|
|
278
|
+
|
|
279
|
+
{
|
|
280
|
+
"defined_at" => location.join(":"),
|
|
281
|
+
"is_clickwrap_default" => location.first.to_s.start_with?(GEM_LIB_ROOT)
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def describe_resolver(resolver)
|
|
286
|
+
return { "configured" => false } if resolver.nil?
|
|
287
|
+
|
|
288
|
+
{ "configured" => true, "class" => resolver.class.name }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def plural_for(category)
|
|
292
|
+
case category
|
|
293
|
+
when :ip_address then "ip_addresses"
|
|
294
|
+
when :browser_user_agent then "browser_user_agents"
|
|
295
|
+
else "ip_geolocation"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# --- Actor export and actor plans ----------------------------------------
|
|
300
|
+
|
|
301
|
+
def current_statements_for(reference)
|
|
302
|
+
StatementState.for_actor(reference).map do |state|
|
|
303
|
+
{
|
|
304
|
+
"policy" => state.policy_key,
|
|
305
|
+
"statement" => state.statement_key,
|
|
306
|
+
"kind" => state.kind,
|
|
307
|
+
"state" => state.state,
|
|
308
|
+
"effective_at" => Receipt.format_time(state.effective_at),
|
|
309
|
+
"expires_at" => Receipt.format_time(state.expires_at)
|
|
310
|
+
}.compact
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def actor_items(reference)
|
|
315
|
+
holds = Retention::Planner::HoldIndex.load
|
|
316
|
+
items = []
|
|
317
|
+
|
|
318
|
+
Event.for_actor(reference).not_disposed.includes(:request_evidence).find_each do |event|
|
|
319
|
+
held = holds.held?(event)
|
|
320
|
+
items << actor_core_item(event, held)
|
|
321
|
+
items.concat(actor_annex_items(event, held))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
items
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def actor_core_item(event, held)
|
|
328
|
+
eligibility = Retention::Planner.core_event_eligibility(event)
|
|
329
|
+
|
|
330
|
+
actor_item(part: :core_event, event: event, record_id: event.id, held: held,
|
|
331
|
+
eligibility: eligibility)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def actor_annex_items(event, held)
|
|
335
|
+
annex = event.request_evidence
|
|
336
|
+
return [] if annex.nil?
|
|
337
|
+
|
|
338
|
+
CATEGORIES.filter_map do |category|
|
|
339
|
+
next if annex.deleted_for?(category)
|
|
340
|
+
next if annex.public_send(:"#{category}_recorded_at").nil?
|
|
341
|
+
|
|
342
|
+
actor_item(part: category, event: event, record_id: annex.id, held: held,
|
|
343
|
+
eligibility: Retention::Planner.annex_eligibility(annex, category))
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Every item carries the retention picture in its own detail line, so the
|
|
348
|
+
# reviewer sees exactly what they would be overriding. Clickwrap states
|
|
349
|
+
# the position; it does not resolve it.
|
|
350
|
+
def actor_item(part:, event:, record_id:, held:, eligibility:)
|
|
351
|
+
Retention::Planner::Item.new(
|
|
352
|
+
part: part,
|
|
353
|
+
status: held ? :held : :due,
|
|
354
|
+
event_id: event.id,
|
|
355
|
+
record_id: record_id,
|
|
356
|
+
policy_key: event.policy_key,
|
|
357
|
+
actor_reference: event.actor_reference,
|
|
358
|
+
retention_class_key: event.retention_class_key,
|
|
359
|
+
eligible_at: eligibility.eligible_at,
|
|
360
|
+
rule: eligibility.rule,
|
|
361
|
+
detail: actor_item_detail(held, eligibility),
|
|
362
|
+
eligibility: "actor_request"
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def actor_item_detail(held, eligibility)
|
|
367
|
+
return "Under a legal hold. Not included in this plan." if held
|
|
368
|
+
return "Its retention rule cannot be resolved: #{eligibility.unresolved_reason}" if eligibility.unresolved?
|
|
369
|
+
|
|
370
|
+
if eligibility.eligible_at > Clickwrap.now
|
|
371
|
+
"Still inside its retention period, which ends #{Receipt.format_time(eligibility.eligible_at)}."
|
|
372
|
+
else
|
|
373
|
+
"Past its retention period, which ended #{Receipt.format_time(eligibility.eligible_at)}."
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def actor_summary(reference, due, held)
|
|
378
|
+
{
|
|
379
|
+
"generated_at" => Receipt.format_time(Clickwrap.now),
|
|
380
|
+
"actor_reference" => reference,
|
|
381
|
+
"due" => due.length,
|
|
382
|
+
"held" => held.length,
|
|
383
|
+
"still_within_retention_period" => due.count { |item| item.eligible_at && item.eligible_at > Clickwrap.now },
|
|
384
|
+
"by_part" => due.group_by { |item| item.part.to_s }.transform_values(&:length),
|
|
385
|
+
"held_examples" => held.first(Retention::Planner::EXAMPLE_LIMIT).map(&:to_plan_entry),
|
|
386
|
+
"means" => "What exists for this actor, what is still inside its retention period, and " \
|
|
387
|
+
"what is under a legal hold. Applying this plan is a decision the host makes; " \
|
|
388
|
+
"Clickwrap does not decide whether an erasure request overrides a retention " \
|
|
389
|
+
"duty, a legal claim, or a hold."
|
|
390
|
+
}
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# --- Shared ---------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def reference_for(actor)
|
|
396
|
+
Reference.actor(actor)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def reference_for!(actor)
|
|
400
|
+
reference = reference_for(actor)
|
|
401
|
+
return reference if reference.present?
|
|
402
|
+
|
|
403
|
+
raise ArgumentError,
|
|
404
|
+
"Clickwrap needs an actor, or the actor reference string recorded in the evidence " \
|
|
405
|
+
"(for example \"gid://my-app/User/123\"). Evidence is keyed by that reference " \
|
|
406
|
+
"precisely so it survives the account row being deleted."
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def require_reason!(because)
|
|
410
|
+
return unless because.to_s.strip.empty?
|
|
411
|
+
|
|
412
|
+
raise LifecycleError,
|
|
413
|
+
"Planning a disposition for an actor needs a `because:` naming the request it " \
|
|
414
|
+
"answers. It is stored on the plan, and it is what tells the next reviewer why " \
|
|
415
|
+
"somebody proposed deleting this."
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Builds the exact result a protected database action records in its
|
|
5
|
+
# evidence event, and re-derives its digest so the recorded claim cannot be
|
|
6
|
+
# edited after the fact. What the digest binds is what the recorder
|
|
7
|
+
# returned — it does not independently verify the recorder told the truth.
|
|
8
|
+
# Hosts provide ordinary canonical facts; Clickwrap owns the stable
|
|
9
|
+
# reference and the digest over the complete claim.
|
|
10
|
+
#
|
|
11
|
+
# Clickwrap.protected_outcome(
|
|
12
|
+
# action: :submitted,
|
|
13
|
+
# record: withdrawal,
|
|
14
|
+
# state: withdrawal.status,
|
|
15
|
+
# facts: {
|
|
16
|
+
# amount_in_cents: withdrawal.amount_cents,
|
|
17
|
+
# currency: withdrawal.currency,
|
|
18
|
+
# destination_reference: withdrawal.destination_reference
|
|
19
|
+
# }
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
# The fingerprint covers the action, record reference, state, and every fact.
|
|
23
|
+
# It detects later changes to that snapshot; it is not a signature, identity
|
|
24
|
+
# proof, trusted timestamp, or substitute for the linked domain record.
|
|
25
|
+
module ProtectedOutcome
|
|
26
|
+
REQUIRED_KEYS = %w[action facts fingerprint reference].freeze
|
|
27
|
+
OPTIONAL_KEYS = %w[state].freeze
|
|
28
|
+
PERMITTED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def build(action:, record:, facts:, state: nil)
|
|
32
|
+
action = required_string(action, "action")
|
|
33
|
+
if record.respond_to?(:persisted?) && !record.persisted?
|
|
34
|
+
raise ArgumentError,
|
|
35
|
+
"A protected outcome needs a persisted `record:` with a stable reference. " \
|
|
36
|
+
"Save the result inside the protected-action block and return it."
|
|
37
|
+
end
|
|
38
|
+
reference = Reference.record(record)
|
|
39
|
+
if reference.blank?
|
|
40
|
+
raise ArgumentError,
|
|
41
|
+
"A protected outcome needs a persisted `record:` with a stable reference."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
normalized_facts = normalize_facts(facts)
|
|
45
|
+
claim = {
|
|
46
|
+
"action" => action,
|
|
47
|
+
"reference" => reference,
|
|
48
|
+
"facts" => normalized_facts
|
|
49
|
+
}
|
|
50
|
+
claim["state"] = required_string(state, "state") unless state.nil?
|
|
51
|
+
claim["fingerprint"] = fingerprint_for(claim)
|
|
52
|
+
claim.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate!(outcome)
|
|
56
|
+
unless outcome.is_a?(Hash)
|
|
57
|
+
raise DefinitionError,
|
|
58
|
+
"A protected-outcome recorder must return `Clickwrap.protected_outcome(...)`, " \
|
|
59
|
+
"which returns a Hash, but it returned #{outcome.class}."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
normalized = outcome.deep_stringify_keys
|
|
63
|
+
unknown = normalized.keys - PERMITTED_KEYS
|
|
64
|
+
missing = REQUIRED_KEYS - normalized.keys
|
|
65
|
+
if unknown.any? || missing.any?
|
|
66
|
+
details = []
|
|
67
|
+
details << "missing #{missing.join(", ")}" if missing.any?
|
|
68
|
+
details << "unknown #{unknown.join(", ")}" if unknown.any?
|
|
69
|
+
raise DefinitionError,
|
|
70
|
+
"The protected outcome has #{details.join("; ")}. Build it with " \
|
|
71
|
+
"`Clickwrap.protected_outcome(action:, record:, facts:, state: nil)` so its " \
|
|
72
|
+
"meaning and fingerprint are complete."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
required_string(normalized["action"], "action")
|
|
76
|
+
required_string(normalized["reference"], "reference")
|
|
77
|
+
required_string(normalized["state"], "state") if normalized.key?("state")
|
|
78
|
+
normalized["facts"] = normalize_facts(normalized["facts"])
|
|
79
|
+
|
|
80
|
+
expected = fingerprint_for(normalized.except("fingerprint"))
|
|
81
|
+
unless Digest.secure_compare?(normalized["fingerprint"], expected)
|
|
82
|
+
raise DefinitionError,
|
|
83
|
+
"The protected outcome fingerprint does not match its action, record reference, " \
|
|
84
|
+
"state, and facts. Build the outcome after the protected action finishes by " \
|
|
85
|
+
"calling `Clickwrap.protected_outcome(...)`."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
normalized
|
|
89
|
+
rescue CanonicalJson::SerializationError => error
|
|
90
|
+
raise DefinitionError,
|
|
91
|
+
"Protected-outcome facts must be canonical JSON values: #{error.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def normalize_facts(facts)
|
|
97
|
+
unless facts.is_a?(Hash) && facts.any?
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"A protected outcome needs a non-empty `facts:` hash containing the exact " \
|
|
100
|
+
"business facts the action committed."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
normalized = facts.deep_stringify_keys
|
|
104
|
+
CanonicalJson.generate(normalized)
|
|
105
|
+
normalized
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def fingerprint_for(claim)
|
|
109
|
+
Digest.digest_canonical(claim)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def required_string(value, name)
|
|
113
|
+
normalized = value.to_s
|
|
114
|
+
return normalized if normalized.present?
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "A protected outcome needs a non-blank `#{name}:`."
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|