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,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
|