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,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ AUTHORITY_DECISION_ATTRIBUTES = %i[authorized source role verified_at details].freeze
5
+
6
+ # Namespace marker for authority adapters and future helpers. The public
7
+ # immutable value objects remain Clickwrap::AuthorityRule and
8
+ # Clickwrap::AuthorityDecision for the shortest host-facing API.
9
+ module Authority
10
+ end
11
+
12
+ # The server-owned rule that says which represented parties a policy allows
13
+ # and which adapter has to establish the actor's authority. It is compiled
14
+ # into the policy revision and repeated in the signed presentation, so a
15
+ # browser cannot choose a weaker role or a different authority source.
16
+ AuthorityRule = Data.define(
17
+ :represented_party_types,
18
+ :adapter_name,
19
+ :minimum_role,
20
+ :required_permission,
21
+ :allow_represented_party_creation
22
+ ) do
23
+ def initialize(represented_party_types: [], adapter_name: :host,
24
+ minimum_role: nil, required_permission: nil,
25
+ allow_represented_party_creation: false)
26
+ normalized_types = Array(represented_party_types).filter_map do |type|
27
+ name = type.is_a?(Class) ? type.name : type.to_s
28
+ name unless name.strip.empty?
29
+ end.uniq.freeze
30
+
31
+ if normalized_types.empty?
32
+ raise DefinitionError,
33
+ "A represented-party authority rule must name at least one represented-party " \
34
+ "class. An empty list would let the rule authorize every kind of record."
35
+ end
36
+
37
+ super(
38
+ represented_party_types: normalized_types,
39
+ adapter_name: adapter_name.to_s,
40
+ minimum_role: minimum_role&.to_s,
41
+ required_permission: required_permission&.to_s,
42
+ allow_represented_party_creation: allow_represented_party_creation == true
43
+ )
44
+ end
45
+
46
+ def allows_represented_party_creation? = allow_represented_party_creation == true
47
+
48
+ def permits?(represented_party)
49
+ return false if represented_party.nil?
50
+
51
+ represented_party.class.ancestors.any? do |ancestor|
52
+ ancestor.respond_to?(:name) && represented_party_types.include?(ancestor.name)
53
+ end
54
+ end
55
+
56
+ def to_snapshot
57
+ {
58
+ "represented_party_types" => represented_party_types,
59
+ "authority_adapter" => adapter_name,
60
+ "when_actor_is_at_least" => minimum_role,
61
+ "when_actor_has_permission" => required_permission,
62
+ "including_when_this_action_creates_the_represented_party" =>
63
+ allows_represented_party_creation?
64
+ }.compact
65
+ end
66
+ end
67
+
68
+ # The host's answer to "may this actor act for that represented party?".
69
+ # Clickwrap records the answer and its provenance; it does not decide whether
70
+ # the authority is legally sufficient.
71
+ AuthorityDecision = Data.define(:authorized, :source, :role, :verified_at, :details) do
72
+ def initialize(authorized: false, source: nil, role: nil, verified_at: nil, details: {})
73
+ super(
74
+ authorized: authorized == true,
75
+ source: source&.to_s,
76
+ role: role&.to_s,
77
+ verified_at: verified_at,
78
+ details: (details || {}).to_h.deep_stringify_keys.freeze
79
+ )
80
+ end
81
+
82
+ def authorized? = authorized == true
83
+
84
+ def to_snapshot
85
+ {
86
+ "state" => authorized? ? "verified" : "not_verified",
87
+ "source" => source,
88
+ "role" => role,
89
+ "verified_at" => verified_at&.iso8601(6),
90
+ "details" => details.presence
91
+ }.compact
92
+ end
93
+
94
+ def self.from(value)
95
+ return value if value.is_a?(self)
96
+
97
+ if value == true
98
+ raise ConfigurationError,
99
+ "The represented-party authority callback returned bare true. Return a " \
100
+ "Clickwrap::AuthorityDecision (or a hash) with `authorized:`, `source:`, " \
101
+ "`role:`, and `verified_at:` so the evidence records why the actor was authorized."
102
+ end
103
+
104
+ return new(authorized: false) if value.nil? || value == false
105
+
106
+ unless value.respond_to?(:to_h)
107
+ raise ConfigurationError,
108
+ "The represented-party authority callback must return false, nil, a " \
109
+ "Clickwrap::AuthorityDecision, or a hash with `authorized:`, `source:`, " \
110
+ "`role:`, and `verified_at:`. It returned #{value.class.name}."
111
+ end
112
+
113
+ attributes = value.to_h.symbolize_keys
114
+ unknown = attributes.keys - AUTHORITY_DECISION_ATTRIBUTES
115
+ if unknown.any?
116
+ label = unknown.one? ? "attribute" : "attributes"
117
+ raise ConfigurationError,
118
+ "The represented-party authority callback returned unknown #{label} " \
119
+ "#{unknown.map { |key| "`#{key}:`" }.join(", ")}. Supported attributes are " \
120
+ "#{AUTHORITY_DECISION_ATTRIBUTES.map { |key| "`#{key}:`" }.join(", ")}."
121
+ end
122
+
123
+ new(**attributes)
124
+ end
125
+ end
126
+
127
+ # One fail-closed path for presentation-time and capture-time authority
128
+ # checks. The represented-party adapter owns the authorization fact;
129
+ # Clickwrap only validates that an affirmative decision carries enough
130
+ # provenance to become meaningful evidence.
131
+ class AuthorityVerifier
132
+ def self.verify!(policy:, actor:, represented_party:, tenant:, authentication_context:)
133
+ unless policy.permits_acting_for_party?(represented_party)
134
+ raise AuthorityNotVerified,
135
+ "Policy #{policy.key} does not permit an actor to act for " \
136
+ "#{represented_party.class.name}. Declare the represented-party type and a " \
137
+ "reviewed server-side authority rule."
138
+ end
139
+
140
+ rule = policy.authority_rule
141
+ adapter = Clickwrap.config.represented_party_authority_adapter(rule.adapter_name)
142
+ raw = if adapter
143
+ adapter.verify(
144
+ actor: actor,
145
+ represented_party: represented_party,
146
+ authority_rule: rule,
147
+ tenant: tenant,
148
+ authentication_context: authentication_context
149
+ )
150
+ else
151
+ Clickwrap.config.verify_actor_can_act_for_represented_party_with.call(
152
+ actor: actor,
153
+ represented_party: represented_party,
154
+ policy: policy,
155
+ tenant: tenant,
156
+ authentication_context: authentication_context
157
+ )
158
+ end
159
+ decision = AuthorityDecision.from(raw)
160
+
161
+ unless decision.authorized?
162
+ raise AuthorityNotVerified,
163
+ "The host authority check did not authorize this actor to act for the represented party."
164
+ end
165
+
166
+ missing = %i[source role verified_at].select { |attribute| decision.public_send(attribute).blank? }
167
+ return decision if missing.empty?
168
+
169
+ raise AuthorityNotVerified,
170
+ "An authorized represented-party action must record #{missing.join(", ")}. Return " \
171
+ "those facts from `verify_actor_can_act_for_represented_party_with`."
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+
6
+ module Clickwrap
7
+ # Canonical JSON serialization, implementing the JSON Canonicalization Scheme
8
+ # (RFC 8785, https://www.rfc-editor.org/rfc/rfc8785).
9
+ #
10
+ # Receipts are digested and verified by code that may be years newer than the
11
+ # code that wrote them, and by verifiers written in other languages. So the
12
+ # bytes have to be reproducible from the data alone: no Ruby object
13
+ # serialization, no YAML, no hash insertion order, no database column order,
14
+ # no locale-dependent number formatting.
15
+ #
16
+ # The Clickwrap profile adds a few rules on top of RFC 8785, applied by the
17
+ # callers in ReceiptSchema rather than here:
18
+ #
19
+ # * timestamps are UTC strings with exactly six fractional digits;
20
+ # * digests are "<algorithm>:<lowercase hex>" strings;
21
+ # * identifiers are strings, never numbers;
22
+ # * a value that was never collected is an explicit state object, never
23
+ # `null` and never a missing key that could mean either; and
24
+ # * host extensions use keys prefixed `x_`.
25
+ module CanonicalJson
26
+ # Integers above this magnitude cannot survive a round trip through the
27
+ # IEEE 754 double that RFC 8785 assumes, so serializing one would produce
28
+ # bytes another verifier could not reproduce.
29
+ MAX_EXACT_INTEGER = (2**53) - 1
30
+
31
+ ESCAPES = {
32
+ "\b" => "\\b",
33
+ "\t" => "\\t",
34
+ "\n" => "\\n",
35
+ "\f" => "\\f",
36
+ "\r" => "\\r",
37
+ '"' => '\\"',
38
+ "\\" => "\\\\"
39
+ }.freeze
40
+
41
+ class SerializationError < Clickwrap::Error; end
42
+
43
+ class << self
44
+ # Returns the canonical UTF-8 JSON bytes for `value`.
45
+ def generate(value)
46
+ buffer = +""
47
+ write(value, buffer)
48
+ buffer.force_encoding(Encoding::UTF_8)
49
+ end
50
+
51
+ alias dump generate
52
+
53
+ # Parses JSON text and returns the canonical bytes for it. Useful for
54
+ # verifying a receipt that arrived as arbitrarily formatted JSON.
55
+ def canonicalize(json_text)
56
+ generate(JSON.parse(json_text))
57
+ end
58
+
59
+ # True when `json_text` is already in canonical form.
60
+ def canonical?(json_text)
61
+ canonicalize(json_text) == json_text
62
+ rescue JSON::ParserError, SerializationError
63
+ false
64
+ end
65
+
66
+ private
67
+
68
+ def write(value, buffer)
69
+ case value
70
+ when nil then buffer << "null"
71
+ when true then buffer << "true"
72
+ when false then buffer << "false"
73
+ when String then write_string(value, buffer)
74
+ when Symbol then write_string(value.to_s, buffer)
75
+ when Integer then write_integer(value, buffer)
76
+ when Float then buffer << format_number(value)
77
+ when Array then write_array(value, buffer)
78
+ when Hash then write_object(value, buffer)
79
+ else
80
+ raise SerializationError,
81
+ "#{value.class} cannot appear in canonical JSON. Convert it to a string, " \
82
+ "number, boolean, array, or hash before it reaches a receipt."
83
+ end
84
+ end
85
+
86
+ def write_array(array, buffer)
87
+ buffer << "["
88
+ array.each_with_index do |element, index|
89
+ buffer << "," unless index.zero?
90
+ write(element, buffer)
91
+ end
92
+ buffer << "]"
93
+ end
94
+
95
+ def write_object(hash, buffer)
96
+ entries = hash.map { |key, value| [canonical_key(key), value] }
97
+ detect_duplicate_keys(entries)
98
+
99
+ buffer << "{"
100
+ entries.sort_by { |key, _| utf16_sort_key(key) }.each_with_index do |(key, value), index|
101
+ buffer << "," unless index.zero?
102
+ write_string(key, buffer)
103
+ buffer << ":"
104
+ write(value, buffer)
105
+ end
106
+ buffer << "}"
107
+ end
108
+
109
+ def canonical_key(key)
110
+ case key
111
+ when String then key
112
+ when Symbol then key.to_s
113
+ else
114
+ raise SerializationError,
115
+ "Canonical JSON object keys must be strings or symbols, got #{key.class}"
116
+ end
117
+ end
118
+
119
+ def detect_duplicate_keys(entries)
120
+ keys = entries.map(&:first)
121
+ return if keys.uniq.length == keys.length
122
+
123
+ duplicate = keys.tally.find { |_, count| count > 1 }&.first
124
+ raise SerializationError, "Duplicate object key #{duplicate.inspect} in canonical JSON"
125
+ end
126
+
127
+ # RFC 8785 sorts object keys by their UTF-16 code units. Ruby compares
128
+ # strings by UTF-8 bytes, which orders characters outside the Basic
129
+ # Multilingual Plane differently, so encode before comparing.
130
+ def utf16_sort_key(key)
131
+ key.encode(Encoding::UTF_16BE, invalid: :replace, undef: :replace).b
132
+ end
133
+
134
+ def write_string(string, buffer)
135
+ raise SerializationError, "Canonical JSON strings must be valid UTF-8" unless string.valid_encoding?
136
+
137
+ buffer << '"'
138
+ string.each_char do |char|
139
+ escape = ESCAPES[char]
140
+ buffer << if escape
141
+ escape
142
+ elsif char.ord < 0x20
143
+ format('\\u%04x', char.ord)
144
+ else
145
+ char
146
+ end
147
+ end
148
+ buffer << '"'
149
+ end
150
+
151
+ def write_integer(value, buffer)
152
+ if value.abs > MAX_EXACT_INTEGER
153
+ raise SerializationError,
154
+ "#{value} is too large to serialize exactly in canonical JSON. " \
155
+ "Record large identifiers as strings."
156
+ end
157
+
158
+ buffer << value.to_s
159
+ end
160
+
161
+ # RFC 8785 defers to the ECMAScript Number::toString algorithm, which is
162
+ # not what Ruby's Float#to_s produces: Ruby writes 1.0 where ECMAScript
163
+ # writes 1, and 1.0e-05 where ECMAScript writes 0.00001. This rebuilds
164
+ # the ECMAScript form from Ruby's shortest round-trip digits.
165
+ def format_number(value)
166
+ raise SerializationError, "Canonical JSON cannot represent #{value}" if value.nan? || value.infinite?
167
+ return "0" if value.zero?
168
+
169
+ sign = value.negative? ? "-" : ""
170
+ digits, exponent = shortest_digits(value.abs)
171
+ sign + place_decimal_point(digits, exponent)
172
+ end
173
+
174
+ # Returns [digits, n] such that the value equals 0.<digits> * 10**n with
175
+ # no leading or trailing zero in `digits`.
176
+ def shortest_digits(magnitude)
177
+ text = magnitude.to_s
178
+ match = /\A(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?\z/.match(text)
179
+ raise SerializationError, "Cannot canonicalize the number #{text}" unless match
180
+
181
+ integer_part = match[1]
182
+ fraction_part = match[2] || ""
183
+ exponent = (match[3] || "0").to_i
184
+
185
+ digits = integer_part + fraction_part
186
+ position = integer_part.length + exponent
187
+
188
+ leading_zeros = digits.length - digits.sub(/\A0+/, "").length
189
+ digits = digits[leading_zeros..] || ""
190
+ position -= leading_zeros
191
+
192
+ digits = digits.sub(/0+\z/, "")
193
+ digits = "0" if digits.empty?
194
+
195
+ [digits, position]
196
+ end
197
+
198
+ def place_decimal_point(digits, position)
199
+ length = digits.length
200
+
201
+ if position.between?(length, 21)
202
+ digits + ("0" * (position - length))
203
+ elsif position.positive? && position <= 21
204
+ "#{digits[0, position]}.#{digits[position..]}"
205
+ elsif position > -6 && position <= 0
206
+ "0.#{"0" * -position}#{digits}"
207
+ else
208
+ exponent = position - 1
209
+ exponent_sign = exponent.negative? ? "-" : "+"
210
+ mantissa = length == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
211
+ "#{mantissa}e#{exponent_sign}#{exponent.abs}"
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ class Capture
5
+ # Builds one complete, unsaved event graph from already verified input.
6
+ # Persistence/idempotency stay in Capture because they decide whether the
7
+ # protected block may run; this collaborator only constructs evidence.
8
+ class EventBuilder
9
+ Result = Data.define(:event, :request_evidence_annex)
10
+
11
+ def initialize(policy:, manifest:, revision:, statement_snapshots:, answers:,
12
+ document_versions_by_id:, request_evidence:, event_type:,
13
+ root_event_id:, predecessor_event_id:, actor:, actor_reference:,
14
+ actor_snapshot:, represented_party:, authority_decision:, tenant_key:,
15
+ subject:, subject_key:, subject_fingerprint:, capture_channel:,
16
+ authentication_context:, attribution_method:, idempotency_key:,
17
+ http_request_id:, http_route_name:, reason:, statement_action_overrides:)
18
+ @policy = policy
19
+ @manifest = manifest
20
+ @revision = revision
21
+ @statement_snapshots = statement_snapshots
22
+ @answers = answers
23
+ @document_versions_by_id = document_versions_by_id
24
+ @request_evidence = request_evidence
25
+ @event_type = event_type
26
+ @root_event_id = root_event_id
27
+ @predecessor_event_id = predecessor_event_id
28
+ @actor = actor
29
+ @actor_reference = actor_reference
30
+ @actor_snapshot = actor_snapshot
31
+ @represented_party = represented_party
32
+ @authority_decision = authority_decision
33
+ @tenant_key = tenant_key
34
+ @subject = subject
35
+ @subject_key = subject_key
36
+ @subject_fingerprint = subject_fingerprint
37
+ @capture_channel = capture_channel
38
+ @authentication_context = authentication_context
39
+ @attribution_method = attribution_method
40
+ @idempotency_key = idempotency_key
41
+ @http_request_id = http_request_id
42
+ @http_route_name = http_route_name
43
+ @reason = reason
44
+ @statement_action_overrides = statement_action_overrides
45
+ end
46
+
47
+ def build
48
+ now = Clickwrap.now
49
+ event_id = Identifier.generate(now)
50
+ annex = build_request_evidence_annex(event_id)
51
+ event = build_event(event_id, annex, now)
52
+
53
+ build_statements(event, now)
54
+ build_documents(event)
55
+
56
+ Result.new(event: event, request_evidence_annex: annex)
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :policy, :manifest
62
+
63
+ def build_event(event_id, annex, now)
64
+ Event.new(
65
+ id: event_id,
66
+ request_evidence_category_binding_digests: annex&.category_binding_digests || {},
67
+ request_evidence_digest_algorithm: annex&.binding_digest_algorithm,
68
+ request_evidence_key_id: annex&.binding_key_id,
69
+ event_type: @event_type,
70
+ policy_key: policy.key,
71
+ policy_revision: @revision,
72
+ root_event_id: @root_event_id,
73
+ predecessor_event_id: @predecessor_event_id,
74
+ actor: persisted_record(@actor),
75
+ actor_reference: @actor_reference,
76
+ actor_snapshot: @actor_snapshot,
77
+ represented_party: persisted_record(@represented_party),
78
+ represented_party_reference: represented_party_reference,
79
+ authority_source: @authority_decision&.source,
80
+ authority_role: @authority_decision&.role,
81
+ authority_verified_at: @authority_decision&.verified_at,
82
+ authority_details: @authority_decision&.details || {},
83
+ tenant_key: @tenant_key.presence,
84
+ subject: persisted_record(@subject),
85
+ subject_key: @subject_key,
86
+ subject_fingerprint: @subject_fingerprint,
87
+ capture_channel: @capture_channel,
88
+ authentication_method: @authentication_context[:method]&.to_s,
89
+ authentication_context: @authentication_context,
90
+ attribution_method: @attribution_method,
91
+ recorded_at_by_server: now,
92
+ idempotency_key: @idempotency_key,
93
+ http_request_id: @http_request_id,
94
+ http_route_name: @http_route_name,
95
+ presentation: persisted_presentation,
96
+ presentation_manifest: manifest.to_h,
97
+ presentation_manifest_digest: manifest.digest,
98
+ retention_class_key: policy.retention_class_key,
99
+ reason: @reason,
100
+ canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
101
+ gem_version: Clickwrap::VERSION,
102
+ application_version: Clickwrap.config.resolved_application_version,
103
+ template_version: Clickwrap.config.resolved_template_version,
104
+ created_at: now
105
+ )
106
+ end
107
+
108
+ def build_statements(event, now)
109
+ @statement_snapshots.each_value.with_index do |statement, index|
110
+ key = statement.fetch("key")
111
+ fragment = manifest.statement(key) || {}
112
+ answer = @answers[key]
113
+ answered = statement["choices"] ? answer.present? : Submission.affirmative?(answer)
114
+
115
+ # Silence on an optional control is an offer not taken, not an
116
+ # affirmative refusal. It creates no grant statement.
117
+ next if statement["optional"] && !answered
118
+
119
+ event.statements.build(
120
+ ordinal: index,
121
+ statement_key: key,
122
+ kind: statement["kind"],
123
+ action: action_for(statement, answer, answered),
124
+ assertion_text: fragment["assertion"],
125
+ assertion_locale: manifest.locale,
126
+ label_text: fragment["label"],
127
+ link_labels: Array(fragment["documents"]).to_h { |document| [document["key"], document["label"]] },
128
+ choices: statement["choices"],
129
+ required: statement["required"],
130
+ optional: statement["optional"],
131
+ answer: answer,
132
+ answered: answered,
133
+ purpose_key: statement["purpose_key"],
134
+ withdrawal_path: statement["withdrawal_path"],
135
+ valid_from: now,
136
+ expires_at: policy.statement!(key).expires_after(now),
137
+ one_time: statement["one_time"],
138
+ requires: statement["requires"],
139
+ subject_fingerprint: fragment["subject_fingerprint"],
140
+ created_at: now
141
+ )
142
+ end
143
+ end
144
+
145
+ def action_for(statement, answer, answered)
146
+ override = @statement_action_overrides[statement.fetch("key")]
147
+ return override if override
148
+
149
+ initial = Vocabulary.initial_action_for(statement.fetch("kind"))
150
+ return initial unless statement["choices"] && answered
151
+
152
+ case statement["choices"][answer.to_s]
153
+ when "grant" then initial
154
+ when "decline" then "declined"
155
+ else statement["choices"][answer.to_s]
156
+ end
157
+ end
158
+
159
+ def build_documents(event)
160
+ ordinal = 0
161
+
162
+ manifest.statements.each do |statement|
163
+ Array(statement["documents"]).each do |document|
164
+ version = @document_versions_by_id.fetch(document["version_id"].to_s)
165
+
166
+ event.documents.build(
167
+ statement_key: statement["key"],
168
+ document_key: document["key"],
169
+ document_version_id: version.id,
170
+ version_label: document["version"],
171
+ locale: document["locale"],
172
+ source_media_type: document["source_media_type"],
173
+ source_content_digest: document["source_digest"],
174
+ rendered_media_type: document["rendered_media_type"],
175
+ rendered_content_digest: document["rendered_digest"],
176
+ renderer_name: document.dig("renderer", "name"),
177
+ renderer_version: document.dig("renderer", "version"),
178
+ sanitizer_name: document.dig("renderer", "sanitizer_name"),
179
+ sanitizer_version: document.dig("renderer", "sanitizer_version"),
180
+ ordinal: ordinal,
181
+ created_at: event.recorded_at_by_server
182
+ )
183
+
184
+ ordinal += 1
185
+ end
186
+ end
187
+ end
188
+
189
+ def build_request_evidence_annex(event_id)
190
+ return nil if @request_evidence.nil? || !@request_evidence.records_anything?
191
+
192
+ # Idempotent, and applied here as well as at boot: declaring an
193
+ # encrypted attribute reads the column, so an application whose
194
+ # database was not reachable during initialization would otherwise
195
+ # write this annex in plain text without anyone noticing.
196
+ RequestEvidence.apply_configured_encryption!
197
+
198
+ RequestEvidence.new(
199
+ @request_evidence.attributes.merge(event_id: event_id, created_at: Clickwrap.now)
200
+ )
201
+ end
202
+
203
+ def represented_party_reference
204
+ return "" if @represented_party.nil?
205
+
206
+ Reference.represented_party(@represented_party)
207
+ end
208
+
209
+ def persisted_record(value)
210
+ value if value.is_a?(::ActiveRecord::Base)
211
+ end
212
+
213
+ def persisted_presentation
214
+ return nil unless policy.persist_presentations?
215
+
216
+ Presentation.find_by(nonce: manifest.nonce)
217
+ end
218
+ end
219
+ end
220
+ end