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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- 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
|