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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The exact document version bound to one statement in one event, with the
5
+ # digests as they stood at capture.
6
+ #
7
+ # The digests are copied here rather than only referenced, so that verification
8
+ # can detect a document version row that was edited in place instead of
9
+ # trusting the current value of a column it is supposed to be checking.
10
+ class EventDocument < ApplicationRecord
11
+ self.table_name = "clickwrap_event_documents"
12
+ self.record_timestamps = false
13
+
14
+ belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :documents
15
+ belongs_to :document_version,
16
+ class_name: "Clickwrap::DocumentVersion",
17
+ optional: true,
18
+ inverse_of: :event_documents
19
+
20
+ validates :statement_key, :document_key, :version_label, :locale, :source_content_digest,
21
+ presence: true
22
+
23
+ before_update :refuse_change
24
+ before_destroy :refuse_destroy
25
+
26
+ # True when the stored document version still carries the digest this event
27
+ # recorded. A false says the document row changed after capture — which is
28
+ # a finding, not a crash, so verification reports it with a stable error
29
+ # rather than raising into an export.
30
+ def still_matches_stored_version?
31
+ return false if document_version.nil?
32
+
33
+ Digest.secure_compare?(document_version.content_digest, source_content_digest) &&
34
+ Digest.secure_compare?(
35
+ document_version.rendered_content_digest.presence || document_version.content_digest,
36
+ rendered_content_digest
37
+ ) && document_version.media_type == source_media_type &&
38
+ (document_version.rendered_media_type.presence || document_version.media_type) == rendered_media_type &&
39
+ document_version.renderer_name.to_s == renderer_name.to_s &&
40
+ document_version.renderer_version.to_s == renderer_version.to_s &&
41
+ document_version.sanitizer_name.to_s == sanitizer_name.to_s &&
42
+ document_version.sanitizer_version.to_s == sanitizer_version.to_s
43
+ end
44
+
45
+ def canonical_fragment
46
+ {
47
+ "statement" => statement_key,
48
+ "key" => document_key,
49
+ "version" => version_label,
50
+ "locale" => locale,
51
+ "source_media_type" => source_media_type,
52
+ "source_digest" => source_content_digest,
53
+ "rendered_media_type" => rendered_media_type,
54
+ "rendered_digest" => rendered_content_digest,
55
+ "renderer" => {
56
+ "name" => renderer_name,
57
+ "version" => renderer_version,
58
+ "sanitizer_name" => sanitizer_name,
59
+ "sanitizer_version" => sanitizer_version
60
+ }.compact.presence,
61
+ "ordinal" => ordinal
62
+ }.compact
63
+ end
64
+
65
+ def to_s = "#{document_key} #{version_label} (#{locale})"
66
+
67
+ private
68
+
69
+ def refuse_change
70
+ raise EventWriteFailed,
71
+ "Document binding #{self} on event #{event_id} is an immutable snapshot."
72
+ end
73
+
74
+ def refuse_destroy
75
+ raise EventWriteFailed,
76
+ "Document binding #{self} on event #{event_id} cannot be destroyed."
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # One act inside one event: the assertion in the server-generated offer and
5
+ # what the submitted answer said about it.
6
+ #
7
+ # `assertion_text` is the resolved sentence, not an I18n key. Storing the key
8
+ # would be a false economy — its meaning can change in a later deploy, and
9
+ # then the receipt no longer says what assertion the application recorded.
10
+ class EventStatement < ApplicationRecord
11
+ self.table_name = "clickwrap_event_statements"
12
+ self.record_timestamps = false
13
+
14
+ belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :statements
15
+
16
+ has_many :event_documents,
17
+ ->(statement) { where(statement_key: statement.statement_key) },
18
+ class_name: "Clickwrap::EventDocument",
19
+ foreign_key: :event_id,
20
+ primary_key: :event_id,
21
+ inverse_of: false,
22
+ dependent: nil
23
+
24
+ validates :statement_key, :assertion_text, :assertion_locale, presence: true
25
+ validates :kind, inclusion: { in: Vocabulary::KINDS }
26
+ validate :action_belongs_to_kind
27
+
28
+ before_update :refuse_change
29
+ before_destroy :refuse_destroy
30
+
31
+ scope :of_kind, ->(kind) { where(kind: kind.to_s) }
32
+ scope :answered, -> { where(answered: true) }
33
+
34
+ def expired?(at = Clickwrap.now) = expires_at.present? && expires_at <= at
35
+
36
+ # An optional control left unselected creates no grant. The receipt can show
37
+ # that the option was offered and not taken, but it does not call silence an
38
+ # affirmative refusal — those are different facts and only one of them
39
+ # actually happened.
40
+ def offered_and_not_taken? = optional? && !answered?
41
+
42
+ def canonical_fragment
43
+ {
44
+ "statement" => statement_key,
45
+ "kind" => kind,
46
+ "action" => action,
47
+ "assertion" => assertion_text,
48
+ "locale" => assertion_locale,
49
+ "label" => label_text,
50
+ "link_labels" => link_labels.presence,
51
+ "choices" => choices.presence,
52
+ "required" => required?,
53
+ "optional" => optional?,
54
+ "answered" => answered?,
55
+ "answer" => answer.presence,
56
+ "purpose" => purpose_key,
57
+ "withdrawal_path" => withdrawal_path,
58
+ "valid_from" => Receipt.format_time(valid_from),
59
+ "expires_at" => Receipt.format_time(expires_at),
60
+ "one_time" => one_time? || nil,
61
+ "requires" => Array(requires).presence,
62
+ "subject_fingerprint" => subject_fingerprint
63
+ }.compact
64
+ end
65
+
66
+ def to_s = "#{kind} #{statement_key} (#{action})"
67
+
68
+ private
69
+
70
+ def action_belongs_to_kind
71
+ return if kind.blank? || action.blank?
72
+ return if Vocabulary::ACTIONS_FOR_KIND.fetch(kind, []).include?(action)
73
+
74
+ errors.add(
75
+ :action,
76
+ "#{action.inspect} is not something a #{kind} can record. A #{kind} can be: " \
77
+ "#{Vocabulary::ACTIONS_FOR_KIND.fetch(kind, []).join(", ")}."
78
+ )
79
+ end
80
+
81
+ def refuse_change
82
+ raise EventWriteFailed,
83
+ "Statement #{statement_key} on event #{event_id} is an immutable snapshot of what " \
84
+ "was offered and answered. Record a correction, withdrawal, or supersession instead."
85
+ end
86
+
87
+ def refuse_destroy
88
+ raise EventWriteFailed,
89
+ "Statement #{statement_key} on event #{event_id} cannot be destroyed."
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The outbox row for an action Clickwrap cannot make atomic.
5
+ #
6
+ # Stripe, an identity provider, a timestamp authority: none of them can join
7
+ # your database transaction, and pretending otherwise is how a provider
8
+ # timeout becomes either a fictional success or a second debit. So the local
9
+ # transaction commits a pending authorization and an idempotency key, the
10
+ # provider is called outside it, and the outcome is appended back
11
+ # idempotently.
12
+ #
13
+ # `unknown` is a first-class state, not an error state. A timeout is not a
14
+ # failure — it is an absence of information — and the reconciliation task
15
+ # exists precisely to resolve those later rather than guessing now.
16
+ class ExternalAction < ApplicationRecord
17
+ self.table_name = "clickwrap_external_actions"
18
+
19
+ STATES = %w[pending succeeded failed unknown].freeze
20
+
21
+ belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :external_action
22
+
23
+ validates :policy_key, :idempotency_key, :requested_at, presence: true
24
+ validates :idempotency_key, uniqueness: true
25
+ validates :state, inclusion: { in: STATES }
26
+
27
+ before_update :refuse_ordinary_update
28
+ before_destroy :refuse_destroy, prepend: true
29
+
30
+ scope :pending, -> { where(state: "pending") }
31
+ scope :unresolved, -> { where(state: %w[pending unknown]) }
32
+ scope :needing_reconciliation, lambda { |older_than = 15.minutes.ago|
33
+ unresolved.where(requested_at: ...older_than)
34
+ }
35
+
36
+ def pending? = state == "pending"
37
+ def resolved? = %w[succeeded failed].include?(state)
38
+
39
+ # Each resolution is one idempotent local transaction. Calling it twice with
40
+ # the same outcome is a no-op; calling it with a different outcome after the
41
+ # action already resolved raises, because silently overwriting "succeeded"
42
+ # with "failed" would rewrite the record of what the provider told us.
43
+ def record_provider_success_and_consume!(provider_receipt = nil)
44
+ resolve!("succeeded", provider_receipt: provider_receipt) do
45
+ Lifecycle.consume_authorization!(event: event, because: "External action succeeded")
46
+ end
47
+ end
48
+
49
+ def record_provider_failure!(reason:, provider_receipt: nil)
50
+ resolve!("failed", provider_receipt: provider_receipt, failure_reason: reason)
51
+ end
52
+
53
+ # For the genuinely ambiguous case: the request may or may not have been
54
+ # carried out, and the honest record says so rather than picking one.
55
+ def record_provider_outcome_unknown!(reason:)
56
+ resolve!("unknown", failure_reason: reason, resolved: false)
57
+ end
58
+
59
+ def to_s = "external action #{idempotency_key} (#{state})"
60
+
61
+ private
62
+
63
+ def resolve!(new_state, provider_receipt: nil, failure_reason: nil, resolved: true)
64
+ transaction do
65
+ reload.lock!
66
+
67
+ if state == new_state
68
+ return self if same_provider_resolution?(
69
+ provider_receipt: provider_receipt,
70
+ failure_reason: failure_reason
71
+ )
72
+
73
+ # Unknown is an observation, not a terminal result. A later
74
+ # reconciliation attempt may still be unknown for a different
75
+ # documented reason; append that attempt instead of erasing the first.
76
+ unless new_state == "unknown"
77
+ raise ExternalActionAlreadyResolved,
78
+ "External action #{idempotency_key} is already #{state}, but the repeated provider " \
79
+ "result carries different evidence. The original outcome is not silently overwritten."
80
+ end
81
+ end
82
+
83
+ if resolved? && new_state != state
84
+ raise ExternalActionAlreadyResolved,
85
+ "External action #{idempotency_key} already resolved as #{state}; it cannot " \
86
+ "become #{new_state}. Appending a new event is the way to record a later change."
87
+ end
88
+
89
+ update_columns(
90
+ state: new_state,
91
+ provider_receipt: provider_receipt || self.provider_receipt,
92
+ failure_reason: failure_reason,
93
+ attempt_count: attempt_count + 1,
94
+ resolved_at: resolved ? Clickwrap.now : nil,
95
+ updated_at: Clickwrap.now
96
+ )
97
+
98
+ Lifecycle.append_lifecycle_event!(
99
+ event: event,
100
+ event_type: "provider_outcome",
101
+ reason: provider_outcome_reason(new_state, failure_reason),
102
+ extra: {
103
+ protected_outcome: {
104
+ "external_action" => {
105
+ "external_action_id" => id.to_s,
106
+ "idempotency_key" => idempotency_key,
107
+ "provider_name" => provider_name,
108
+ "state" => new_state,
109
+ "provider_receipt" => provider_receipt,
110
+ "failure_reason" => failure_reason
111
+ }.compact
112
+ }
113
+ }
114
+ )
115
+
116
+ yield if block_given?
117
+ end
118
+
119
+ self
120
+ end
121
+
122
+ def provider_outcome_reason(new_state, failure_reason)
123
+ ["External action was recorded as #{new_state}.", failure_reason].compact.join(" ")
124
+ end
125
+
126
+ def same_provider_resolution?(provider_receipt:, failure_reason:)
127
+ receipt_matches = provider_receipt.nil? ||
128
+ canonical_value(provider_receipt) == canonical_value(self.provider_receipt)
129
+ reason_matches = failure_reason.to_s == self.failure_reason.to_s
130
+
131
+ receipt_matches && reason_matches
132
+ end
133
+
134
+ def canonical_value(value)
135
+ CanonicalJson.generate(value)
136
+ rescue CanonicalJson::SerializationError
137
+ value
138
+ end
139
+
140
+ def refuse_ordinary_update
141
+ raise ImmutableEvidenceError,
142
+ "External action outcomes are changed only through the named provider-result methods."
143
+ end
144
+
145
+ def refuse_destroy
146
+ raise ImmutableEvidenceError,
147
+ "External actions are durable outbox records and cannot be destroyed."
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # One immutable result from an optional external integrity adapter. Merely
5
+ # configuring an adapter is not evidence; this row records what was actually
6
+ # submitted, what the provider returned, and whether its verifier checked the
7
+ # exact event digest.
8
+ class IntegrityAttestation < ApplicationRecord
9
+ self.table_name = "clickwrap_integrity_attestations"
10
+ self.record_timestamps = false
11
+
12
+ KINDS = %w[event_anchor third_party_timestamp].freeze
13
+ STATES = %w[verified issued_unverified unavailable failed].freeze
14
+
15
+ belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :integrity_attestations
16
+
17
+ validates :kind, inclusion: { in: KINDS }
18
+ validates :state, inclusion: { in: STATES }
19
+ validates :event_id, :provider_name, :subject_digest, :attempted_at, :attestation_digest,
20
+ presence: true
21
+
22
+ before_validation :assign_attestation_digest, on: :create
23
+ before_update :refuse_update
24
+ before_destroy :refuse_destroy, prepend: true
25
+
26
+ scope :verified, -> { where(state: "verified") }
27
+
28
+ def verified?
29
+ state == "verified" && verification.to_h["checked"] == true &&
30
+ verification.to_h["verified"] == true && digest_verified?
31
+ end
32
+
33
+ def verified_for?(source_event)
34
+ verified? && event_id.to_s == source_event.id.to_s &&
35
+ subject_digest.to_s == source_event.event_digest.to_s &&
36
+ (kind != "event_anchor" ||
37
+ (chain_scope.to_s == source_event.chain_scope.to_s && chain_sequence == source_event.chain_sequence))
38
+ end
39
+
40
+ def digest_verified?
41
+ Digest.secure_compare?(attestation_digest.to_s, compute_attestation_digest)
42
+ end
43
+
44
+ def canonical_fragment
45
+ canonical_body.merge(
46
+ "attestation_digest" => attestation_digest
47
+ )
48
+ end
49
+
50
+ def canonical_body
51
+ {
52
+ "event_id" => event_id,
53
+ "kind" => kind,
54
+ "state" => state,
55
+ "provider_name" => provider_name,
56
+ "subject_digest" => subject_digest,
57
+ "chain_scope" => chain_scope,
58
+ "chain_sequence" => chain_sequence,
59
+ "provider_reference" => provider_reference,
60
+ "provider_result" => provider_result.presence,
61
+ "verification" => verification.presence,
62
+ "adapter_capabilities" => adapter_capabilities.presence,
63
+ "attempted_at" => Receipt.format_time(attempted_at),
64
+ "provider_reported_at" => Receipt.format_time(provider_reported_at),
65
+ "created_at" => Receipt.format_time(created_at)
66
+ }.compact
67
+ end
68
+
69
+ private
70
+
71
+ def assign_attestation_digest
72
+ self.attestation_digest ||= compute_attestation_digest
73
+ end
74
+
75
+ def compute_attestation_digest
76
+ Digest.digest_canonical(canonical_body)
77
+ end
78
+
79
+ def refuse_update
80
+ raise ImmutableEvidenceError,
81
+ "Integrity attestations cannot be updated through Clickwrap. " \
82
+ "Re-check a provider by recording a new attestation."
83
+ end
84
+
85
+ def refuse_destroy
86
+ raise ImmutableEvidenceError,
87
+ "Integrity attestations cannot be destroyed through Clickwrap."
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A legal hold pauses scheduled disposition for an event, an actor, or a
5
+ # policy.
6
+ #
7
+ # It requires a reason, an owner, and a review date. That is not bureaucracy:
8
+ # an indefinite hold with no owner is exactly how "we'll delete it later"
9
+ # becomes "we kept everything forever", and a retention policy that can be
10
+ # suspended invisibly is not a retention policy. Placement and release append
11
+ # linked lifecycle events; this row is the current operational projection and
12
+ # changes only through the named `release!` transition.
13
+ class LegalHold < ApplicationRecord
14
+ self.table_name = "clickwrap_legal_holds"
15
+ self.record_timestamps = false
16
+
17
+ SCOPES = %w[event actor policy].freeze
18
+
19
+ belongs_to :event, class_name: "Clickwrap::Event", optional: true, inverse_of: :legal_holds
20
+
21
+ validates :reason, :placed_by_reference, :placed_at, :review_at, presence: true
22
+ validates :hold_scope, inclusion: { in: SCOPES }
23
+ validate :scope_has_its_target
24
+ validate :review_follows_placement
25
+ before_update :refuse_ordinary_update
26
+ before_destroy :refuse_destroy, prepend: true
27
+
28
+ scope :in_effect, -> { where(released_at: nil) }
29
+ scope :due_for_review, ->(at = Clickwrap.now) { in_effect.where(review_at: ..at) }
30
+ scope :for_event, ->(id) { where(event_id: id) }
31
+ scope :for_actor, ->(reference) { where(actor_reference: reference) }
32
+
33
+ def released? = released_at.present?
34
+ def in_effect? = !released?
35
+
36
+ def release!(because:, released_by:)
37
+ raise LegalHoldInEffect, "Releasing a legal hold needs a `because:` explaining why." if because.to_s.strip.empty?
38
+ raise LegalHoldInEffect, "Legal hold #{id} was already released at #{released_at}." if released?
39
+
40
+ update_columns(
41
+ released_at: Clickwrap.now,
42
+ released_by_reference: Reference.actor(released_by),
43
+ release_reason: because
44
+ )
45
+ self
46
+ end
47
+
48
+ def to_s = "legal hold on #{hold_scope} (#{reason})"
49
+
50
+ private
51
+
52
+ def refuse_ordinary_update
53
+ raise ImmutableEvidenceError,
54
+ "Legal holds refuse ordinary updates. Release one through " \
55
+ "`release!(because:, released_by:)`, which records the named transition."
56
+ end
57
+
58
+ def refuse_destroy
59
+ raise ImmutableEvidenceError,
60
+ "Legal holds cannot be destroyed; release one through the named transition so its history remains."
61
+ end
62
+
63
+ def scope_has_its_target
64
+ target = case hold_scope
65
+ when "event" then event_id
66
+ when "actor" then actor_reference
67
+ when "policy" then policy_key
68
+ end
69
+
70
+ return if target.present?
71
+
72
+ errors.add(:base, "A #{hold_scope}-scoped legal hold must name the #{hold_scope} it holds.")
73
+ end
74
+
75
+ def review_follows_placement
76
+ return if placed_at.nil? || review_at.nil? || review_at > placed_at
77
+
78
+ errors.add(:review_at, "must be after the hold was placed")
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A frozen snapshot of one compiled policy.
5
+ #
6
+ # Policies are written in Ruby because that is what a reviewer can read in a
7
+ # pull request. But an export has to stay intelligible after the source has
8
+ # moved on, so the first time a revision is offered or captured its compiled
9
+ # form is written here and never changed. An event points at the revision it
10
+ # was captured under, so a receipt explains itself without a checkout of the
11
+ # application at the right commit.
12
+ #
13
+ # The revision digest covers declared structure and copy. Host callables —
14
+ # subject fingerprints, protected-outcome recorders — are recorded as present
15
+ # rather than serialized, because a lambda's body cannot be canonicalized.
16
+ # That boundary is stated in the receipt rather than papered over.
17
+ class PolicyRevision < ApplicationRecord
18
+ self.table_name = "clickwrap_policy_revisions"
19
+
20
+ has_many :events,
21
+ class_name: "Clickwrap::Event",
22
+ foreign_key: :policy_revision_id,
23
+ inverse_of: :policy_revision,
24
+ dependent: :restrict_with_error
25
+
26
+ has_many :presentations,
27
+ class_name: "Clickwrap::Presentation",
28
+ foreign_key: :policy_revision_id,
29
+ inverse_of: :policy_revision,
30
+ dependent: :restrict_with_error
31
+
32
+ validates :policy_key, :revision_digest, :canonical_schema_version, :gem_version, presence: true
33
+ validates :revision_digest, uniqueness: { scope: :policy_key }
34
+
35
+ before_update :refuse_change
36
+
37
+ scope :for_policy, ->(key) { where(policy_key: key.to_s) }
38
+
39
+ # Finds or freezes the revision for a compiled policy. Called on the first
40
+ # presentation and again at capture; both are races that the unique index
41
+ # settles, so a lost race just reads the row the winner wrote.
42
+ def self.freeze_for(policy)
43
+ existing = find_by(policy_key: policy.key, revision_digest: policy.revision)
44
+ return existing if existing
45
+
46
+ create!(
47
+ policy_key: policy.key,
48
+ revision_digest: policy.revision,
49
+ compiled_snapshot: policy.snapshot,
50
+ retention_class_key: policy.retention_class_key,
51
+ canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
52
+ gem_version: Clickwrap::VERSION,
53
+ compiled_at: Clickwrap.now,
54
+ created_at: Clickwrap.now
55
+ )
56
+ rescue ActiveRecord::RecordNotUnique
57
+ find_by!(policy_key: policy.key, revision_digest: policy.revision)
58
+ end
59
+
60
+ # Freezes the semantics of a legacy mapping without pretending the old act
61
+ # used the policy revision loaded today. The snapshot names only what the
62
+ # importer actually knows: source, mapped statements, known facts, and
63
+ # unknown facts. It intentionally cannot match `policy.revision`, so
64
+ # `require_current_revision: true` always asks for a modern presentation.
65
+ def self.freeze_legacy_import_for(policy, source:, statements:, known:, unknown:)
66
+ snapshot = {
67
+ "schema" => Clickwrap::CANONICAL_SCHEMA_VERSION,
68
+ "policy" => policy.key,
69
+ "revision_kind" => "legacy_import_mapping",
70
+ "source" => source.presence,
71
+ "statements" => statements.map { |statement| { "key" => statement.key, "kind" => statement.kind } },
72
+ "known" => known,
73
+ "unknown" => unknown,
74
+ "retention_class" => policy.retention_class_key
75
+ }.compact
76
+ digest = Digest.digest_canonical(snapshot)
77
+ existing = find_by(policy_key: policy.key, revision_digest: digest)
78
+ return existing if existing
79
+
80
+ create!(
81
+ policy_key: policy.key,
82
+ revision_digest: digest,
83
+ compiled_snapshot: snapshot,
84
+ retention_class_key: policy.retention_class_key,
85
+ canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
86
+ gem_version: Clickwrap::VERSION,
87
+ compiled_at: Clickwrap.now,
88
+ created_at: Clickwrap.now
89
+ )
90
+ rescue ActiveRecord::RecordNotUnique
91
+ find_by!(policy_key: policy.key, revision_digest: digest)
92
+ end
93
+
94
+ # True when the currently loaded policy compiles to this same revision. A
95
+ # false is not an error — it means the policy has been edited since, which
96
+ # is exactly why the snapshot is here.
97
+ def matches_loaded_policy?
98
+ Clickwrap.policies[policy_key]&.revision == revision_digest
99
+ end
100
+
101
+ def statement_snapshot(statement_key)
102
+ Array(compiled_snapshot["statements"]).find { |s| s["key"] == statement_key.to_s }
103
+ end
104
+
105
+ def to_s = "#{policy_key}@#{revision_digest}"
106
+
107
+ private
108
+
109
+ def refuse_change
110
+ raise EventWriteFailed,
111
+ "Policy revision #{self} is frozen. Editing the policy in Ruby produces a new " \
112
+ "revision; it does not change what earlier events were captured under."
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A record of one presentation manifest.
5
+ #
6
+ # Most presentations never become rows. The default path signs the manifest
7
+ # into a short-lived token and writes nothing on GET, because a render is not
8
+ # evidence and a row per page view is both slower and more personal data than
9
+ # the job needs.
10
+ #
11
+ # Rows appear in two cases. A policy can explicitly retain pre-submit
12
+ # presentations for a documented reason, which some high-assurance flows want
13
+ # so that display attempts are visible. And every successful capture persists
14
+ # the manifest it accepted, because that one is part of the evidence.
15
+ #
16
+ # The `state` vocabulary is deliberately careful. `presented_by_server` means
17
+ # the server generated and offered this manifest — not that anyone saw it, not
18
+ # that anyone read it, and certainly not that anyone accepted it.
19
+ class Presentation < ApplicationRecord
20
+ self.table_name = "clickwrap_presentations"
21
+
22
+ STATES = %w[presented_by_server accepted rejected expired].freeze
23
+
24
+ belongs_to :policy_revision, class_name: "Clickwrap::PolicyRevision", inverse_of: :presentations
25
+ belongs_to :actor, polymorphic: true, optional: true
26
+ belongs_to :subject, polymorphic: true, optional: true
27
+ belongs_to :represented_party, polymorphic: true, optional: true
28
+
29
+ has_many :events,
30
+ class_name: "Clickwrap::Event",
31
+ foreign_key: :presentation_id,
32
+ inverse_of: :presentation,
33
+ dependent: :restrict_with_error
34
+
35
+ validates :policy_key, :nonce, :manifest_digest, :issued_at, :expires_at, presence: true
36
+ validates :nonce, uniqueness: true
37
+ validates :state, inclusion: { in: STATES }
38
+ validates :capture_channel, inclusion: { in: Vocabulary::CAPTURE_CHANNELS }
39
+
40
+ scope :pending, -> { where(state: "presented_by_server") }
41
+ scope :expired_at, ->(moment = Clickwrap.now) { where(expires_at: ..moment) }
42
+ scope :due_for_disposition, lambda { |at = Clickwrap.now|
43
+ where.not(retain_until: nil).where(retain_until: ..at)
44
+ }
45
+
46
+ def expired?(at = Clickwrap.now) = expires_at <= at
47
+ def accepted? = state == "accepted"
48
+
49
+ def mark_accepted!(at: Clickwrap.now)
50
+ update!(state: "accepted", submitted_at: at)
51
+ end
52
+
53
+ def mark_rejected!(at: Clickwrap.now)
54
+ update!(state: "rejected", submitted_at: at)
55
+ end
56
+
57
+ def to_s = "presentation #{nonce} for #{policy_key}"
58
+ end
59
+ end