clickwrap 0.0.0 → 0.1.1
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 +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A record of who read a receipt, what the export revealed, and why.
|
|
5
|
+
#
|
|
6
|
+
# Unredacted request evidence needs host authorization plus a human-readable
|
|
7
|
+
# reason, and asking for it appends a row here. The table is plain and
|
|
8
|
+
# queryable on purpose: an access log nobody can read is not much of a
|
|
9
|
+
# control.
|
|
10
|
+
class ReceiptAccess < ApplicationRecord
|
|
11
|
+
self.table_name = "clickwrap_receipt_accesses"
|
|
12
|
+
self.record_timestamps = false
|
|
13
|
+
|
|
14
|
+
CHANNELS = %w[api web export task].freeze
|
|
15
|
+
|
|
16
|
+
belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :accesses
|
|
17
|
+
|
|
18
|
+
validates :event_id, :accessed_at, presence: true
|
|
19
|
+
validates :access_channel, inclusion: { in: CHANNELS }
|
|
20
|
+
|
|
21
|
+
before_update :refuse_update
|
|
22
|
+
before_destroy :refuse_destroy, prepend: true
|
|
23
|
+
|
|
24
|
+
scope :recent_first, -> { order(accessed_at: :desc) }
|
|
25
|
+
|
|
26
|
+
def self.record!(event:, requested_by:, because:, included_fields:, access_channel: "api")
|
|
27
|
+
create!(
|
|
28
|
+
event_id: event.is_a?(String) ? event : event.id,
|
|
29
|
+
requested_by_reference: Reference.actor(requested_by),
|
|
30
|
+
reason: because,
|
|
31
|
+
included_fields: included_fields,
|
|
32
|
+
access_channel: access_channel,
|
|
33
|
+
accessed_at: Clickwrap.now,
|
|
34
|
+
created_at: Clickwrap.now
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s = "access to #{event_id} by #{requested_by_reference}"
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def refuse_update
|
|
43
|
+
raise ImmutableEvidenceError,
|
|
44
|
+
"Receipt access records cannot be updated through Clickwrap. " \
|
|
45
|
+
"Record a new access instead of editing this one."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def refuse_destroy
|
|
49
|
+
raise ImmutableEvidenceError,
|
|
50
|
+
"Receipt access records cannot be destroyed through Clickwrap."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# One database-assigned number for each evidence event. The row exists so the
|
|
5
|
+
# database's native auto-increment/sequence mechanism—not an application
|
|
6
|
+
# clock, process-local counter, or lexicographically sortable public ID—owns
|
|
7
|
+
# the ordering. Precisely: the number is allocated at INSERT time, not at
|
|
8
|
+
# COMMIT time, so two concurrent transactions can commit in the opposite
|
|
9
|
+
# order of their numbers. Within one statement identity the actor lock makes
|
|
10
|
+
# allocation order and commit order agree — which is the guarantee
|
|
11
|
+
# `recorded_after?` relies on. Do not build an outbox or cursor pagination
|
|
12
|
+
# on this column; it is an ordering key for evidence questions, not a
|
|
13
|
+
# commit-ordered feed.
|
|
14
|
+
#
|
|
15
|
+
# Gaps are expected when a transaction rolls back. Ordering asks only whether
|
|
16
|
+
# one committed event's number is greater than another's; it never assumes
|
|
17
|
+
# numbers are contiguous or reveals them as the public event identifier.
|
|
18
|
+
class RecordingSequence < ApplicationRecord
|
|
19
|
+
self.table_name = "clickwrap_recording_sequences"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The optional request-evidence annex: IP address, browser user-agent, and
|
|
5
|
+
# provider-estimated IP geolocation.
|
|
6
|
+
#
|
|
7
|
+
# None of it is recorded unless a policy names the field. It lives in its own
|
|
8
|
+
# table, apart from the core event payload, for one specific reason: personal
|
|
9
|
+
# request evidence needs its own deletion schedule, and welding it into the
|
|
10
|
+
# event would force a choice between ignoring a lawful deletion request and
|
|
11
|
+
# destroying the historical record of an agreement. Here the annex can go away
|
|
12
|
+
# on its own clock while the agreement it accompanied stays intact and
|
|
13
|
+
# verifiable, and the deletion is itself recorded.
|
|
14
|
+
#
|
|
15
|
+
# What is stored here is bounded by what it can honestly support:
|
|
16
|
+
#
|
|
17
|
+
# * an IP address is a network observation, not a person;
|
|
18
|
+
# * IP geolocation is a provider's estimate about that address, not a
|
|
19
|
+
# physical location, not GPS, and not proof anyone was there;
|
|
20
|
+
# * the User-Agent header is whatever the client chose to send.
|
|
21
|
+
#
|
|
22
|
+
# Every stored geolocation value therefore carries its provenance in the same
|
|
23
|
+
# row. Coordinates without an accuracy radius, or a country without knowing
|
|
24
|
+
# which provider guessed it, read as far more certain than they are.
|
|
25
|
+
class RequestEvidence < ApplicationRecord
|
|
26
|
+
self.table_name = "clickwrap_request_evidence"
|
|
27
|
+
self.record_timestamps = false
|
|
28
|
+
|
|
29
|
+
CATEGORIES = %i[ip_address browser_user_agent ip_geolocation].freeze
|
|
30
|
+
|
|
31
|
+
IP_GEOLOCATION_VALUE_COLUMNS = %w[
|
|
32
|
+
ip_geolocation_country_code
|
|
33
|
+
ip_geolocation_country_name
|
|
34
|
+
ip_geolocation_region_name
|
|
35
|
+
ip_geolocation_region_code
|
|
36
|
+
ip_geolocation_city_name
|
|
37
|
+
ip_geolocation_postal_code
|
|
38
|
+
ip_geolocation_latitude
|
|
39
|
+
ip_geolocation_longitude
|
|
40
|
+
ip_geolocation_timezone
|
|
41
|
+
ip_geolocation_continent_code
|
|
42
|
+
ip_geolocation_metro_code
|
|
43
|
+
ip_geolocation_accuracy_radius_in_kilometers
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
VALUE_COLUMNS_BY_CATEGORY = {
|
|
47
|
+
ip_address: %w[ip_address_ciphertext].freeze,
|
|
48
|
+
browser_user_agent: %w[browser_user_agent_ciphertext].freeze,
|
|
49
|
+
ip_geolocation: IP_GEOLOCATION_VALUE_COLUMNS
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Every category gets its own HMAC. A single whole-annex HMAC cannot be
|
|
53
|
+
# recomputed after one category is lawfully deleted; treating that expected
|
|
54
|
+
# mismatch as acceptable would also stop us detecting a later edit to a
|
|
55
|
+
# category that was *not* deleted. Independent bindings let one category be
|
|
56
|
+
# disposed of while every retained category keeps verifying.
|
|
57
|
+
COMMON_BINDING_COLUMNS = %w[event_id authorized_fields created_at].freeze
|
|
58
|
+
BINDING_COLUMNS_BY_CATEGORY = {
|
|
59
|
+
ip_address: %w[
|
|
60
|
+
ip_address_ciphertext ip_address_reader_name trusted_proxy_configuration_digest
|
|
61
|
+
ip_address_recorded_at ip_address_delete_after ip_address_retain_until_rule
|
|
62
|
+
ip_address_deleted_at ip_address_unavailable_reason
|
|
63
|
+
],
|
|
64
|
+
browser_user_agent: %w[
|
|
65
|
+
browser_user_agent_ciphertext browser_user_agent_was_client_supplied
|
|
66
|
+
browser_user_agent_recorded_at browser_user_agent_delete_after
|
|
67
|
+
browser_user_agent_retain_until_rule browser_user_agent_deleted_at
|
|
68
|
+
browser_user_agent_unavailable_reason
|
|
69
|
+
],
|
|
70
|
+
ip_geolocation: %w[
|
|
71
|
+
ip_geolocation_country_code ip_geolocation_country_name
|
|
72
|
+
ip_geolocation_region_name ip_geolocation_region_code ip_geolocation_city_name
|
|
73
|
+
ip_geolocation_postal_code ip_geolocation_latitude ip_geolocation_longitude
|
|
74
|
+
ip_geolocation_timezone ip_geolocation_continent_code ip_geolocation_metro_code
|
|
75
|
+
ip_geolocation_provider_name ip_geolocation_provider_source
|
|
76
|
+
ip_geolocation_database_version ip_geolocation_database_sha256
|
|
77
|
+
ip_geolocation_accuracy_radius_in_kilometers
|
|
78
|
+
ip_geolocation_accuracy_radius_confidence_percentage ip_geolocation_was_estimated
|
|
79
|
+
ip_geolocation_source_was_verified_by_host ip_geolocation_resolved_at
|
|
80
|
+
ip_geolocation_unavailable_reason ip_geolocation_recorded_at
|
|
81
|
+
ip_geolocation_delete_after ip_geolocation_retain_until_rule
|
|
82
|
+
ip_geolocation_deleted_at
|
|
83
|
+
]
|
|
84
|
+
}.transform_values(&:freeze).freeze
|
|
85
|
+
BINDING_COLUMNS = (COMMON_BINDING_COLUMNS + BINDING_COLUMNS_BY_CATEGORY.values.flatten).uniq.freeze
|
|
86
|
+
|
|
87
|
+
belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :request_evidence
|
|
88
|
+
|
|
89
|
+
validates :event_id, presence: true, uniqueness: true
|
|
90
|
+
|
|
91
|
+
before_save :ensure_encryption_is_possible
|
|
92
|
+
before_update :refuse_ordinary_update
|
|
93
|
+
before_destroy :refuse_destroy, prepend: true
|
|
94
|
+
|
|
95
|
+
# Application-layer encryption, on by default.
|
|
96
|
+
#
|
|
97
|
+
# Applied from the engine's `to_prepare` rather than declared here, because
|
|
98
|
+
# whether to encrypt is a host decision (`encrypt_recorded_ip_addresses` and
|
|
99
|
+
# friends) and a declaration in the class body would apply before the
|
|
100
|
+
# initializer has been read. The two raw columns are named `_ciphertext` so
|
|
101
|
+
# that a developer reading the schema, a database dump, or a query result
|
|
102
|
+
# can tell at a glance that the plain value is not supposed to be there.
|
|
103
|
+
#
|
|
104
|
+
# The geolocation VALUE columns are encrypted too, when the host asks for
|
|
105
|
+
# it. The provenance columns beside them — provider name, database version,
|
|
106
|
+
# accuracy radius, resolution time — are not: they say how certain the
|
|
107
|
+
# values are rather than what they are, they are what `clickwrap:doctor` and
|
|
108
|
+
# the privacy inventory read, and encrypting them would hide the uncertainty
|
|
109
|
+
# while leaving the estimate itself just as sensitive.
|
|
110
|
+
#
|
|
111
|
+
# A country code is lower precision than a coordinate, but it is still
|
|
112
|
+
# personal data once it is attached to an identified actor and an event, so
|
|
113
|
+
# it is in this list rather than treated as harmless.
|
|
114
|
+
ENCRYPTED_COLUMNS = {
|
|
115
|
+
ip_address_ciphertext: :encrypt_recorded_ip_addresses,
|
|
116
|
+
browser_user_agent_ciphertext: :encrypt_recorded_browser_user_agents,
|
|
117
|
+
ip_geolocation_country_code: :encrypt_recorded_ip_geolocation,
|
|
118
|
+
ip_geolocation_country_name: :encrypt_recorded_ip_geolocation,
|
|
119
|
+
ip_geolocation_region_name: :encrypt_recorded_ip_geolocation,
|
|
120
|
+
ip_geolocation_region_code: :encrypt_recorded_ip_geolocation,
|
|
121
|
+
ip_geolocation_city_name: :encrypt_recorded_ip_geolocation,
|
|
122
|
+
ip_geolocation_postal_code: :encrypt_recorded_ip_geolocation,
|
|
123
|
+
ip_geolocation_latitude: :encrypt_recorded_ip_geolocation,
|
|
124
|
+
ip_geolocation_longitude: :encrypt_recorded_ip_geolocation,
|
|
125
|
+
ip_geolocation_timezone: :encrypt_recorded_ip_geolocation,
|
|
126
|
+
ip_geolocation_continent_code: :encrypt_recorded_ip_geolocation,
|
|
127
|
+
ip_geolocation_metro_code: :encrypt_recorded_ip_geolocation
|
|
128
|
+
}.freeze
|
|
129
|
+
|
|
130
|
+
# Declaring an encrypted attribute reads the column, so this can only run
|
|
131
|
+
# where the table exists. On an installation that records no request
|
|
132
|
+
# evidence the annex table is not created at all — and there is nothing to
|
|
133
|
+
# encrypt, because there is nothing to store. It is applied again at the
|
|
134
|
+
# moment an annex is actually built, so an application whose connection was
|
|
135
|
+
# not up at boot still encrypts everything it was told to.
|
|
136
|
+
def self.apply_configured_encryption!
|
|
137
|
+
return unless respond_to?(:encrypts)
|
|
138
|
+
return unless annex_table_exists?
|
|
139
|
+
|
|
140
|
+
wanted = ENCRYPTED_COLUMNS.select { |_, setting| Clickwrap.config.public_send(setting) }.keys
|
|
141
|
+
return if wanted.empty?
|
|
142
|
+
|
|
143
|
+
already = (encrypted_attributes || []).map(&:to_sym)
|
|
144
|
+
(wanted - already).each { |column| encrypts column }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.annex_table_exists?
|
|
148
|
+
connection.data_source_exists?(table_name)
|
|
149
|
+
rescue StandardError
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Whether this application has Active Record encryption keys at all.
|
|
154
|
+
# Reading the key raises when it is unset, which is why this is a probe
|
|
155
|
+
# rather than a plain read.
|
|
156
|
+
def self.encryption_keys_available?
|
|
157
|
+
encryption = ::ActiveRecord::Encryption.config
|
|
158
|
+
encryption.primary_key.present? && encryption.key_derivation_salt.present?
|
|
159
|
+
rescue StandardError
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Checked when something is actually about to be encrypted, NOT at boot.
|
|
164
|
+
#
|
|
165
|
+
# That distinction matters more than it looks. Adding this gem to an
|
|
166
|
+
# application must never stop it from booting, and most applications
|
|
167
|
+
# record no request evidence at all — so a boot-time key check would fail
|
|
168
|
+
# installations that were never going to encrypt anything, before the
|
|
169
|
+
# developer had a chance to run the installer or generate a key. A host
|
|
170
|
+
# that does collect this data gets the sentence below the first time it
|
|
171
|
+
# tries, and `clickwrap:doctor` reports the missing keys before that.
|
|
172
|
+
def ensure_encryption_is_possible
|
|
173
|
+
encrypted = self.class.encrypted_attributes.to_a.map(&:to_sym)
|
|
174
|
+
return if encrypted.none? { |column| self[column].present? }
|
|
175
|
+
return if self.class.encryption_keys_available?
|
|
176
|
+
|
|
177
|
+
raise ConfigurationError,
|
|
178
|
+
"Clickwrap is about to record request evidence it is configured to encrypt, but " \
|
|
179
|
+
"this application has no Active Record encryption keys. Generate them with " \
|
|
180
|
+
"`bin/rails db:encryption:init` and add them to your credentials — or, if storing " \
|
|
181
|
+
"these values in plain text is a reviewed decision, say so explicitly with " \
|
|
182
|
+
"`config.deliberately_store_request_evidence_unencrypted!(because: \"...\")` and " \
|
|
183
|
+
"set the matching `encrypt_recorded_*` settings to false."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
scope :with_ip_address_due, lambda { |at = Clickwrap.now|
|
|
187
|
+
where(ip_address_deleted_at: nil).where.not(ip_address_delete_after: nil)
|
|
188
|
+
.where(ip_address_delete_after: ..at)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
scope :with_browser_user_agent_due, lambda { |at = Clickwrap.now|
|
|
192
|
+
where(browser_user_agent_deleted_at: nil).where.not(browser_user_agent_delete_after: nil)
|
|
193
|
+
.where(browser_user_agent_delete_after: ..at)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
scope :with_ip_geolocation_due, lambda { |at = Clickwrap.now|
|
|
197
|
+
where(ip_geolocation_deleted_at: nil).where.not(ip_geolocation_delete_after: nil)
|
|
198
|
+
.where(ip_geolocation_delete_after: ..at)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# --- What was actually recorded ------------------------------------------
|
|
202
|
+
|
|
203
|
+
def recorded_ip_address? = ip_address_recorded_at.present? && ip_address_deleted_at.nil?
|
|
204
|
+
def recorded_browser_user_agent? = browser_user_agent_recorded_at.present? && browser_user_agent_deleted_at.nil?
|
|
205
|
+
def recorded_ip_geolocation? = ip_geolocation_recorded_at.present? && ip_geolocation_deleted_at.nil?
|
|
206
|
+
|
|
207
|
+
def ip_address_was_deleted? = ip_address_deleted_at.present?
|
|
208
|
+
def browser_user_agent_was_deleted? = browser_user_agent_deleted_at.present?
|
|
209
|
+
def ip_geolocation_was_deleted? = ip_geolocation_deleted_at.present?
|
|
210
|
+
|
|
211
|
+
def ip_geolocation_was_estimated? = ip_geolocation_was_estimated
|
|
212
|
+
def ip_geolocation_source_was_verified_by_host? = ip_geolocation_source_was_verified_by_host
|
|
213
|
+
def browser_user_agent_was_client_supplied? = browser_user_agent_was_client_supplied
|
|
214
|
+
|
|
215
|
+
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.each do |field|
|
|
216
|
+
define_method(:"recorded_ip_geolocation_#{field}?") do
|
|
217
|
+
return false unless recorded_ip_geolocation?
|
|
218
|
+
|
|
219
|
+
authorized_ip_geolocation_fields.include?(field)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def ip_address = ip_address_ciphertext
|
|
224
|
+
def browser_user_agent = browser_user_agent_ciphertext
|
|
225
|
+
|
|
226
|
+
def authorized_ip_geolocation_fields
|
|
227
|
+
Array(authorized_fields.to_h["ip_geolocation"]&.select { |_, on| on }&.keys)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# --- The state a receipt reports -----------------------------------------
|
|
231
|
+
|
|
232
|
+
# Five distinct answers, kept distinct on purpose. "Blank" is never allowed
|
|
233
|
+
# to blur "we chose not to collect this" into "collection failed" into "we
|
|
234
|
+
# deleted it under a retention rule" — those tell an auditor completely
|
|
235
|
+
# different things about how the application behaves.
|
|
236
|
+
def state_for(category, authorized_to_read: false, held: false)
|
|
237
|
+
configured = authorized_for?(category)
|
|
238
|
+
return "not_configured" unless configured
|
|
239
|
+
|
|
240
|
+
return "deleted_after_retention" if deleted_for?(category)
|
|
241
|
+
return "held" if held && !authorized_to_read
|
|
242
|
+
return "unavailable" if unavailable_reason_for(category).present?
|
|
243
|
+
return "redacted_for_this_viewer" unless authorized_to_read
|
|
244
|
+
|
|
245
|
+
"recorded"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def unavailable_reason_for(category)
|
|
249
|
+
public_send(:"#{category}_unavailable_reason")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def deleted_for?(category)
|
|
253
|
+
public_send(:"#{category}_deleted_at").present?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def authorized_for?(category)
|
|
257
|
+
case category.to_sym
|
|
258
|
+
when :ip_address then authorized_fields.to_h["ip_address"] == true
|
|
259
|
+
when :browser_user_agent then authorized_fields.to_h["browser_user_agent"] == true
|
|
260
|
+
when :ip_geolocation then authorized_ip_geolocation_fields.any?
|
|
261
|
+
else false
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# --- Binding the annex to its event --------------------------------------
|
|
266
|
+
|
|
267
|
+
# The event keeps a digest of this annex so the two are provably the same
|
|
268
|
+
# pair. It is a keyed construction, not a plain hash: an IPv4 address is 32
|
|
269
|
+
# bits, so an unsalted hash of one can be tested by enumerating every
|
|
270
|
+
# address in minutes, and calling that anonymization would be wrong.
|
|
271
|
+
#
|
|
272
|
+
# Even keyed, the result is described as a retained linkable digest. It is
|
|
273
|
+
# not automatically anonymous, and a host's privacy analysis should treat it
|
|
274
|
+
# as pseudonymous data that outlives the value it covers.
|
|
275
|
+
def category_binding_digests
|
|
276
|
+
CATEGORIES.to_h { |category| [category.to_s, binding_digest_for(category)] }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def binding_digest_for(category)
|
|
280
|
+
key_id = binding_key_id
|
|
281
|
+
Digest.keyed_digest(
|
|
282
|
+
CanonicalJson.generate(binding_body_for(category)),
|
|
283
|
+
key: binding_key_for!(key_id),
|
|
284
|
+
algorithm: Clickwrap.config.digest_canonical_receipts_with.to_s
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def binding_digest_algorithm = "hmac-#{Clickwrap.config.digest_canonical_receipts_with}"
|
|
289
|
+
|
|
290
|
+
def binding_key_id
|
|
291
|
+
Clickwrap.config.current_request_evidence_binding_key_id.presence ||
|
|
292
|
+
raise(ConfigurationError,
|
|
293
|
+
"Clickwrap cannot name the request-evidence binding key. Configure " \
|
|
294
|
+
"`current_request_evidence_binding_key_id` and " \
|
|
295
|
+
"`find_request_evidence_binding_key_with` before recording request evidence.")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def category_binding_digest_verified?(category:, digest:, algorithm:, key_id:)
|
|
299
|
+
digest_algorithm = algorithm.to_s.delete_prefix("hmac-")
|
|
300
|
+
return false unless Digest.supported?(digest_algorithm)
|
|
301
|
+
|
|
302
|
+
key = Clickwrap.config.request_evidence_binding_key_for(key_id)
|
|
303
|
+
return false if key.nil?
|
|
304
|
+
|
|
305
|
+
computed = Digest.keyed_digest(
|
|
306
|
+
CanonicalJson.generate(binding_body_for(category)),
|
|
307
|
+
key: key,
|
|
308
|
+
algorithm: digest_algorithm
|
|
309
|
+
)
|
|
310
|
+
Digest.secure_compare?(computed, digest)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def binding_key_available?(key_id)
|
|
314
|
+
Clickwrap.config.request_evidence_binding_key_for(key_id).present?
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def any_category_disposed?
|
|
318
|
+
CATEGORIES.any? { |category| deleted_for?(category) }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The only supported mutation. The caller has already locked the root event
|
|
322
|
+
# and rechecked legal holds; this method limits the write to the one named
|
|
323
|
+
# category's value columns plus its deletion timestamp.
|
|
324
|
+
def dispose_category!(category, at: Clickwrap.now)
|
|
325
|
+
normalized = category.to_s.to_sym
|
|
326
|
+
columns = VALUE_COLUMNS_BY_CATEGORY.fetch(normalized) do
|
|
327
|
+
raise ArgumentError, "Unknown request-evidence category #{category.inspect}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
update_columns(columns.to_h { |column| [column, nil] }
|
|
331
|
+
.merge("#{normalized}_deleted_at" => at))
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def to_s = "request evidence for event #{event_id}"
|
|
335
|
+
|
|
336
|
+
private
|
|
337
|
+
|
|
338
|
+
def binding_body_for(category)
|
|
339
|
+
normalized = category.to_s.to_sym
|
|
340
|
+
columns = COMMON_BINDING_COLUMNS + BINDING_COLUMNS_BY_CATEGORY.fetch(normalized) do
|
|
341
|
+
raise ArgumentError, "Unknown request-evidence category #{category.inspect}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
columns.to_h do |column|
|
|
345
|
+
[column, canonical_binding_value(public_send(column))]
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def canonical_binding_value(value)
|
|
350
|
+
case value
|
|
351
|
+
when Time, ActiveSupport::TimeWithZone then Receipt.format_time(value)
|
|
352
|
+
when Hash then value.deep_stringify_keys
|
|
353
|
+
else value
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def binding_key_for!(key_id)
|
|
358
|
+
Clickwrap.config.request_evidence_binding_key_for(key_id) ||
|
|
359
|
+
raise(ConfigurationError,
|
|
360
|
+
"find_request_evidence_binding_key_with returned no key for the current " \
|
|
361
|
+
"request-evidence binding key ID #{key_id.inspect}.")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def refuse_ordinary_update
|
|
365
|
+
raise ImmutableEvidenceError,
|
|
366
|
+
"Request evidence is immutable after capture. Delete one category only through " \
|
|
367
|
+
"Clickwrap.delete_recorded_ip_address!, " \
|
|
368
|
+
"Clickwrap.delete_recorded_browser_user_agent!, or " \
|
|
369
|
+
"Clickwrap.delete_recorded_ip_geolocation!, which records the disposition."
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def refuse_destroy
|
|
373
|
+
raise ImmutableEvidenceError,
|
|
374
|
+
"A request-evidence annex cannot be destroyed directly. Dispose each authorized " \
|
|
375
|
+
"category through Clickwrap's named retention methods so the deletion is recorded."
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A small coordination row for one exact statement identity or actor-wide
|
|
5
|
+
# state scope. Evidence stays in Event; this table exists because a row
|
|
6
|
+
# lock cannot be taken before the first projection/event creates its row.
|
|
7
|
+
#
|
|
8
|
+
# Persisting one lock per identity avoids adapter-specific advisory locks.
|
|
9
|
+
# On PostgreSQL and MySQL each acquisition is a real row lock; SQLite emits
|
|
10
|
+
# no locking clause (Arel's SQLite visitor is a no-op for FOR UPDATE), so
|
|
11
|
+
# what serializes writers there is the database-wide write lock — a
|
|
12
|
+
# different mechanism whose contention surfaces as SQLITE_BUSY rather than
|
|
13
|
+
# a blocked row. The concurrency test lane runs on PostgreSQL and MySQL for
|
|
14
|
+
# exactly this reason. Callers acquire several identities in digest order so
|
|
15
|
+
# policies with more than one one-time statement cannot deadlock by
|
|
16
|
+
# choosing a different order.
|
|
17
|
+
class StatementIdentityLock < ApplicationRecord
|
|
18
|
+
self.table_name = "clickwrap_statement_identity_locks"
|
|
19
|
+
|
|
20
|
+
validates :identity_digest, presence: true
|
|
21
|
+
|
|
22
|
+
def self.acquire!(identity_digest)
|
|
23
|
+
lock_row = create_or_find_by!(identity_digest:) do |row|
|
|
24
|
+
row.created_at = Clickwrap.now
|
|
25
|
+
end
|
|
26
|
+
lock_row.lock!
|
|
27
|
+
lock_row
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.actor_state_scope_for(actor_reference)
|
|
31
|
+
Digest.digest_canonical({ "actor_state_reference" => actor_reference.to_s })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.acquire_for_actor!(actor_reference)
|
|
35
|
+
acquire!(actor_state_scope_for(actor_reference))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The current-state projection: the answer to "does this person currently have
|
|
5
|
+
# X?" without walking the whole event history on every request.
|
|
6
|
+
#
|
|
7
|
+
# This table is a cache of a computation over retained event payloads. Nothing
|
|
8
|
+
# here is evidence — the evidence is in `clickwrap_events`. Before retention
|
|
9
|
+
# disposes of a root payload it can be rebuilt from those events; afterward,
|
|
10
|
+
# deleting this projection would try to recreate personal identity facts that
|
|
11
|
+
# the reviewed disposition intentionally removed. `CurrentState.rebuild_for!`
|
|
12
|
+
# therefore refuses that destructive operation when it can see such a root.
|
|
13
|
+
# That separation lets this row be mutable, indexed, and fast without those
|
|
14
|
+
# properties leaking into the evidence record.
|
|
15
|
+
#
|
|
16
|
+
# The unique index guarantees one projection row per identity. Portable
|
|
17
|
+
# StatementIdentityLock rows serialize writers; the unique index alone would
|
|
18
|
+
# not stop two immutable capture events or decide which one is current.
|
|
19
|
+
class StatementState < ApplicationRecord
|
|
20
|
+
self.table_name = "clickwrap_statement_states"
|
|
21
|
+
|
|
22
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
23
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
24
|
+
belongs_to :policy_revision, class_name: "Clickwrap::PolicyRevision", optional: true
|
|
25
|
+
|
|
26
|
+
validates :policy_key, :statement_key, :actor_reference, :current_event_id, presence: true
|
|
27
|
+
validates :identity_digest, presence: true
|
|
28
|
+
|
|
29
|
+
# Recomputed on every save rather than only on create: if any part of the
|
|
30
|
+
# identity is ever corrected, the digest has to follow it or the unique
|
|
31
|
+
# index would be guarding a value nothing matches.
|
|
32
|
+
before_validation :assign_identity_digest
|
|
33
|
+
validates :kind, inclusion: { in: Vocabulary::KINDS }
|
|
34
|
+
validates :state, inclusion: { in: Vocabulary::STATES }
|
|
35
|
+
|
|
36
|
+
scope :active, -> { where(state: "active") }
|
|
37
|
+
scope :for_actor, ->(reference) { where(actor_reference: reference) }
|
|
38
|
+
scope :for_policy, ->(key) { where(policy_key: key.to_s) }
|
|
39
|
+
scope :for_statement, ->(key) { where(statement_key: key.to_s) }
|
|
40
|
+
scope :for_purpose, ->(key) { where(purpose_key: key.to_s) }
|
|
41
|
+
scope :expiring_before, ->(moment) { where.not(expires_at: nil).where(expires_at: ...moment) }
|
|
42
|
+
|
|
43
|
+
scope :due_for_expiry, lambda { |at = Clickwrap.now|
|
|
44
|
+
active.where.not(expires_at: nil).where(expires_at: ..at)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The identity a unique index can enforce. NULLs do not collide in a unique
|
|
48
|
+
# index on most adapters, so "no tenant" and "no subject" are the empty
|
|
49
|
+
# string rather than NULL — otherwise a policy with no subject would happily
|
|
50
|
+
# accumulate duplicate live grants.
|
|
51
|
+
def self.identity_for(policy_key:, statement_key:, actor_reference:, tenant_key: nil,
|
|
52
|
+
subject_key: nil, represented_party_reference: nil)
|
|
53
|
+
attributes = {
|
|
54
|
+
policy_key: policy_key.to_s,
|
|
55
|
+
statement_key: statement_key.to_s,
|
|
56
|
+
actor_reference: actor_reference.to_s,
|
|
57
|
+
tenant_key: tenant_key.to_s,
|
|
58
|
+
subject_key: subject_key.to_s,
|
|
59
|
+
represented_party_reference: represented_party_reference.to_s
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
attributes.merge(identity_digest: identity_digest_for(attributes))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The digest the unique index is taken over. Canonicalized first, so the
|
|
66
|
+
# value depends on the five parts and not on the order a caller happened to
|
|
67
|
+
# build the hash in.
|
|
68
|
+
def self.identity_digest_for(attributes)
|
|
69
|
+
Digest.digest_canonical(attributes.transform_keys(&:to_s))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.subject_key_for(subject) = Reference.subject(subject)
|
|
73
|
+
def self.tenant_key_for(tenant) = Reference.tenant(tenant)
|
|
74
|
+
|
|
75
|
+
def current_event = Event.find_by(id: current_event_id)
|
|
76
|
+
|
|
77
|
+
def expired?(at = Clickwrap.now) = expires_at.present? && expires_at <= at
|
|
78
|
+
|
|
79
|
+
# Whether this projection currently satisfies a requirement. Expiry is
|
|
80
|
+
# evaluated live rather than trusted from the `state` column, because a
|
|
81
|
+
# declaration expires on a clock, not on a background job having run.
|
|
82
|
+
def satisfies?(at = Clickwrap.now)
|
|
83
|
+
return false unless state == "active"
|
|
84
|
+
return false if expired?(at)
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Why it does not, as one of the stable error symbols applications branch
|
|
90
|
+
# on. Never an English string: an authorization decision should not depend
|
|
91
|
+
# on parsing a message.
|
|
92
|
+
def failure_reason(at = Clickwrap.now)
|
|
93
|
+
return nil if satisfies?(at)
|
|
94
|
+
|
|
95
|
+
case state
|
|
96
|
+
when "withdrawn" then :consent_withdrawn
|
|
97
|
+
when "declined" then :declined
|
|
98
|
+
when "superseded", "corrected" then :superseded
|
|
99
|
+
when "revoked" then :revoked
|
|
100
|
+
when "consumed" then :authorization_consumed
|
|
101
|
+
when "exempted" then :exemption_not_accepted
|
|
102
|
+
when "expired" then expiry_error
|
|
103
|
+
else expired?(at) ? expiry_error : :no_evidence
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def to_s = "#{kind} #{statement_key} for #{actor_reference} (#{state})"
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def assign_identity_digest
|
|
112
|
+
self.identity_digest = self.class.identity_digest_for(
|
|
113
|
+
policy_key: policy_key.to_s,
|
|
114
|
+
statement_key: statement_key.to_s,
|
|
115
|
+
actor_reference: actor_reference.to_s,
|
|
116
|
+
tenant_key: tenant_key.to_s,
|
|
117
|
+
subject_key: subject_key.to_s,
|
|
118
|
+
represented_party_reference: represented_party_reference.to_s
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def expiry_error
|
|
123
|
+
case kind
|
|
124
|
+
when "acknowledgment" then :acknowledgment_expired
|
|
125
|
+
when "authorization" then :authorization_expired
|
|
126
|
+
else :declaration_expired
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|