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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +650 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Clickwrap
7
+ module Generators
8
+ # Links a domain table to the clickwrap evidence that authorized its rows:
9
+ #
10
+ # bin/rails generate clickwrap:link payouts_withdrawals
11
+ #
12
+ # writes the migration for a `clickwrap_event_id` column (ULID string,
13
+ # indexed, nullable, no foreign key — see the migration's own comment for
14
+ # why each of those is deliberate). Pair it with `has_clickwrap_evidence`
15
+ # on the model and assign `pending_receipt.event_id` inside
16
+ # `capture_clickwrap_and!`.
17
+ class LinkGenerator < Rails::Generators::Base
18
+ include ActiveRecord::Generators::Migration
19
+
20
+ source_root File.expand_path("templates", __dir__)
21
+
22
+ argument :table_name, type: :string,
23
+ desc: "The domain table whose rows are authorized by a clickwrap capture"
24
+
25
+ def create_link_migration
26
+ migration_template "link_clickwrap_event_migration.rb.erb",
27
+ "db/migrate/add_clickwrap_event_to_#{table_name}.rb"
28
+ end
29
+
30
+ def explain
31
+ say ""
32
+ say "Next, on the model:"
33
+ say ""
34
+ say " has_clickwrap_evidence policy: :your_policy,"
35
+ say " statement: :your_statement,"
36
+ say " actor: :user,"
37
+ say " subject: :self"
38
+ say ""
39
+ say "and assign the event inside the capture that authorizes the row:"
40
+ say ""
41
+ say " capture_clickwrap_and!(:your_policy) do |pending_receipt|"
42
+ say " record.clickwrap_event_id = pending_receipt.event_id"
43
+ say " record.save!"
44
+ say " record # returned to the protected-outcome recorder"
45
+ say " end"
46
+ say ""
47
+ end
48
+
49
+ private
50
+
51
+ def migration_class_name
52
+ "AddClickwrapEventTo#{table_name.camelize}"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Clickwrap
6
+ module Generators
7
+ # `rails generate clickwrap:policy contractor_declaration declare
8
+ # --statement-text="I declare ..."` — one compiling policy and its test.
9
+ #
10
+ # The verb and exact first-person statement are required because choosing or
11
+ # inventing either would silently make product/legal meaning on the host's
12
+ # behalf. Once they are supplied, the generated file compiles immediately;
13
+ # it never writes an empty policy or an executable TODO placeholder.
14
+ #
15
+ # The test comes with it for the same reason tests come with a model: a
16
+ # policy is executable meaning, and "does this still say what we think it
17
+ # says" is a question worth asking on every commit.
18
+ class PolicyGenerator < Rails::Generators::Base
19
+ source_root File.expand_path("templates", __dir__)
20
+ desc "Create one compiling clickwrap policy and its test"
21
+
22
+ VERBS = %w[agree_to acknowledge consent_to declare attest authorize].freeze
23
+
24
+ argument :name, type: :string, banner: "POLICY_NAME"
25
+ argument :verb, type: :string, banner: VERBS.join("|")
26
+
27
+ class_option :statement_text, type: :string, required: true,
28
+ desc: "Exact first-person assertion for the generated control"
29
+ class_option :statement_key, type: :string,
30
+ desc: "Stable statement key (defaults to the policy name)"
31
+ class_option :document, type: :string,
32
+ desc: "Declared Clickwrap document key to link (optional)"
33
+ class_option :retention_class, type: :string, default: "ordinary_agreement_evidence",
34
+ desc: "Existing Clickwrap retention class"
35
+ class_option :withdrawal_path, type: :string,
36
+ desc: "Required remediation path for consent_to"
37
+
38
+ def validate_arguments!
39
+ return if VERBS.include?(policy_verb) && (!consent? || withdrawal_path.present?)
40
+
41
+ unless VERBS.include?(policy_verb)
42
+ raise Thor::Error,
43
+ "#{verb.inspect} is not a Clickwrap policy verb. Choose one of: #{VERBS.join(", ")}."
44
+ end
45
+
46
+ raise Thor::Error,
47
+ "A consent_to policy needs --withdrawal-path=/your/settings/page so the " \
48
+ "person has a concrete way to withdraw it."
49
+ end
50
+
51
+ def create_policy_file
52
+ template "policy.rb.erb", "config/clickwrap/#{policy_key}.rb"
53
+ end
54
+
55
+ def create_policy_test
56
+ template "policy_test.rb.erb", "test/clickwrap/#{policy_key}_policy_test.rb"
57
+ end
58
+
59
+ def display_next_steps
60
+ say "\n☑️ Compiling policy created.", :green
61
+ say "\nTo finish it:"
62
+ say " 1. Review the verb and exact statement in config/clickwrap/#{policy_key}.rb."
63
+ say " 2. If it references a document, declare and publish that document."
64
+ say " 3. Replace the generated test skips with your real actor and submission flow."
65
+ say "\nThe policy compiles at boot: a missing document, a duplicate statement key, a"
66
+ say "consent without a withdrawal path, or an indefinite one-time authorization is a"
67
+ say "startup failure with a sentence explaining it.\n"
68
+ end
69
+
70
+ private
71
+
72
+ def policy_key
73
+ name.to_s.underscore.tr("-", "_").tr("/", "_")
74
+ end
75
+
76
+ def policy_verb = verb.to_s
77
+
78
+ def statement_key
79
+ (options[:statement_key].presence || policy_key).underscore.tr("-", "_").tr("/", "_")
80
+ end
81
+
82
+ def statement_text = options.fetch(:statement_text).to_s
83
+
84
+ def document_key
85
+ key = options[:document].presence&.underscore
86
+ key&.tr("-", "_")&.tr("/", "_")
87
+ end
88
+
89
+ def retention_class_key = options.fetch(:retention_class).to_s.underscore.tr("-", "_").tr("/", "_")
90
+ def withdrawal_path = options[:withdrawal_path].to_s.strip
91
+ def consent? = policy_verb == "consent_to"
92
+
93
+ def evidence_kind
94
+ {
95
+ "agree_to" => "agreement",
96
+ "acknowledge" => "acknowledgment",
97
+ "consent_to" => "consent",
98
+ "declare" => "declaration",
99
+ "attest" => "attestation",
100
+ "authorize" => "authorization"
101
+ }.fetch(policy_verb)
102
+ end
103
+
104
+ def statement_options
105
+ options = ["statement: #{statement_text.inspect}"]
106
+ options.unshift("document: :#{document_key}") if document_key
107
+ options.unshift("document: nil") unless document_key
108
+ options << "optional: true" if consent?
109
+ options << "withdrawal_path: #{withdrawal_path.inspect}" if consent?
110
+ options
111
+ end
112
+
113
+ def test_class_name
114
+ "#{policy_key.camelize}PolicyTest"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ==============================================================================
4
+ # Clickwrap database hardening — OPT-IN, PostgreSQL only
5
+ # ==============================================================================
6
+ #
7
+ # Clickwrap's models make unsupported mutation loud. This migration adds a
8
+ # narrower database control for writes that bypass models: direct SQL, `delete`,
9
+ # `delete_all`, `update_column`, and `update_all`.
10
+ #
11
+ # WHAT THIS CLAIMS, IN FULL
12
+ #
13
+ # It rejects unsupported UPDATE and DELETE paths while these triggers are
14
+ # installed and the writer is not able to disable or replace them.
15
+ #
16
+ # WHAT IT DOES NOT CLAIM
17
+ #
18
+ # It does not make evidence impossible to alter. A database owner, superuser,
19
+ # migration role, or anyone able to drop triggers can remove this control. It
20
+ # does not authenticate a person, establish when an event happened, or make a
21
+ # local digest independent of the database that stores it.
22
+ #
23
+ # WHAT REMAINS POSSIBLE
24
+ #
25
+ # * A new event may be finalized once while its INSERT transaction is open.
26
+ # * Host records may be deleted, nulling only optional polymorphic pointers; the
27
+ # stable actor/subject/represented-party references remain unchanged.
28
+ # * A request-evidence annex may be linked once.
29
+ # * A legal-hold flag may change through Clickwrap's recorded hold lifecycle.
30
+ # * Core payload may be removed only when a finalized linked disposition event
31
+ # already identifies the event and its original digest. Child statement and
32
+ # document rows may be deleted only after that marker exists.
33
+ #
34
+ # Optional personal request-evidence annex values remain separately disposable
35
+ # in `clickwrap_request_evidence` on their reviewed schedules, so this migration
36
+ # does not protect that table from its own named disposition operations.
37
+ #
38
+ # Reversible: `rails db:rollback` drops only the functions and triggers created
39
+ # below. It changes no evidence rows.
40
+ # ==============================================================================
41
+ class ClickwrapDatabaseHardening < ActiveRecord::Migration<%= migration_version %>
42
+ # This is a generation-time snapshot of the runtime's named write sets. It is
43
+ # intentionally self-contained: a host must be able to rebuild a database
44
+ # from this migration after upgrading Clickwrap without asking a newer gem to
45
+ # reinterpret an older database contract.
46
+ EVENT_WRITE_SETS = {
47
+ <%= event_write_sets_for_migration %>
48
+ }.transform_values { |columns| columns.map(&:to_s).sort.freeze }.freeze
49
+
50
+ EVIDENCE_CHILD_TABLES = %w[
51
+ clickwrap_event_statements clickwrap_event_documents
52
+ ].freeze
53
+
54
+ def up
55
+ return say_unsupported_adapter unless postgresql?
56
+
57
+ execute <<~SQL
58
+ CREATE OR REPLACE FUNCTION clickwrap_reject_evidence_write() RETURNS trigger AS $function$
59
+ BEGIN
60
+ RAISE EXCEPTION
61
+ 'clickwrap: % rejects this %. Use a named Clickwrap lifecycle or disposition operation.',
62
+ TG_TABLE_NAME,
63
+ TG_OP
64
+ USING ERRCODE = 'restrict_violation';
65
+ END;
66
+ $function$ LANGUAGE plpgsql;
67
+ SQL
68
+
69
+ execute <<~SQL
70
+ CREATE OR REPLACE FUNCTION clickwrap_guard_event_update() RETURNS trigger AS $function$
71
+ BEGIN
72
+ -- An unfinalized row is visible only inside its INSERT transaction. The
73
+ -- application builds children/outcome and then changes event_digest from
74
+ -- NULL to a value exactly once before COMMIT.
75
+ IF OLD.event_digest IS NULL THEN
76
+ RETURN NEW;
77
+ END IF;
78
+
79
+ -- A hold is a mutable projection backed by immutable hold lifecycle
80
+ -- rows. No other event field may ride along with this update.
81
+ IF (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("legal_hold"))})
82
+ IS NOT DISTINCT FROM
83
+ (#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("legal_hold"))}) THEN
84
+ RETURN NEW;
85
+ END IF;
86
+
87
+ -- The annex pointer is attached once; its keyed category digests were
88
+ -- already covered by the finalized event digest.
89
+ IF OLD.request_evidence_id IS NULL
90
+ AND NEW.request_evidence_id IS NOT NULL
91
+ AND (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("request_evidence_link"))})
92
+ IS NOT DISTINCT FROM
93
+ (#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("request_evidence_link"))}) THEN
94
+ RETURN NEW;
95
+ END IF;
96
+
97
+ -- Deleting a host actor/subject/represented party may only null its
98
+ -- convenience pointer. Stable references and every covered fact stay.
99
+ IF (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("pointer_nullification"))})
100
+ IS NOT DISTINCT FROM
101
+ (#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("pointer_nullification"))})
102
+ AND ((NEW.actor_type IS NULL AND NEW.actor_id IS NULL)
103
+ OR (NEW.actor_type IS NOT DISTINCT FROM OLD.actor_type
104
+ AND NEW.actor_id IS NOT DISTINCT FROM OLD.actor_id))
105
+ AND ((NEW.represented_party_type IS NULL AND NEW.represented_party_id IS NULL)
106
+ OR (NEW.represented_party_type IS NOT DISTINCT FROM OLD.represented_party_type
107
+ AND NEW.represented_party_id IS NOT DISTINCT FROM OLD.represented_party_id))
108
+ AND ((NEW.subject_type IS NULL AND NEW.subject_id IS NULL)
109
+ OR (NEW.subject_type IS NOT DISTINCT FROM OLD.subject_type
110
+ AND NEW.subject_id IS NOT DISTINCT FROM OLD.subject_id)) THEN
111
+ RETURN NEW;
112
+ END IF;
113
+
114
+ -- Core disposition is a one-way transition whose linked, finalized
115
+ -- disposition event names both this event and its original digest.
116
+ IF OLD.core_event_disposed_at IS NULL
117
+ AND NEW.core_event_disposed_at IS NOT NULL
118
+ AND NEW.core_event_disposition_event_id IS NOT NULL
119
+ AND (#{jsonb_without("NEW", EVENT_WRITE_SETS.fetch("disposition"))})
120
+ IS NOT DISTINCT FROM
121
+ (#{jsonb_without("OLD", EVENT_WRITE_SETS.fetch("disposition"))})
122
+ AND NEW.actor_type IS NULL AND NEW.actor_id IS NULL
123
+ AND NEW.actor_reference = '' AND NEW.actor_snapshot = '{}'::jsonb
124
+ AND NEW.represented_party_type IS NULL AND NEW.represented_party_id IS NULL
125
+ AND NEW.represented_party_reference = ''
126
+ AND NEW.authority_source IS NULL AND NEW.authority_role IS NULL
127
+ AND NEW.authority_verified_at IS NULL AND NEW.authority_details = '{}'::jsonb
128
+ AND NEW.tenant_key = ''
129
+ AND NEW.subject_type IS NULL AND NEW.subject_id IS NULL
130
+ AND NEW.subject_key = '' AND NEW.subject_fingerprint IS NULL
131
+ AND NEW.authentication_method IS NULL AND NEW.authentication_context = '{}'::jsonb
132
+ AND NEW.idempotency_key IS NULL
133
+ AND NEW.http_request_id IS NULL AND NEW.http_route_name IS NULL
134
+ AND NEW.presentation_id IS NULL AND NEW.presentation_manifest IS NULL
135
+ AND NEW.presentation_manifest_digest IS NULL
136
+ AND NEW.protected_outcome IS NULL
137
+ AND NEW.provider_receipt IS NULL AND NEW.provider_verification IS NULL
138
+ AND NEW.reason IS NULL
139
+ AND EXISTS (
140
+ SELECT 1
141
+ FROM clickwrap_events disposition
142
+ WHERE disposition.id = NEW.core_event_disposition_event_id
143
+ AND disposition.event_type = 'disposition'
144
+ AND disposition.event_digest IS NOT NULL
145
+ AND disposition.protected_outcome
146
+ -> 'core_event_disposition' ->> 'event_id' = OLD.id
147
+ AND disposition.protected_outcome
148
+ -> 'core_event_disposition' ->> 'original_event_digest' = OLD.event_digest
149
+ ) THEN
150
+ RETURN NEW;
151
+ END IF;
152
+
153
+ RAISE EXCEPTION
154
+ 'clickwrap: clickwrap_events rejects this UPDATE. Use a named Clickwrap lifecycle or disposition operation.'
155
+ USING ERRCODE = 'restrict_violation';
156
+ END;
157
+ $function$ LANGUAGE plpgsql;
158
+ SQL
159
+
160
+ execute <<~SQL
161
+ CREATE OR REPLACE FUNCTION clickwrap_guard_evidence_child_write() RETURNS trigger AS $function$
162
+ BEGIN
163
+ IF TG_OP = 'DELETE' AND EXISTS (
164
+ SELECT 1
165
+ FROM clickwrap_events source_event
166
+ JOIN clickwrap_events disposition
167
+ ON disposition.id = source_event.core_event_disposition_event_id
168
+ WHERE source_event.id = OLD.event_id
169
+ AND source_event.core_event_disposed_at IS NOT NULL
170
+ AND disposition.event_type = 'disposition'
171
+ AND disposition.event_digest IS NOT NULL
172
+ AND disposition.protected_outcome
173
+ -> 'core_event_disposition' ->> 'event_id' = source_event.id
174
+ AND disposition.protected_outcome
175
+ -> 'core_event_disposition' ->> 'original_event_digest' = source_event.event_digest
176
+ ) THEN
177
+ RETURN OLD;
178
+ END IF;
179
+
180
+ RAISE EXCEPTION
181
+ 'clickwrap: % rejects this %. Evidence children may be deleted only by a documented core disposition.',
182
+ TG_TABLE_NAME,
183
+ TG_OP
184
+ USING ERRCODE = 'restrict_violation';
185
+ END;
186
+ $function$ LANGUAGE plpgsql;
187
+ SQL
188
+
189
+ install_event_triggers
190
+ install_child_triggers
191
+
192
+ say "clickwrap: PostgreSQL update/delete protection installed on finalized events and their evidence children."
193
+ end
194
+
195
+ def down
196
+ return unless postgresql?
197
+
198
+ execute "DROP TRIGGER IF EXISTS clickwrap_events_guard_update ON clickwrap_events;"
199
+ execute "DROP TRIGGER IF EXISTS clickwrap_events_reject_delete ON clickwrap_events;"
200
+
201
+ EVIDENCE_CHILD_TABLES.each do |table|
202
+ execute "DROP TRIGGER IF EXISTS #{table}_guard_write ON #{table};"
203
+ end
204
+
205
+ execute "DROP FUNCTION IF EXISTS clickwrap_guard_event_update();"
206
+ execute "DROP FUNCTION IF EXISTS clickwrap_guard_evidence_child_write();"
207
+ execute "DROP FUNCTION IF EXISTS clickwrap_reject_evidence_write();"
208
+
209
+ say "clickwrap: PostgreSQL update/delete protection removed. No evidence rows were changed."
210
+ end
211
+
212
+ private
213
+
214
+ def install_event_triggers
215
+ execute "DROP TRIGGER IF EXISTS clickwrap_events_guard_update ON clickwrap_events;"
216
+ execute <<~SQL
217
+ CREATE TRIGGER clickwrap_events_guard_update
218
+ BEFORE UPDATE ON clickwrap_events
219
+ FOR EACH ROW EXECUTE FUNCTION clickwrap_guard_event_update();
220
+ SQL
221
+
222
+ execute "DROP TRIGGER IF EXISTS clickwrap_events_reject_delete ON clickwrap_events;"
223
+ execute <<~SQL
224
+ CREATE TRIGGER clickwrap_events_reject_delete
225
+ BEFORE DELETE ON clickwrap_events
226
+ FOR EACH ROW EXECUTE FUNCTION clickwrap_reject_evidence_write();
227
+ SQL
228
+ end
229
+
230
+ def install_child_triggers
231
+ EVIDENCE_CHILD_TABLES.each do |table|
232
+ execute "DROP TRIGGER IF EXISTS #{table}_guard_write ON #{table};"
233
+ execute <<~SQL
234
+ CREATE TRIGGER #{table}_guard_write
235
+ BEFORE UPDATE OR DELETE ON #{table}
236
+ FOR EACH ROW EXECUTE FUNCTION clickwrap_guard_evidence_child_write();
237
+ SQL
238
+ end
239
+ end
240
+
241
+ def jsonb_without(row_name, columns)
242
+ (["to_jsonb(#{row_name})"] + columns.map { |column| "- #{connection.quote(column)}" }).join(" ")
243
+ end
244
+
245
+ def postgresql?
246
+ connection.adapter_name.to_s.downcase.match?(/postgres|postgis/)
247
+ end
248
+
249
+ def say_unsupported_adapter
250
+ say "clickwrap: no database-level update/delete protection was installed."
251
+ say " This opt-in migration is implemented and tested only for PostgreSQL.", true
252
+ say " SQLite has no separate runtime role; any writer can drop its own trigger.", true
253
+ say " On MySQL, use a runtime role whose grants match your reviewed write paths.", true
254
+ say " Model refusals and digest verification continue to work on every supported adapter.", true
255
+ end
256
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ==============================================================================
4
+ # Clickwrap documents, policies, and retention classes
5
+ # ==============================================================================
6
+ #
7
+ # This file is ordinary Ruby so it is reviewable in a pull request and deploys
8
+ # with the code that depends on it. It is re-read on every boot and every
9
+ # development reload, and a mistake is a boot failure with a sentence explaining
10
+ # it — not a surprise the first time someone tries to sign up.
11
+ #
12
+ # Three kinds of declaration live here:
13
+ #
14
+ # Clickwrap.document versioned source and rendered bytes used in offers
15
+ # Clickwrap.policy what the server offers and will accept
16
+ # Clickwrap.retention how long each part of the evidence lives
17
+ #
18
+ # The browser never chooses any of it. Policy key, revision, document versions,
19
+ # validity, subject binding, retention, and request-evidence fields are resolved
20
+ # on the server and rechecked when the form comes back.
21
+ # ==============================================================================
22
+
23
+ # --- Documents ----------------------------------------------------------------
24
+ #
25
+ # A logical document (:terms) is separate from its immutable versions. Declaring
26
+ # a version here does not publish it: `bin/rails clickwrap:publish` reads the
27
+ # bytes once, digests them, and freezes a database snapshot. From then on the
28
+ # snapshot is the evidence and the file is only where it came from.
29
+ #
30
+ # Every version has a LABEL YOU OWN, and the file that holds the words is where
31
+ # that label belongs. A page with leading YAML front matter names its own
32
+ # version, so there is no `version:` line here and no second copy of the label
33
+ # to drift:
34
+ #
35
+ # ---
36
+ # title: Terms of Service
37
+ # last_updated: <%= Date.today.iso8601 %>
38
+ # ---
39
+ #
40
+ # `last_updated:` is the usual key, because a page that tells readers when it
41
+ # last changed already has one. `clickwrap_version:` wins when both are present,
42
+ # which is how a same-day correction gets a fresh label while the date readers
43
+ # see stays put. So changing a legal text is one edit in one file, then
44
+ # `bin/rails clickwrap:publish` — and reusing a label over changed bytes is
45
+ # refused rather than quietly accepted, which is what keeps the label and the
46
+ # bytes describing the same document. A file with neither key and no explicit
47
+ # `version:` fails the boot with a sentence; Clickwrap never invents a label.
48
+ #
49
+ # `version:` here stays the way to label a source that cannot carry front matter
50
+ # — a PDF, an HTML fragment, or a `resolver:` whose bytes are only read at
51
+ # publish time:
52
+ #
53
+ # Clickwrap.document :handbook,
54
+ # version: "<%= Date.today.iso8601 %>",
55
+ # from: Rails.root.join("app/content/legal/handbook.pdf")
56
+ #
57
+ # Whether a change is material enough to require re-acceptance is your decision.
58
+ # Clickwrap enforces the rule it is given; it does not decide materiality.
59
+
60
+ <%- unless terms_document_names_its_own_version? -%>
61
+ # <%= File.basename(terms_document_path) %> carries no version key yet. Move this label into the page's own
62
+ # `last_updated:` front matter and delete the line, so the text names its version.
63
+ <%- end -%>
64
+ Clickwrap.document :terms,
65
+ <%- unless terms_document_names_its_own_version? -%>
66
+ version: "<%= Date.today.iso8601 %>",
67
+ <%- end -%>
68
+ from: Rails.root.join("<%= terms_document_path %>")
69
+
70
+ <%- unless privacy_document_names_its_own_version? -%>
71
+ # <%= File.basename(privacy_document_path) %> carries no version key yet. Move this label into the page's own
72
+ # `last_updated:` front matter and delete the line, so the text names its version.
73
+ <%- end -%>
74
+ Clickwrap.document :privacy_notice,
75
+ <%- unless privacy_document_names_its_own_version? -%>
76
+ version: "<%= Date.today.iso8601 %>",
77
+ <%- end -%>
78
+ from: Rails.root.join("<%= privacy_document_path %>")
79
+
80
+ # Several locales are several versions of the same logical document. Each is
81
+ # published and digested separately, each names its own version in its own front
82
+ # matter, and the receipt records which one the accepted server offer bound:
83
+ #
84
+ # Clickwrap.document :terms,
85
+ # locale: :es,
86
+ # effective_at: Time.utc(<%= Date.today.year %>, <%= Date.today.month %>, <%= Date.today.day %>),
87
+ # from: Rails.root.join("app/content/legal/terms.es.md")
88
+
89
+ # --- Retention ----------------------------------------------------------------
90
+ #
91
+ # Clickwrap does not know how long your evidence should live and will not
92
+ # pretend to. What it does is make a reviewed decision executable and auditable,
93
+ # and keep two clocks apart:
94
+ #
95
+ # the CORE EVENT — what was agreed, by whom, to which version
96
+ # the ANNEX — optional personal request evidence attached to it
97
+ #
98
+ # They are separate because they answer to different rules. Deleting the annex
99
+ # removes the encrypted value and appends a disposition event; the agreement it
100
+ # accompanied stays intact and still verifies. Nothing cascades: deleting an
101
+ # actor account never silently destroys the record of what they agreed to.
102
+ #
103
+ # THE PERIODS BELOW ARE PLACEHOLDERS, NOT RECOMMENDATIONS. Replace them with
104
+ # periods you have reviewed for your jurisdiction, your obligations, and your
105
+ # disputes. `bin/rails clickwrap:retention:plan` shows what any of this would
106
+ # delete before anything is deleted.
107
+
108
+ Clickwrap.retention :ordinary_agreement_evidence do
109
+ retain_core_event_for 6.years
110
+
111
+ # These only matter if a policy or the initializer enables the field at all.
112
+ # A rule for something you never collect costs nothing and stops a future
113
+ # "we turned it on and forgot the clock".
114
+ delete_recorded_ip_address_after 90.days
115
+ delete_recorded_browser_user_agent_after 90.days
116
+ delete_recorded_ip_geolocation_after 90.days
117
+ end
118
+
119
+ # A regulated schedule that a duration cannot express — "the later of five years
120
+ # after capture and three years after the subject closed" — names a host event
121
+ # instead, and registers the calculation in config/initializers/clickwrap.rb:
122
+ #
123
+ # Clickwrap.retention :regulated_evidence do
124
+ # retain_core_event_until :regulated_evidence_retention_ends
125
+ # retain_recorded_ip_address_until :security_evidence_retention_ends
126
+ # retain_recorded_browser_user_agent_until :security_evidence_retention_ends
127
+ # retain_recorded_ip_geolocation_until :security_evidence_retention_ends
128
+ # end
129
+
130
+ # --- Policies -----------------------------------------------------------------
131
+ #
132
+ # A policy is a server-owned offer: exactly what the server will generate, and
133
+ # exactly what it will accept back. One submitted policy produces one event and one
134
+ # receipt, but each act inside it keeps its own kind, documents, wording,
135
+ # answer, and lifecycle — "agreed to the Terms" and "acknowledged the Privacy
136
+ # Notice" never flatten into one meaning.
137
+
138
+ Clickwrap.policy :signup do
139
+ # `agree_to` means assent to contractual terms. The lifecycle it gets is the
140
+ # one assent needs: it stands until a new version supersedes it.
141
+ agree_to :terms
142
+
143
+ # `acknowledge`, NOT `agree_to`, and NOT `consent_to`.
144
+ #
145
+ # A privacy notice is information a person is entitled to receive. It is not a
146
+ # contract term they assent to, and it is not permission they grant: consent
147
+ # is one possible lawful basis among several, and most of what a notice
148
+ # describes usually rests on a different one. Presenting a notice as something
149
+ # to "agree" to misdescribes it, and treating the acknowledgment as blanket
150
+ # consent would let a checkbox stand in for permissions nobody asked for.
151
+ #
152
+ # So the receipt says what the application recorded: the server offered this
153
+ # exact notice version, and the person submitted an affirmative acknowledgment
154
+ # of receiving it. Anything you genuinely need consent for gets its own
155
+ # `consent_to`, its own purpose, its own unselected control, and its own
156
+ # withdrawal path — see the marketing example below.
157
+ acknowledge :privacy_notice
158
+
159
+ retain_with :ordinary_agreement_evidence
160
+ end
161
+
162
+ # --- An optional, separate marketing consent ----------------------------------
163
+ #
164
+ # Never bundled into signup, never preselected, and never required to create an
165
+ # account. Leaving an optional control unselected creates no grant at all: the
166
+ # receipt can show the option was offered and not taken, but silence is not
167
+ # recorded as a refusal.
168
+ #
169
+ # Consent that cannot be withdrawn is not consent, so Clickwrap structurally
170
+ # requires a withdrawal path. It still does not decide whether consent is the
171
+ # right basis for what you are doing — that one is yours.
172
+ #
173
+ # Clickwrap.document :marketing_notice,
174
+ # from: Rails.root.join("app/content/legal/marketing.md")
175
+ #
176
+ # Clickwrap.policy :marketing_preferences do
177
+ # consent_to :product_updates,
178
+ # document: :marketing_notice,
179
+ # optional: true,
180
+ # withdrawal_path: "/settings/privacy"
181
+ #
182
+ # retain_with :ordinary_agreement_evidence
183
+ # end
184
+ #
185
+ # Withdrawal appends an event; it never edits or deletes the historical grant:
186
+ #
187
+ # Clickwrap.withdraw!(
188
+ # :product_updates,
189
+ # actor: current_user,
190
+ # http_request: request,
191
+ # because: "The user withdrew this purpose in privacy settings"
192
+ # )