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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. 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