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,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Answers "does this actor currently satisfy this policy?" and, when the
|
|
5
|
+
# answer is no, exactly why.
|
|
6
|
+
#
|
|
7
|
+
# The convention across the whole gem is consistent: predicates answer
|
|
8
|
+
# booleans, `verify` returns a Result, and bang methods raise a typed error
|
|
9
|
+
# carrying that same Result. An application should never have to parse an
|
|
10
|
+
# English message to make an authorization decision, so the reason is always
|
|
11
|
+
# one of the stable symbols in Clickwrap::Vocabulary::VERIFICATION_ERRORS and
|
|
12
|
+
# the human sentence is localized separately.
|
|
13
|
+
module Verification
|
|
14
|
+
UNSPECIFIED = Object.new.freeze
|
|
15
|
+
|
|
16
|
+
# The structured answer. `details` carries stable machine-readable facts —
|
|
17
|
+
# never a surprise field of personal data, because a verification result
|
|
18
|
+
# routinely ends up in a log line or an API response.
|
|
19
|
+
class Result
|
|
20
|
+
attr_reader :error, :policy_key, :statement_key, :event_id, :details
|
|
21
|
+
|
|
22
|
+
def initialize(success:, error: nil, policy_key: nil, statement_key: nil,
|
|
23
|
+
event_id: nil, details: {})
|
|
24
|
+
@success = success
|
|
25
|
+
@error = error
|
|
26
|
+
@policy_key = policy_key
|
|
27
|
+
@statement_key = statement_key
|
|
28
|
+
@event_id = event_id
|
|
29
|
+
@details = details.freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.success(policy_key:, event_id: nil, details: {})
|
|
34
|
+
new(success: true, policy_key: policy_key, event_id: event_id, details: details)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether this result's evidence was durably sequenced after another's.
|
|
38
|
+
# Events written after the recording-order migration reserve a
|
|
39
|
+
# database-assigned sequence, so this remains true across actors, app
|
|
40
|
+
# processes, and same-microsecond writes; ULID lexical order is
|
|
41
|
+
# deliberately not used as chronology. It returns false for either event
|
|
42
|
+
# predating that migration because an upgrade cannot invent honest order.
|
|
43
|
+
#
|
|
44
|
+
# declaration.recorded_after?(acknowledgments) # => true or false
|
|
45
|
+
#
|
|
46
|
+
# Accepts another verification result or a bare event id. False whenever
|
|
47
|
+
# either side has no event, which composes with `success?` the way a
|
|
48
|
+
# guard should: nothing about a missing act is "after" anything.
|
|
49
|
+
def recorded_after?(other)
|
|
50
|
+
other_event_id = other.respond_to?(:event_id) ? other.event_id : other
|
|
51
|
+
return false unless event_id.present? && other_event_id.present?
|
|
52
|
+
|
|
53
|
+
current, previous = Event.where(id: [event_id, other_event_id]).index_by(&:id).values_at(
|
|
54
|
+
event_id.to_s, other_event_id.to_s
|
|
55
|
+
)
|
|
56
|
+
return false unless current && previous
|
|
57
|
+
return false if current.recording_sequence.blank? || previous.recording_sequence.blank?
|
|
58
|
+
|
|
59
|
+
current.recording_sequence > previous.recording_sequence
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One predicate per stable error symbol, generated from the vocabulary so
|
|
63
|
+
# the two can never drift: `result.subject_fingerprint_mismatch?`,
|
|
64
|
+
# `result.no_evidence?`, `result.consent_withdrawn?`, … Branching on a
|
|
65
|
+
# predicate reads aloud and survives typos (a misspelled predicate is
|
|
66
|
+
# NoMethodError; a misspelled symbol comparison is silently false).
|
|
67
|
+
Vocabulary::VERIFICATION_ERRORS.each do |error_symbol|
|
|
68
|
+
define_method("#{error_symbol}?") { error == error_symbol }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.failure(error, policy_key: nil, statement_key: nil, event_id: nil, details: {})
|
|
72
|
+
unless Vocabulary::VERIFICATION_ERRORS.include?(error)
|
|
73
|
+
raise ArgumentError,
|
|
74
|
+
"#{error.inspect} is not a stable verification error. Add it to " \
|
|
75
|
+
"Clickwrap::Vocabulary::VERIFICATION_ERRORS so applications can branch on it."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
new(success: false, error: error, policy_key: policy_key, statement_key: statement_key,
|
|
79
|
+
event_id: event_id, details: details)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def success? = @success
|
|
83
|
+
def failure? = !@success
|
|
84
|
+
|
|
85
|
+
def message
|
|
86
|
+
return I18n.t("clickwrap.verification.success", default: "Verified.") if success?
|
|
87
|
+
|
|
88
|
+
I18n.t(
|
|
89
|
+
"clickwrap.verification.errors.#{error}",
|
|
90
|
+
policy: policy_key,
|
|
91
|
+
statement: statement_key,
|
|
92
|
+
default: default_message
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_h
|
|
97
|
+
{
|
|
98
|
+
"success" => success?,
|
|
99
|
+
"policy" => policy_key,
|
|
100
|
+
"statement" => statement_key,
|
|
101
|
+
"error" => error&.to_s,
|
|
102
|
+
"event_id" => event_id,
|
|
103
|
+
"details" => details
|
|
104
|
+
}.compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def as_json(*) = to_h
|
|
108
|
+
|
|
109
|
+
def inspect
|
|
110
|
+
success? ? "#<Clickwrap::Verification::Result success>" : "#<Clickwrap::Verification::Result #{error}>"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def default_message
|
|
116
|
+
error.to_s.tr("_", " ").capitalize
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class << self
|
|
121
|
+
# Verifies a policy for an actor, or re-verifies one specific recorded
|
|
122
|
+
# event. Both are the same question asked from different ends: the first
|
|
123
|
+
# is "is there current evidence", the second is "is this evidence still
|
|
124
|
+
# good for this exact operation".
|
|
125
|
+
#
|
|
126
|
+
# "The same question" is a promise, so both ends answer every part of it.
|
|
127
|
+
# `subject:` re-derives the subject fingerprint from the live record, and
|
|
128
|
+
# `require_current_revision: true` re-asks whether the act was made under
|
|
129
|
+
# the wording that is current now — on an event id exactly as on a policy
|
|
130
|
+
# key. That is the whole reason a host never has to reach into
|
|
131
|
+
# Clickwrap::PolicyRevision or Clickwrap::SubjectFingerprint to ask
|
|
132
|
+
# "is this old evidence still good?":
|
|
133
|
+
#
|
|
134
|
+
# Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)
|
|
135
|
+
def verify(policy_or_event, actor: nil, subject: nil, tenant: nil, acting_for: UNSPECIFIED,
|
|
136
|
+
policy: nil, at: nil, require_current_revision: false)
|
|
137
|
+
at ||= Clickwrap.now
|
|
138
|
+
|
|
139
|
+
if policy_or_event.is_a?(String) && Identifier.valid?(policy_or_event)
|
|
140
|
+
verify_event(policy_or_event, policy: policy, subject: subject,
|
|
141
|
+
acting_for: acting_for, at: at,
|
|
142
|
+
require_current_revision: require_current_revision)
|
|
143
|
+
else
|
|
144
|
+
acting_for = nil if acting_for.equal?(UNSPECIFIED)
|
|
145
|
+
verify_policy(policy_or_event, actor: actor, subject: subject, tenant: tenant,
|
|
146
|
+
acting_for: acting_for, at: at,
|
|
147
|
+
require_current_revision: require_current_revision)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def verify_policy(policy_key, actor:, subject:, tenant:, acting_for:, at:,
|
|
154
|
+
require_current_revision: false)
|
|
155
|
+
policy = Clickwrap.policies[policy_key.to_s]
|
|
156
|
+
|
|
157
|
+
return Result.failure(:unknown_policy, policy_key: policy_key.to_s) unless policy
|
|
158
|
+
|
|
159
|
+
policy.validate_tenant!(tenant)
|
|
160
|
+
|
|
161
|
+
actor_reference = reference_for(actor)
|
|
162
|
+
|
|
163
|
+
return Result.failure(:wrong_actor, policy_key: policy.key) unless actor_reference
|
|
164
|
+
|
|
165
|
+
states = load_states(policy, actor_reference, tenant, subject, acting_for)
|
|
166
|
+
# Resolved once, only when asked for: "was this act made under the
|
|
167
|
+
# wording that is current NOW?" A bumped statement compiles a new
|
|
168
|
+
# revision, so evidence recorded under a superseded one re-asks — the
|
|
169
|
+
# verify-time counterpart of the capture-time :stale_policy_revision.
|
|
170
|
+
current_revision_id = (PolicyRevision.freeze_for(policy).id if require_current_revision)
|
|
171
|
+
|
|
172
|
+
policy.required_statements.each do |statement|
|
|
173
|
+
state = states[statement.key]
|
|
174
|
+
|
|
175
|
+
return Result.failure(:no_evidence, policy_key: policy.key, statement_key: statement.key) unless state
|
|
176
|
+
|
|
177
|
+
source_failure = check_state_source(policy, statement, state)
|
|
178
|
+
return source_failure if source_failure
|
|
179
|
+
|
|
180
|
+
failure = check_statement(policy, statement, state, subject, at)
|
|
181
|
+
return failure if failure
|
|
182
|
+
|
|
183
|
+
if current_revision_id && state.policy_revision_id != current_revision_id
|
|
184
|
+
return Result.failure(:stale_policy_revision, policy_key: policy.key,
|
|
185
|
+
statement_key: statement.key,
|
|
186
|
+
event_id: state.current_event_id)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
newest = states.values.max_by(&:effective_at)
|
|
191
|
+
Result.success(policy_key: policy.key, event_id: newest&.current_event_id,
|
|
192
|
+
details: { "statements" => states.keys.sort })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def check_statement(policy, statement, state, subject, at)
|
|
196
|
+
unless state.satisfies?(at)
|
|
197
|
+
return Result.failure(state.failure_reason(at),
|
|
198
|
+
policy_key: policy.key,
|
|
199
|
+
statement_key: statement.key,
|
|
200
|
+
event_id: state.current_event_id)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if statement.subject_bound?
|
|
204
|
+
expected = subject_fingerprint_for(statement, subject)
|
|
205
|
+
|
|
206
|
+
if expected.nil?
|
|
207
|
+
return Result.failure(:wrong_subject, policy_key: policy.key,
|
|
208
|
+
statement_key: statement.key,
|
|
209
|
+
event_id: state.current_event_id)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
unless Digest.secure_compare?(expected, state.subject_fingerprint.to_s)
|
|
213
|
+
return Result.failure(:subject_fingerprint_mismatch,
|
|
214
|
+
policy_key: policy.key,
|
|
215
|
+
statement_key: statement.key,
|
|
216
|
+
event_id: state.current_event_id)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if statement.requires_current_version?
|
|
221
|
+
stale = stale_document?(statement, state)
|
|
222
|
+
|
|
223
|
+
if stale
|
|
224
|
+
return Result.failure(:unseen_document_version,
|
|
225
|
+
policy_key: policy.key,
|
|
226
|
+
statement_key: statement.key,
|
|
227
|
+
event_id: state.current_event_id,
|
|
228
|
+
details: { "document" => stale })
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# An authorization's prerequisites must have been made in the same
|
|
233
|
+
# submission, and before it. A fresh acknowledgment paired with a
|
|
234
|
+
# year-old declaration is not the evidence the policy asked for.
|
|
235
|
+
statement.requires.each do |prerequisite_key|
|
|
236
|
+
prerequisite = Event.where(id: state.root_event_id)
|
|
237
|
+
.joins(:statements)
|
|
238
|
+
.where(clickwrap_event_statements: { statement_key: prerequisite_key })
|
|
239
|
+
.exists?
|
|
240
|
+
|
|
241
|
+
next if prerequisite
|
|
242
|
+
|
|
243
|
+
return Result.failure(:predecessor_missing, policy_key: policy.key,
|
|
244
|
+
statement_key: statement.key, event_id: state.current_event_id,
|
|
245
|
+
details: { "requires" => prerequisite_key })
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def check_state_source(policy, statement, state)
|
|
252
|
+
event = Event.includes(:statements, documents: :document_version).find_by(id: state.current_event_id)
|
|
253
|
+
unless event
|
|
254
|
+
return Result.failure(:no_evidence, policy_key: policy.key,
|
|
255
|
+
statement_key: statement.key)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if event.disposed?
|
|
259
|
+
if event.documented_core_disposition?
|
|
260
|
+
return Result.failure(:core_event_disposed, policy_key: policy.key,
|
|
261
|
+
statement_key: statement.key,
|
|
262
|
+
event_id: event.id)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
return Result.failure(:integrity_check_failed, policy_key: policy.key,
|
|
266
|
+
statement_key: statement.key,
|
|
267
|
+
event_id: event.id,
|
|
268
|
+
details: { "disposition" => "not_documented" })
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
unless event.evidence_integrity_verified?
|
|
272
|
+
return Result.failure(:integrity_check_failed, policy_key: policy.key,
|
|
273
|
+
statement_key: statement.key,
|
|
274
|
+
event_id: event.id,
|
|
275
|
+
details: {
|
|
276
|
+
"request_evidence_binding" =>
|
|
277
|
+
event.request_evidence_binding_status.to_s
|
|
278
|
+
})
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
recorded = event.statement(statement.key)
|
|
282
|
+
identity_source = statement_identity_source(event, state)
|
|
283
|
+
identity_matches = identity_source &&
|
|
284
|
+
event.policy_key == policy.key &&
|
|
285
|
+
identity_source.policy_key == policy.key &&
|
|
286
|
+
identity_source.actor_reference == state.actor_reference &&
|
|
287
|
+
identity_source.tenant_key.to_s == state.tenant_key.to_s &&
|
|
288
|
+
identity_source.subject_key.to_s == state.subject_key.to_s &&
|
|
289
|
+
identity_source.represented_party_reference.to_s ==
|
|
290
|
+
state.represented_party_reference.to_s &&
|
|
291
|
+
recorded&.kind == statement.kind &&
|
|
292
|
+
recorded&.action == state.current_action
|
|
293
|
+
|
|
294
|
+
unless identity_matches
|
|
295
|
+
return Result.failure(:integrity_check_failed, policy_key: policy.key,
|
|
296
|
+
statement_key: statement.key,
|
|
297
|
+
event_id: event.id)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# An active grant needs a source that can legitimately grant: a human
|
|
301
|
+
# action this application captured, or a legacy record imported with
|
|
302
|
+
# its provenance. An exemption or a lifecycle event backing an
|
|
303
|
+
# "active" projection is a hand-crafted row, and stays refused.
|
|
304
|
+
active_grant_source = event.human_action? || event.event_type == "imported_legacy"
|
|
305
|
+
if state.state == "active" && !active_grant_source
|
|
306
|
+
return Result.failure(:no_evidence, policy_key: policy.key,
|
|
307
|
+
statement_key: statement.key,
|
|
308
|
+
event_id: event.id)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
mismatched = event.documents.any? do |binding|
|
|
312
|
+
!binding.still_matches_stored_version? ||
|
|
313
|
+
!binding.document_version&.verify_content_digest ||
|
|
314
|
+
!binding.document_version&.verify_rendered_content_digest
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
return unless mismatched
|
|
318
|
+
|
|
319
|
+
Result.failure(:document_digest_mismatch, policy_key: policy.key,
|
|
320
|
+
statement_key: statement.key,
|
|
321
|
+
event_id: event.id)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Lifecycle events record who performed the transition. For an automatic
|
|
325
|
+
# expiry/consumption that is a system actor; for an administrative
|
|
326
|
+
# revocation it may be an operator. Neither replaces the human whose
|
|
327
|
+
# statement is being changed. Its immutable root event is therefore the
|
|
328
|
+
# source of actor/tenant/subject identity, while the current event is the
|
|
329
|
+
# source of the transition action itself.
|
|
330
|
+
def statement_identity_source(event, state)
|
|
331
|
+
return event if CurrentState::INITIAL_EVENT_TYPES.include?(event.event_type)
|
|
332
|
+
return nil unless CurrentState::TRANSITION_STATE_BY_EVENT_TYPE.key?(event.event_type)
|
|
333
|
+
return nil unless event.root_event_id.to_s == state.root_event_id.to_s
|
|
334
|
+
|
|
335
|
+
root = Event.find_by(id: event.root_event_id)
|
|
336
|
+
predecessor = Event.find_by(id: event.predecessor_event_id)
|
|
337
|
+
return nil unless root&.digest_verified? && predecessor&.digest_verified?
|
|
338
|
+
return nil unless (predecessor.root_event_id.presence || predecessor.id).to_s == root.id.to_s
|
|
339
|
+
|
|
340
|
+
root
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def verify_event(event_id, policy:, subject:, acting_for:, at:, require_current_revision: false)
|
|
344
|
+
event = Event.find_by(id: event_id)
|
|
345
|
+
|
|
346
|
+
return Result.failure(:no_evidence, event_id: event_id) unless event
|
|
347
|
+
|
|
348
|
+
if policy && event.policy_key != policy.to_s
|
|
349
|
+
return Result.failure(:presentation_policy_mismatch, policy_key: event.policy_key,
|
|
350
|
+
event_id: event.id)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if event.disposed?
|
|
354
|
+
if event.documented_core_disposition?
|
|
355
|
+
return Result.failure(:core_event_disposed, policy_key: event.policy_key, event_id: event.id)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
return Result.failure(
|
|
359
|
+
:integrity_check_failed,
|
|
360
|
+
policy_key: event.policy_key,
|
|
361
|
+
event_id: event.id,
|
|
362
|
+
details: { "disposition" => "not_documented" }
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
unless event.evidence_integrity_verified?
|
|
367
|
+
return Result.failure(
|
|
368
|
+
:integrity_check_failed,
|
|
369
|
+
policy_key: event.policy_key,
|
|
370
|
+
event_id: event.id,
|
|
371
|
+
details: { "request_evidence_binding" => event.request_evidence_binding_status.to_s }
|
|
372
|
+
)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Two different ways a document can stop matching, and both have to be
|
|
376
|
+
# caught. The first is the version row being swapped or its recorded
|
|
377
|
+
# digest changed. The second is subtler and more likely: the row keeps
|
|
378
|
+
# its digest column while someone edits the bytes underneath it, which
|
|
379
|
+
# would leave every receipt citing it silently describing content that
|
|
380
|
+
# is no longer there. So the stored bytes get re-digested, not trusted.
|
|
381
|
+
mismatched = event.documents.reject do |binding|
|
|
382
|
+
binding.still_matches_stored_version? &&
|
|
383
|
+
binding.document_version&.verify_content_digest &&
|
|
384
|
+
binding.document_version.verify_rendered_content_digest
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
unless mismatched.empty?
|
|
388
|
+
return Result.failure(:document_digest_mismatch, policy_key: event.policy_key,
|
|
389
|
+
event_id: event.id,
|
|
390
|
+
details: { "documents" => mismatched.map(&:document_key) })
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
if subject && event.subject_key.present? &&
|
|
394
|
+
event.subject_key != StatementState.subject_key_for(subject)
|
|
395
|
+
return Result.failure(:wrong_subject, policy_key: event.policy_key, event_id: event.id)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
fingerprint_failure = check_event_subject_fingerprint(event, subject)
|
|
399
|
+
return fingerprint_failure if fingerprint_failure
|
|
400
|
+
|
|
401
|
+
if require_current_revision
|
|
402
|
+
stale = check_event_policy_revision(event)
|
|
403
|
+
return stale if stale
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
if !acting_for.equal?(UNSPECIFIED) &&
|
|
407
|
+
event.represented_party_reference.to_s != Reference.represented_party(acting_for).to_s
|
|
408
|
+
return Result.failure(:represented_party_mismatch,
|
|
409
|
+
policy_key: event.policy_key, event_id: event.id)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
Result.success(
|
|
413
|
+
policy_key: event.policy_key,
|
|
414
|
+
event_id: event.id,
|
|
415
|
+
details: { "request_evidence_binding" => event.request_evidence_binding_status.to_s }
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The same subject binding `verify_policy` applies, applied to one
|
|
420
|
+
# recorded event. Matching `subject_key` only proves the evidence is
|
|
421
|
+
# about the same RECORD; the fingerprint is what proves it is still about
|
|
422
|
+
# the same record in the same state, which is the whole point of a policy
|
|
423
|
+
# that fingerprints its subject.
|
|
424
|
+
#
|
|
425
|
+
# Recomputing needs the compiled policy, so a policy that is no longer
|
|
426
|
+
# declared is reported as `:unknown_policy` rather than passed. "We can
|
|
427
|
+
# no longer check this" is not the same answer as "this is fine", and on
|
|
428
|
+
# this question they must never be spelled the same way.
|
|
429
|
+
def check_event_subject_fingerprint(event, subject)
|
|
430
|
+
return nil if subject.nil? || event.subject_fingerprint.blank?
|
|
431
|
+
|
|
432
|
+
policy = Clickwrap.policies[event.policy_key]
|
|
433
|
+
|
|
434
|
+
return Result.failure(:unknown_policy, policy_key: event.policy_key, event_id: event.id) unless policy
|
|
435
|
+
|
|
436
|
+
expected = SubjectFingerprint.for(policy, subject)
|
|
437
|
+
|
|
438
|
+
return nil if expected && Digest.secure_compare?(expected, event.subject_fingerprint.to_s)
|
|
439
|
+
|
|
440
|
+
Result.failure(:subject_fingerprint_mismatch, policy_key: event.policy_key, event_id: event.id)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# "Was this act made under the wording that is current NOW?", asked of one
|
|
444
|
+
# event. Deliberately compared by digest against the compiled policy
|
|
445
|
+
# rather than through `PolicyRevision.freeze_for`, because verifying is a
|
|
446
|
+
# read: asking whether old evidence is still good must not write a new
|
|
447
|
+
# revision row as a side effect.
|
|
448
|
+
def check_event_policy_revision(event)
|
|
449
|
+
policy = Clickwrap.policies[event.policy_key]
|
|
450
|
+
|
|
451
|
+
return Result.failure(:unknown_policy, policy_key: event.policy_key, event_id: event.id) unless policy
|
|
452
|
+
|
|
453
|
+
recorded = event.policy_revision&.revision_digest
|
|
454
|
+
return nil if recorded.present? && Digest.secure_compare?(recorded, policy.revision.to_s)
|
|
455
|
+
|
|
456
|
+
Result.failure(:stale_policy_revision, policy_key: event.policy_key, event_id: event.id)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def load_states(policy, actor_reference, tenant, subject, acting_for)
|
|
460
|
+
StatementState
|
|
461
|
+
.for_policy(policy.key)
|
|
462
|
+
.for_actor(actor_reference)
|
|
463
|
+
.where(
|
|
464
|
+
tenant_key: tenant_key_for(tenant),
|
|
465
|
+
subject_key: StatementState.subject_key_for(subject),
|
|
466
|
+
represented_party_reference: Reference.represented_party(acting_for)
|
|
467
|
+
)
|
|
468
|
+
.index_by(&:statement_key)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def reference_for(actor)
|
|
472
|
+
Reference.actor(actor)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def tenant_key_for(tenant)
|
|
476
|
+
Reference.tenant(tenant)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def subject_fingerprint_for(statement, subject)
|
|
480
|
+
return nil if subject.nil? || statement.subject_fingerprint_with.nil?
|
|
481
|
+
|
|
482
|
+
value = statement.subject_fingerprint_with.call(subject)
|
|
483
|
+
value.nil? ? nil : Digest.digest(value.to_s)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Returns the document key whose current published version is newer than
|
|
487
|
+
# the one this evidence was captured against, or nil when everything the
|
|
488
|
+
# statement requires is still current.
|
|
489
|
+
def stale_document?(statement, state)
|
|
490
|
+
recorded = Array(state.document_version_ids).map(&:to_s)
|
|
491
|
+
|
|
492
|
+
statement.document_keys.find do |document_key|
|
|
493
|
+
document = ::Clickwrap::Document.find_by(
|
|
494
|
+
document_key: document_key,
|
|
495
|
+
tenant_key: state.tenant_key.presence
|
|
496
|
+
)
|
|
497
|
+
current = document&.current_version(locale: I18n.locale)
|
|
498
|
+
|
|
499
|
+
current && !recorded.include?(current.id.to_s)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
data/lib/clickwrap/version.rb
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Clickwrap
|
|
4
|
-
VERSION = "0.
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# The canonical schema version for receipts, event digests, and presentation
|
|
7
|
+
# manifests. This is deliberately independent of VERSION: gem releases may
|
|
8
|
+
# come and go without changing how historical evidence is serialized, and a
|
|
9
|
+
# change here always means a new explicit schema plus a verifier that still
|
|
10
|
+
# reads every previously released version.
|
|
11
|
+
CANONICAL_SCHEMA_VERSION = "clickwrap.receipt.v1"
|
|
12
|
+
|
|
13
|
+
# Bumped only when the receiver-side verification logic changes in a way an
|
|
14
|
+
# auditor should be able to see in a receipt.
|
|
15
|
+
VERIFIER_VERSION = "1"
|
|
5
16
|
end
|