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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # What `capture_and!` yields to the protected action, before anything has
5
+ # committed.
6
+ #
7
+ # It carries the one thing the domain row legitimately needs — a stable
8
+ # `event_id` to reference — and deliberately withholds everything else.
9
+ # Export and verification are unavailable because there is nothing yet to
10
+ # export or verify: the transaction may still roll back, and an object that
11
+ # happily serialized itself into a receipt at this point would be describing
12
+ # evidence that might never exist.
13
+ #
14
+ # If the transaction does roll back, this object becomes invalid rather than
15
+ # continuing to look like a committed record.
16
+ class PendingReceipt
17
+ attr_reader :event
18
+
19
+ def initialize(event, wait_for_outer_transaction: false)
20
+ @event = event
21
+ @wait_for_outer_transaction = wait_for_outer_transaction
22
+ @committed = false
23
+ @rolled_back = false
24
+ end
25
+
26
+ def event_id = event.id
27
+ def policy_key = event.policy_key
28
+ def recorded_at_by_server = event.recorded_at_by_server
29
+ def actor_reference = event.actor_reference
30
+ def subject_fingerprint = event.subject_fingerprint
31
+
32
+ def statements
33
+ event.statements.to_h { |statement| [statement.statement_key, statement.action] }
34
+ end
35
+
36
+ # Read the exact answer that this pending event accepted for one statement.
37
+ # Protected domain work often needs it before commit: for example, an
38
+ # optional consent can enable one preference while an unselected consent
39
+ # must leave that preference disabled. Reaching through
40
+ # `pending_receipt.event.statements` at every call site is both noisy and
41
+ # dangerously easy to get wrong (an offered-but-unselected option is not a
42
+ # grant).
43
+ #
44
+ # Unknown statement keys fail loudly so a typo cannot silently turn into a
45
+ # false boolean at a consequential boundary.
46
+ def answer_for(statement_key)
47
+ statement_for(statement_key)&.answer
48
+ end
49
+
50
+ def answered?(statement_key)
51
+ statement_for(statement_key)&.answered? || false
52
+ end
53
+
54
+ # Consent-oriented convenience predicates. They inspect BOTH the recorded
55
+ # action and whether the person actually answered, so these methods never
56
+ # reinterpret silence as a grant or decline.
57
+ def granted?(statement_key)
58
+ answered?(statement_key) && statement_for(statement_key).action == "granted"
59
+ end
60
+
61
+ def declined?(statement_key)
62
+ answered?(statement_key) && statement_for(statement_key).action == "declined"
63
+ end
64
+
65
+ def committed?
66
+ refresh_commit_state_if_possible!
67
+ @committed
68
+ end
69
+
70
+ def rolled_back? = @rolled_back
71
+
72
+ # Registers work that is only truthful after the real outer transaction
73
+ # commits. Clickwrap's controller adapters use this to clear a registration
74
+ # flow id without clearing it after a savepoint that later rolls back.
75
+ def when_durably_committed(&callback)
76
+ raise ArgumentError, "when_durably_committed needs a block" unless callback
77
+
78
+ if committed?
79
+ run_durable_commit_callback(callback)
80
+ elsif !rolled_back?
81
+ (@durable_commit_callbacks ||= []) << callback
82
+ end
83
+
84
+ self
85
+ end
86
+
87
+ # Once the real outermost transaction commits, this same object becomes a
88
+ # truthful handle to the finalized receipt. This matters when Clickwrap
89
+ # joins a host-owned transaction: the capture method has to return before
90
+ # that outer transaction does, so returning a Receipt there would claim a
91
+ # commit that has not happened yet.
92
+ def receipt
93
+ refuse(:receipt) unless committed?
94
+
95
+ Receipt.find(event_id)
96
+ end
97
+
98
+ def to_canonical_json = refuse(:to_canonical_json)
99
+ def to_html = refuse(:to_html)
100
+ def verify = refuse(:verify)
101
+ def export(*) = refuse(:export)
102
+
103
+ def inspect
104
+ state = if committed? then "committed"
105
+ elsif rolled_back? then "rolled back"
106
+ else "uncommitted"
107
+ end
108
+
109
+ "#<Clickwrap::PendingReceipt #{event_id} (#{state})>"
110
+ end
111
+
112
+ def to_s = event_id
113
+
114
+ # Internal transaction callbacks. Public only because the Event callback
115
+ # invokes them; downstream code gains no authority from calling them—the
116
+ # receipt lookup still requires the event to exist after commit.
117
+ def mark_committed!
118
+ # A non-joinable outer transaction (most visibly Rails' transactional
119
+ # test wrapper) can make a record-level after_commit fire at a savepoint.
120
+ # That is not durable finality. Leave the handle pending until no database
121
+ # transaction is open and the finalized row can be observed afresh.
122
+ return self if @wait_for_outer_transaction && ::ActiveRecord::Base.connection.transaction_open?
123
+
124
+ return self if @committed
125
+
126
+ @committed = true
127
+ @rolled_back = false
128
+ run_durable_commit_callbacks
129
+ self
130
+ end
131
+
132
+ def mark_rolled_back!
133
+ @committed = false
134
+ @rolled_back = true
135
+ @durable_commit_callbacks = []
136
+ self
137
+ end
138
+
139
+ private
140
+
141
+ def statement_for(statement_key)
142
+ # The policy lookup distinguishes an expected optional statement (which
143
+ # legitimately has no event row when left unselected) from a misspelled
144
+ # key. The former returns nil/false through the public helpers; the latter
145
+ # raises with the policy builder's precise error.
146
+ Clickwrap.policy!(policy_key).statement!(statement_key)
147
+ event.statement(statement_key)
148
+ end
149
+
150
+ def refresh_commit_state_if_possible!
151
+ return if @committed || @rolled_back || !@wait_for_outer_transaction
152
+ return if ::ActiveRecord::Base.connection.transaction_open?
153
+
154
+ mark_committed! if Event.where(id: event_id).where.not(event_digest: nil).exists?
155
+ end
156
+
157
+ def run_durable_commit_callbacks
158
+ callbacks = Array(@durable_commit_callbacks)
159
+ @durable_commit_callbacks = []
160
+ callbacks.each { |callback| run_durable_commit_callback(callback) }
161
+ end
162
+
163
+ def run_durable_commit_callback(callback)
164
+ callback.call
165
+ rescue StandardError => error
166
+ Clickwrap.report_after_commit_failure(error, event)
167
+ end
168
+
169
+ def refuse(method_name)
170
+ status = rolled_back? ? "rolled back" : "has not committed yet"
171
+
172
+ raise ReceiptNotCommitted,
173
+ "##{method_name} is not available because this pending receipt #{status}. There is " \
174
+ "no durable evidence to read until the real outermost database transaction commits."
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # A compiled, frozen policy: what the server will present and what it will
5
+ # accept back.
6
+ #
7
+ # The browser may answer a policy. It may never choose one. Policy key,
8
+ # revision, document versions, validity, subject binding, retention, and
9
+ # request-evidence fields are all resolved server-side and rechecked at
10
+ # submit, because every one of them is a security decision and a form field
11
+ # is not a safe place to keep one.
12
+ #
13
+ # A policy's `revision` is the digest of its compiled snapshot. Publishing
14
+ # freezes that snapshot in the database the first time the policy is
15
+ # presented or captured, so a receipt written today still explains itself
16
+ # after the Ruby source has moved on. The digest covers declared structure and
17
+ # copy; host lambdas (subject fingerprints, protected-outcome recorders) are
18
+ # recorded as present rather than serialized, since their bodies cannot be
19
+ # canonicalized. That boundary is stated in the receipt rather than papered
20
+ # over.
21
+ class Policy
22
+ attr_reader :key, :statements, :retention_class_key, :request_evidence, :persist_presentations_for,
23
+ :persist_presentations_because, :capture_channels, :locales, :authority_rule,
24
+ :tenant_scope, :options, :snapshot, :revision
25
+
26
+ def initialize(key:, statements:, retention_class_key: nil, request_evidence: nil,
27
+ persist_presentations_for: nil, persist_presentations_because: nil,
28
+ capture_channels: nil, locales: nil, tenant_scope: "optional",
29
+ authority_rule: nil, options: {})
30
+ @key = key.to_s
31
+ @statements = statements.freeze
32
+ @retention_class_key = retention_class_key&.to_s
33
+ @request_evidence = request_evidence || RequestEvidencePolicy.new(policy_key: @key)
34
+ @persist_presentations_for = persist_presentations_for
35
+ @persist_presentations_because = persist_presentations_because
36
+ @capture_channels = (capture_channels || Vocabulary::CAPTURE_CHANNELS).map(&:to_s).freeze
37
+ @locales = locales&.map(&:to_s)&.freeze
38
+ @tenant_scope = tenant_scope.to_s
39
+ @authority_rule = authority_rule
40
+ @options = options.freeze
41
+
42
+ validate!
43
+ @snapshot = build_snapshot.freeze
44
+ @revision = Digest.digest_canonical(@snapshot).freeze
45
+ freeze
46
+ end
47
+
48
+ def statement(statement_key)
49
+ statements.find { |statement| statement.key == statement_key.to_s }
50
+ end
51
+
52
+ def statement!(statement_key)
53
+ statement(statement_key) || raise(
54
+ UnknownStatementError,
55
+ "Policy #{key} has no statement #{statement_key.inspect}. It declares: " \
56
+ "#{statements.map(&:key).join(", ")}."
57
+ )
58
+ end
59
+
60
+ def required_statements = statements.select(&:required?)
61
+ def optional_statements = statements.select(&:optional?)
62
+ def document_keys = statements.flat_map(&:document_keys).uniq
63
+ def kinds = statements.map(&:kind).uniq
64
+
65
+ def one_time_statements = statements.select(&:one_time?)
66
+ def subject_bound? = statements.any?(&:subject_bound?)
67
+ def consent_statements = statements.select { |statement| statement.kind == "consent" }
68
+ def authorization_statements = statements.select { |statement| statement.kind == "authorization" }
69
+ def protected_outcome_statements = statements.select(&:record_protected_outcome_with)
70
+ def protected_outcome_statement = protected_outcome_statements.first
71
+ def records_protected_outcome? = protected_outcome_statement.present?
72
+
73
+ def persist_presentations? = !persist_presentations_for.nil?
74
+
75
+ # Whether a system exemption may stand in for a human action under this
76
+ # policy. It is off unless the policy says otherwise, and an exemption never
77
+ # satisfies `agreed_to?` or any other human-action predicate even when it
78
+ # is permitted — it answers `exempted_from?` instead.
79
+ def permits_exemptions? = options.fetch(:permit_exemptions, false) == true
80
+
81
+ def permits_capture_channel?(channel) = capture_channels.include?(channel.to_s)
82
+
83
+ def permits_locale?(locale)
84
+ locales.nil? || locales.include?(locale.to_s)
85
+ end
86
+
87
+ def tenant_not_applicable? = tenant_scope == "not_applicable"
88
+ def tenant_required? = tenant_scope == "required"
89
+ def tenant_optional? = tenant_scope == "optional"
90
+
91
+ # Resolves ambient controller context according to this policy. Personal
92
+ # evidence deliberately discards a current organization; tenant-required
93
+ # evidence fails before rendering if the host cannot supply one.
94
+ def tenant_from_controller(candidate)
95
+ return nil if tenant_not_applicable?
96
+
97
+ validate_tenant!(candidate)
98
+ candidate
99
+ end
100
+
101
+ # Direct service callers own their arguments, so an incompatible explicit
102
+ # value is rejected instead of silently rewritten.
103
+ def validate_tenant!(tenant)
104
+ if tenant_not_applicable? && tenant.present?
105
+ raise DefinitionError,
106
+ "Policy #{key} says `tenant_is :not_applicable`, but this call supplied a tenant. " \
107
+ "Remove `tenant:`; personal evidence must not change identity when the actor joins " \
108
+ "or switches organizations."
109
+ end
110
+
111
+ if tenant_required? && tenant.nil?
112
+ raise DefinitionError,
113
+ "Policy #{key} says `tenant_is :required`, but this call supplied no tenant. Pass " \
114
+ "the server-resolved tenant to presentation, capture, and verification."
115
+ end
116
+
117
+ true
118
+ end
119
+
120
+ # Delegation, guardianship, service-account action, and impersonation are
121
+ # rejected unless the policy opts in and the host authority adapter agrees.
122
+ # When permitted, the receipt keeps the authenticated principal, asserted
123
+ # actor, and represented party as separate facts; Clickwrap does not decide
124
+ # whether that authority is sufficient.
125
+ def permits_acting_for? = options.fetch(:permit_acting_for, false) == true
126
+
127
+ def permits_acting_for_party?(represented_party)
128
+ permits_acting_for? && authority_rule&.permits?(represented_party)
129
+ end
130
+
131
+ def to_s = "Clickwrap policy #{key} (#{revision})"
132
+
133
+ private
134
+
135
+ def build_snapshot
136
+ {
137
+ "schema" => Clickwrap::CANONICAL_SCHEMA_VERSION,
138
+ "policy" => key,
139
+ "statements" => statements.map(&:to_snapshot),
140
+ "retention_class" => retention_class_key,
141
+ "request_evidence" => request_evidence.to_snapshot,
142
+ "persist_presentations_for_seconds" => persist_presentations_for&.to_i,
143
+ "persist_presentations_because" => persist_presentations_because,
144
+ "capture_channels" => capture_channels,
145
+ "locales" => locales,
146
+ "tenant_scope" => tenant_scope,
147
+ "permit_exemptions" => permits_exemptions?,
148
+ "permit_acting_for" => permits_acting_for?,
149
+ "represented_party_authority" => authority_rule&.to_snapshot
150
+ }.compact
151
+ end
152
+
153
+ def validate!
154
+ validate_statements_present!
155
+ validate_unique_statement_keys!
156
+ validate_one_protected_outcome_recorder!
157
+ validate_prerequisites!
158
+ validate_retention!
159
+ validate_persisted_presentations!
160
+ validate_capture_channels!
161
+ validate_tenant_scope!
162
+ validate_authority_rule!
163
+ end
164
+
165
+ def validate_authority_rule!
166
+ if permits_acting_for? && authority_rule.nil?
167
+ raise DefinitionError,
168
+ "Policy #{key} permits represented-party action without a compiled authority rule."
169
+ end
170
+
171
+ return unless authority_rule
172
+
173
+ unless permits_acting_for?
174
+ raise DefinitionError,
175
+ "Policy #{key} has a represented-party authority rule but does not permit acting for another party."
176
+ end
177
+
178
+ return if authority_rule.adapter_name == "host"
179
+ return if Clickwrap.config.represented_party_authority_adapter(authority_rule.adapter_name)
180
+
181
+ raise DefinitionError,
182
+ "Policy #{key} uses represented-party authority adapter " \
183
+ "#{authority_rule.adapter_name.inspect}, but it is not registered. Register it with " \
184
+ "`config.register_represented_party_authority`. Registered adapters: " \
185
+ "#{Clickwrap.config.represented_party_authority_adapter_names.join(", ").presence || "(none)"}."
186
+ end
187
+
188
+ def validate_statements_present!
189
+ return unless statements.empty?
190
+
191
+ raise DefinitionError,
192
+ "Policy #{key} declares no statements. A policy that asks for nothing records " \
193
+ "nothing; add at least one `agree_to`, `acknowledge`, `consent_to`, `declare`, " \
194
+ "`attest`, or `authorize`."
195
+ end
196
+
197
+ def validate_unique_statement_keys!
198
+ duplicates = statements.map(&:key).tally.select { |_, count| count > 1 }.keys
199
+ return if duplicates.empty?
200
+
201
+ raise DefinitionError,
202
+ "Policy #{key} declares #{duplicates.join(", ")} more than once. Two statements " \
203
+ "with the same key would produce evidence nobody can tell apart."
204
+ end
205
+
206
+ def validate_one_protected_outcome_recorder!
207
+ return if protected_outcome_statements.length <= 1
208
+
209
+ raise DefinitionError,
210
+ "Policy #{key} configures protected-outcome recorders on " \
211
+ "#{protected_outcome_statements.map(&:key).join(", ")}. One protected action has one " \
212
+ "result snapshot; put `record_protected_outcome_with:` on exactly one statement."
213
+ end
214
+
215
+ # `requires:` says an authorization is only good when named statements were
216
+ # made in the same submission, and in order. A prerequisite that does not
217
+ # exist, or that comes later on the page, is a policy bug rather than a
218
+ # runtime surprise.
219
+ def validate_prerequisites!
220
+ statements.each do |statement|
221
+ statement.requires.each do |prerequisite_key|
222
+ prerequisite = statement(prerequisite_key)
223
+
224
+ unless prerequisite
225
+ raise DefinitionError,
226
+ "Statement #{statement.key} in policy #{key} requires #{prerequisite_key}, " \
227
+ "which this policy does not declare."
228
+ end
229
+
230
+ next if prerequisite.ordinal < statement.ordinal
231
+
232
+ raise DefinitionError,
233
+ "Statement #{statement.key} in policy #{key} requires #{prerequisite_key}, " \
234
+ "but that statement is declared after it. Prerequisites must come first, " \
235
+ "because that is the order the person will be asked."
236
+ end
237
+ end
238
+ end
239
+
240
+ def validate_retention!
241
+ return if retention_class_key
242
+
243
+ raise DefinitionError,
244
+ "Policy #{key} has no retention class. Add `retain_with :some_class` and define " \
245
+ "that class with `Clickwrap.retention`. Clickwrap will not default your evidence " \
246
+ "to forever, and it will not pick a period for you."
247
+ end
248
+
249
+ def validate_persisted_presentations!
250
+ return unless persist_presentations?
251
+
252
+ if persist_presentations_because.to_s.strip.empty?
253
+ raise DefinitionError,
254
+ "Policy #{key} persists presentations before submission but gives no `because:`. " \
255
+ "Storing every render is more personal data than the default path; say why."
256
+ end
257
+
258
+ return if persist_presentations_for.to_i.positive?
259
+
260
+ raise DefinitionError,
261
+ "Policy #{key} persists presentations for #{persist_presentations_for.inspect}, " \
262
+ "which is not a retention period."
263
+ end
264
+
265
+ def validate_capture_channels!
266
+ unknown = capture_channels - Vocabulary::CAPTURE_CHANNELS
267
+ return if unknown.empty?
268
+
269
+ raise DefinitionError,
270
+ "Policy #{key} allows unknown capture channels #{unknown.join(", ")}. " \
271
+ "Choose from: #{Vocabulary::CAPTURE_CHANNELS.join(", ")}."
272
+ end
273
+
274
+ def validate_tenant_scope!
275
+ return if %w[not_applicable optional required].include?(tenant_scope)
276
+
277
+ raise DefinitionError,
278
+ "Policy #{key} says `tenant_is #{tenant_scope.inspect}`. Choose `:not_applicable`, " \
279
+ "`:optional`, or `:required`; Clickwrap will not guess whether ambient tenant context " \
280
+ "belongs in an evidence identity."
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The canonical description of one offer: which policy revision, which
5
+ # document versions and digests, the exact sentences and link labels, the
6
+ # choice structure, the call to action, who it was issued to, and when it
7
+ # stops being valid.
8
+ #
9
+ # This exists because "the database says version 3" is a weak claim. It says
10
+ # what the row contains now, not what the server offered then. A manifest is
11
+ # generated by the server at render, signed, and verified again at submit
12
+ # against the current immutable rows — so a deploy between GET and POST can
13
+ # never cause the server to record a version the person was not offered.
14
+ #
15
+ # What it proves is bounded, and the receipt says so: the server generated and
16
+ # accepted this particular offer. It does not prove the person read the
17
+ # document, understood it, saw particular pixels, or received an interface
18
+ # that any given jurisdiction would consider adequate.
19
+ class PresentationManifest
20
+ SIGNING_PURPOSE = "clickwrap/presentation"
21
+
22
+ attr_reader :attributes
23
+
24
+ def initialize(attributes)
25
+ @attributes = deep_stringify(attributes).freeze
26
+ # Computed here rather than memoized on first use: the object is frozen
27
+ # below, and a manifest that could still change after construction is not
28
+ # a manifest anything should be bound to.
29
+ @digest = Digest.digest_canonical(
30
+ @attributes, algorithm: Clickwrap.config.digest_canonical_receipts_with.to_s
31
+ ).freeze
32
+ freeze
33
+ end
34
+
35
+ class << self
36
+ def build(policy:, revision_digest:, statements:, submit_button_text:, locale:,
37
+ actor_reference: nil, actor_type: nil, tenant_key: nil, subject_key: nil,
38
+ subject_fingerprint: nil, registration_flow_id: nil,
39
+ prospective_actor_type: nil, represented_party_reference: nil,
40
+ represented_party_type: nil, authority_rule: nil,
41
+ represented_party_creation_flow_id: nil,
42
+ represented_party_will_be_created_by_protected_action: false,
43
+ authority_at_presentation: nil, combined_control: nil,
44
+ capture_channel: "web_browser", issued_at: nil, expires_at: nil, nonce: nil)
45
+ issued_at ||= Clickwrap.now
46
+ expires_at ||= issued_at + Clickwrap.config.presentation_valid_for
47
+
48
+ attributes = {
49
+ "schema" => Clickwrap::CANONICAL_SCHEMA_VERSION,
50
+ "policy" => { "key" => policy.key, "revision" => revision_digest },
51
+ "statements" => statements,
52
+ "submit_button_text" => submit_button_text,
53
+ "locale" => locale.to_s,
54
+ "actor" => { "reference" => actor_reference, "type" => actor_type }.compact.presence,
55
+ "registration_flow_id" => registration_flow_id,
56
+ "prospective_actor_type" => prospective_actor_type,
57
+ "represented_party" => {
58
+ "reference" => represented_party_reference,
59
+ "type" => represented_party_type,
60
+ "authority_rule" => authority_rule,
61
+ "creation_flow_id" => represented_party_creation_flow_id,
62
+ "will_be_created_by_protected_action" =>
63
+ represented_party_will_be_created_by_protected_action == true,
64
+ "authority_at_presentation" => authority_at_presentation
65
+ }.compact.presence,
66
+ "tenant_key" => tenant_key,
67
+ "subject" => { "reference" => subject_key, "fingerprint" => subject_fingerprint }.compact.presence,
68
+ "capture_channel" => capture_channel.to_s,
69
+ "issued_at" => Receipt.format_time(issued_at),
70
+ "expires_at" => Receipt.format_time(expires_at),
71
+ "nonce" => nonce || SecureRandom.uuid,
72
+ "gem_version" => Clickwrap::VERSION,
73
+ "application_version" => Clickwrap.config.resolved_application_version,
74
+ "template_version" => Clickwrap.config.resolved_template_version
75
+ }
76
+
77
+ # Only present when the offer really was one control, so a manifest
78
+ # from an itemized presentation is byte-identical to the ones this gem
79
+ # has always written.
80
+ attributes["combined_control"] = combined_control if combined_control
81
+
82
+ new(attributes)
83
+ end
84
+
85
+ # Reads a token back into a manifest. Every failure here is the same kind
86
+ # of failure — the browser handed us something we did not sign, or signed
87
+ # too long ago — so they all raise PresentationInvalid rather than leaking
88
+ # which specific check tripped.
89
+ def from_token(token)
90
+ raise PresentationInvalid, "No presentation token was submitted" if token.blank?
91
+
92
+ payload = verifier.verified(token, purpose: SIGNING_PURPOSE)
93
+
94
+ raise PresentationInvalid, "The presentation token could not be verified" if payload.nil?
95
+
96
+ new(payload)
97
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
98
+ raise PresentationInvalid, "The presentation token could not be verified"
99
+ end
100
+
101
+ def verifier
102
+ @verifier ||= build_verifier
103
+ end
104
+
105
+ def reset_verifier!
106
+ @verifier = nil
107
+ end
108
+
109
+ private
110
+
111
+ def build_verifier
112
+ secret =
113
+ if defined?(::Rails) && ::Rails.application
114
+ ::Rails.application.key_generator.generate_key("clickwrap/presentation-manifest", 32)
115
+ else
116
+ ENV.fetch("CLICKWRAP_PRESENTATION_SECRET") do
117
+ raise ConfigurationError,
118
+ "Clickwrap needs a signing secret for presentation tokens. Inside Rails it " \
119
+ "derives one from secret_key_base; outside Rails, set " \
120
+ "CLICKWRAP_PRESENTATION_SECRET."
121
+ end
122
+ end
123
+
124
+ ActiveSupport::MessageVerifier.new(secret, digest: "SHA256", serializer: JSON)
125
+ end
126
+ end
127
+
128
+ def [](key) = attributes[key.to_s]
129
+
130
+ def policy_key = attributes.dig("policy", "key")
131
+ def revision_digest = attributes.dig("policy", "revision")
132
+ def statements = attributes["statements"] || []
133
+ def submit_button_text = attributes["submit_button_text"]
134
+ def locale = attributes["locale"]
135
+ def nonce = attributes["nonce"]
136
+ def actor_reference = attributes.dig("actor", "reference")
137
+ def registration_flow_id = attributes["registration_flow_id"]
138
+ def prospective_actor_type = attributes["prospective_actor_type"]
139
+ def represented_party_reference = attributes.dig("represented_party", "reference")
140
+ def represented_party_type = attributes.dig("represented_party", "type")
141
+ def authority_rule = attributes.dig("represented_party", "authority_rule")
142
+ def represented_party_creation_flow_id = attributes.dig("represented_party", "creation_flow_id")
143
+
144
+ def represented_party_will_be_created_by_protected_action?
145
+ attributes.dig("represented_party", "will_be_created_by_protected_action") == true
146
+ end
147
+
148
+ def authority_at_presentation = attributes.dig("represented_party", "authority_at_presentation")
149
+ def tenant_key = attributes["tenant_key"]
150
+ def subject_key = attributes.dig("subject", "reference")
151
+ def subject_fingerprint = attributes.dig("subject", "fingerprint")
152
+ def capture_channel = attributes["capture_channel"]
153
+ def issued_at = parse_time(attributes["issued_at"])
154
+ def expires_at = parse_time(attributes["expires_at"])
155
+
156
+ def statement(key)
157
+ statements.find { |statement| statement["key"] == key.to_s }
158
+ end
159
+
160
+ # The one control this offer rendered, and the statements it answered.
161
+ #
162
+ # This is where the substitution defense lives for a composed presentation.
163
+ # The per-statement assertions below say what each act was; this says what
164
+ # the person actually READ — one sentence, composed from those acts, with
165
+ # the document links inside it — and which keys the single answer covers.
166
+ # Nil whenever the offer was itemized.
167
+ def combined_control = attributes["combined_control"]
168
+ def combined_sentence = combined_control&.fetch("sentence", nil)
169
+ def combined_statement_keys = Array(combined_control&.fetch("covers", nil))
170
+
171
+ # The statement key the single control is submitted under. Every covered
172
+ # key takes its answer from this one, server-side.
173
+ def combined_answered_as = combined_control&.fetch("answered_as", nil)
174
+
175
+ def expired?(at = Clickwrap.now) = expires_at.nil? || expires_at <= at
176
+
177
+ # Computed in the constructor, before the object freezes. Lazy memoization
178
+ # would raise on a frozen manifest, and recomputing on every call would
179
+ # canonicalize the whole body each time it is read — which happens on every
180
+ # render and every capture.
181
+ attr_reader :digest
182
+
183
+ def to_token
184
+ self.class.verifier.generate(attributes, purpose: SIGNING_PURPOSE, expires_at: expires_at)
185
+ end
186
+
187
+ def to_h = attributes
188
+
189
+ def to_s = "presentation of #{policy_key} (#{nonce})"
190
+
191
+ private
192
+
193
+ def parse_time(value)
194
+ return nil if value.blank?
195
+
196
+ Time.parse(value).utc
197
+ rescue ArgumentError
198
+ nil
199
+ end
200
+
201
+ def deep_stringify(value)
202
+ case value
203
+ when Hash then value.to_h { |key, nested| [key.to_s, deep_stringify(nested)] }
204
+ when Array then value.map { |element| deep_stringify(element) }
205
+ when Symbol then value.to_s
206
+ else value
207
+ end
208
+ end
209
+ end
210
+ end