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,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Integrity
|
|
5
|
+
# Finds committed events for which a configured external integrity adapter
|
|
6
|
+
# never left an attestation row, then asks only for those missing records.
|
|
7
|
+
# This closes the recoverable part of the after-commit crash window: the
|
|
8
|
+
# required evidence remains committed, and an operator can safely discover
|
|
9
|
+
# and retry the optional external work later.
|
|
10
|
+
#
|
|
11
|
+
# No local database can prove whether a process died immediately before or
|
|
12
|
+
# immediately after a provider accepted a request. Adapters should therefore
|
|
13
|
+
# use the event digest (timestamps) or exact chain snapshot (anchors) as their
|
|
14
|
+
# idempotency key. A retry may otherwise create a second valid provider
|
|
15
|
+
# record; Clickwrap preserves both and never calls that exactly-once delivery.
|
|
16
|
+
class AttestationReconciler
|
|
17
|
+
Outcome = Data.define(:event_id, :kind, :state, :attestation_id) do
|
|
18
|
+
def recorded? = attestation_id.present?
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
"event_id" => event_id,
|
|
23
|
+
"kind" => kind,
|
|
24
|
+
"state" => state,
|
|
25
|
+
"attestation_id" => attestation_id
|
|
26
|
+
}.compact
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Result = Data.define(:outcomes) do
|
|
31
|
+
def attempted = outcomes.length
|
|
32
|
+
def recorded = outcomes.count(&:recorded?)
|
|
33
|
+
def not_recorded = attempted - recorded
|
|
34
|
+
|
|
35
|
+
def counts
|
|
36
|
+
{ "attempted" => attempted, "recorded" => recorded, "not_recorded" => not_recorded }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clean? = not_recorded.zero?
|
|
40
|
+
def to_h = { "counts" => counts, "outcomes" => outcomes.map(&:to_h) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def missing_counts(scope: Event.all)
|
|
45
|
+
configured_kinds.to_h do |kind|
|
|
46
|
+
eligible = eligible_scope(scope, kind)
|
|
47
|
+
recorded = IntegrityAttestation.where(kind: kind).select(:event_id)
|
|
48
|
+
[kind, eligible.where.not(id: recorded).count]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def configured_kinds
|
|
53
|
+
kinds = []
|
|
54
|
+
kinds << "third_party_timestamp" if Clickwrap.config.timestamp_receipts_with
|
|
55
|
+
kinds << "event_anchor" if Clickwrap.config.anchor_event_history_with
|
|
56
|
+
kinds
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def eligible_scope(scope, kind)
|
|
60
|
+
kind == "event_anchor" ? scope.where.not(chain_scope: nil) : scope
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(scope: Event.all, retry_failed_attestations: false)
|
|
65
|
+
@scope = scope
|
|
66
|
+
@retry_failed_attestations = retry_failed_attestations
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
attr_reader :scope, :retry_failed_attestations
|
|
70
|
+
|
|
71
|
+
def call
|
|
72
|
+
outcomes = []
|
|
73
|
+
|
|
74
|
+
each_event do |event|
|
|
75
|
+
configured_kinds_for(event).each do |kind|
|
|
76
|
+
next unless attempt_needed?(event, kind)
|
|
77
|
+
|
|
78
|
+
attestation = attest(event, kind)
|
|
79
|
+
outcomes << Outcome.new(
|
|
80
|
+
event_id: event.id,
|
|
81
|
+
kind: kind,
|
|
82
|
+
state: attestation&.state || "not_recorded",
|
|
83
|
+
attestation_id: attestation&.id
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Result.new(outcomes: outcomes.freeze)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def each_event(&)
|
|
94
|
+
scope.respond_to?(:find_each) ? scope.find_each(&) : scope.each(&)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def configured_kinds_for(event)
|
|
98
|
+
self.class.configured_kinds.reject do |kind|
|
|
99
|
+
kind == "event_anchor" && event.chain_scope.blank?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def attempt_needed?(event, kind)
|
|
104
|
+
latest = IntegrityAttestation.where(event_id: event.id, kind: kind).order(:attempted_at, :id).last
|
|
105
|
+
latest.nil? || (retry_failed_attestations && latest.state == "failed")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def attest(event, kind)
|
|
109
|
+
attestor = Attestor.new(event)
|
|
110
|
+
kind == "event_anchor" ? attestor.anchor_event : attestor.timestamp_event
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Integrity
|
|
5
|
+
# Invokes optional external adapters only after the required event commit.
|
|
6
|
+
# An outage can never roll back the protected action; every ordinary result
|
|
7
|
+
# is persisted, and an exception is reported through the host's existing
|
|
8
|
+
# after-commit failure hook.
|
|
9
|
+
class Attestor
|
|
10
|
+
ChainSnapshot = Data.define(:chain_scope, :sequence, :last_event_id, :last_event_digest) do
|
|
11
|
+
def checkpoint_digest = last_event_digest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.attest_after_commit(event) = new(event).attest_after_commit
|
|
15
|
+
|
|
16
|
+
def initialize(event)
|
|
17
|
+
@event = event
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :event
|
|
21
|
+
|
|
22
|
+
def attest_after_commit
|
|
23
|
+
timestamp_event if Clickwrap.config.timestamp_receipts_with
|
|
24
|
+
anchor_event if Clickwrap.config.anchor_event_history_with && event.chain_scope.present?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def timestamp_event
|
|
28
|
+
adapter = Clickwrap.config.timestamp_receipts_with
|
|
29
|
+
attempted_at = Clickwrap.now
|
|
30
|
+
token = adapter.timestamp(event.event_digest)
|
|
31
|
+
token_body = normalized_result(token)
|
|
32
|
+
token_digest = token_body["digest"]
|
|
33
|
+
|
|
34
|
+
unless token_digest.to_s == event.event_digest.to_s
|
|
35
|
+
return record!(
|
|
36
|
+
kind: "third_party_timestamp", state: "failed", adapter: adapter,
|
|
37
|
+
attempted_at: attempted_at, provider_result: token_body,
|
|
38
|
+
verification: { "checked" => false, "verified" => false,
|
|
39
|
+
"detail" => "The provider result named a different digest." }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
verification = if token_body["issued"] == true
|
|
44
|
+
normalized_result(adapter.verify(token_body["token"], event.event_digest))
|
|
45
|
+
else
|
|
46
|
+
{ "checked" => false, "verified" => false,
|
|
47
|
+
"detail" => token_body["detail"] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
record!(
|
|
51
|
+
kind: "third_party_timestamp",
|
|
52
|
+
state: attestation_state(token_body["issued"], verification),
|
|
53
|
+
adapter: adapter,
|
|
54
|
+
attempted_at: attempted_at,
|
|
55
|
+
provider_result: token_body,
|
|
56
|
+
verification: verification,
|
|
57
|
+
provider_reference: token_body["token_reference"],
|
|
58
|
+
provider_reported_at: parse_time(token_body["provider_reported_time"])
|
|
59
|
+
)
|
|
60
|
+
rescue StandardError => error
|
|
61
|
+
record_exception(kind: "third_party_timestamp", adapter: adapter,
|
|
62
|
+
attempted_at: attempted_at, error: error)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def anchor_event
|
|
66
|
+
adapter = Clickwrap.config.anchor_event_history_with
|
|
67
|
+
attempted_at = Clickwrap.now
|
|
68
|
+
snapshot = ChainSnapshot.new(
|
|
69
|
+
chain_scope: event.chain_scope,
|
|
70
|
+
sequence: event.chain_sequence,
|
|
71
|
+
last_event_id: event.id,
|
|
72
|
+
last_event_digest: event.event_digest
|
|
73
|
+
)
|
|
74
|
+
publication = normalized_result(adapter.anchor(snapshot))
|
|
75
|
+
verification = if publication["anchored"] == true
|
|
76
|
+
# Verification receives the exact publication result
|
|
77
|
+
# as well as the expected chain head. An adapter that
|
|
78
|
+
# merely re-reads "some current head" cannot prove the
|
|
79
|
+
# reference stored beside this attestation is the one
|
|
80
|
+
# it actually checked.
|
|
81
|
+
normalized_result(adapter.verify(publication, snapshot))
|
|
82
|
+
else
|
|
83
|
+
{ "checked" => false, "verified" => false,
|
|
84
|
+
"detail" => publication["detail"] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
record!(
|
|
88
|
+
kind: "event_anchor",
|
|
89
|
+
state: attestation_state(publication["anchored"], verification),
|
|
90
|
+
adapter: adapter,
|
|
91
|
+
attempted_at: attempted_at,
|
|
92
|
+
provider_result: publication,
|
|
93
|
+
verification: verification,
|
|
94
|
+
provider_reference: publication["reference"],
|
|
95
|
+
provider_reported_at: parse_time(publication["published_at"]),
|
|
96
|
+
chain_scope: event.chain_scope,
|
|
97
|
+
chain_sequence: event.chain_sequence
|
|
98
|
+
)
|
|
99
|
+
rescue StandardError => error
|
|
100
|
+
record_exception(kind: "event_anchor", adapter: adapter,
|
|
101
|
+
attempted_at: attempted_at, error: error,
|
|
102
|
+
chain_scope: event.chain_scope, chain_sequence: event.chain_sequence)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def record!(kind:, state:, adapter:, attempted_at:, provider_result:, verification:,
|
|
108
|
+
provider_reference: nil, provider_reported_at: nil, chain_scope: nil, chain_sequence: nil)
|
|
109
|
+
IntegrityAttestation.create!(
|
|
110
|
+
event_id: event.id,
|
|
111
|
+
kind: kind,
|
|
112
|
+
state: state,
|
|
113
|
+
provider_name: provider_name(adapter, provider_result),
|
|
114
|
+
subject_digest: event.event_digest,
|
|
115
|
+
chain_scope: chain_scope,
|
|
116
|
+
chain_sequence: chain_sequence,
|
|
117
|
+
provider_reference: provider_reference,
|
|
118
|
+
provider_result: provider_result,
|
|
119
|
+
verification: verification,
|
|
120
|
+
adapter_capabilities: safe_capabilities(adapter),
|
|
121
|
+
attempted_at: attempted_at,
|
|
122
|
+
provider_reported_at: provider_reported_at,
|
|
123
|
+
created_at: Clickwrap.now
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def attestation_state(issued, verification)
|
|
128
|
+
return "unavailable" unless issued == true
|
|
129
|
+
return "verified" if verification["checked"] == true && verification["verified"] == true
|
|
130
|
+
|
|
131
|
+
"issued_unverified"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def provider_name(adapter, body)
|
|
135
|
+
body["provider_name"].presence ||
|
|
136
|
+
(adapter.respond_to?(:provider_name) ? adapter.provider_name.to_s : adapter.class.name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def normalized_result(value)
|
|
140
|
+
body = value.respond_to?(:to_h) ? value.to_h : value
|
|
141
|
+
unless body.respond_to?(:to_h)
|
|
142
|
+
raise ConfigurationError,
|
|
143
|
+
"An integrity adapter must return a Hash-like result, got #{value.class}."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
body.to_h.each_with_object({}) do |(key, nested), result|
|
|
147
|
+
result[key.to_s] = normalize_value(nested)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalize_value(value)
|
|
152
|
+
case value
|
|
153
|
+
when Hash then value.to_h { |key, nested| [key.to_s, normalize_value(nested)] }
|
|
154
|
+
when Array then value.map { |nested| normalize_value(nested) }
|
|
155
|
+
when Time, ActiveSupport::TimeWithZone then Receipt.format_time(value)
|
|
156
|
+
else value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse_time(value)
|
|
161
|
+
return value if value.is_a?(Time)
|
|
162
|
+
return nil if value.blank?
|
|
163
|
+
|
|
164
|
+
Time.parse(value.to_s).utc
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def safe_capabilities(adapter)
|
|
170
|
+
normalized_result(adapter.capabilities)
|
|
171
|
+
rescue StandardError => error
|
|
172
|
+
{
|
|
173
|
+
"unavailable" => true,
|
|
174
|
+
"error_class" => error.class.name,
|
|
175
|
+
"detail" => "The adapter's capabilities could not be read."
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def record_exception(kind:, adapter:, attempted_at:, error:, chain_scope: nil, chain_sequence: nil)
|
|
180
|
+
adapter_error = error
|
|
181
|
+
IntegrityAttestation.create!(
|
|
182
|
+
event_id: event.id,
|
|
183
|
+
kind: kind,
|
|
184
|
+
state: "failed",
|
|
185
|
+
provider_name: safe_provider_name(adapter),
|
|
186
|
+
subject_digest: event.event_digest,
|
|
187
|
+
chain_scope: chain_scope,
|
|
188
|
+
chain_sequence: chain_sequence,
|
|
189
|
+
provider_result: {
|
|
190
|
+
"error_class" => adapter_error.class.name,
|
|
191
|
+
"detail" => adapter_error.message.to_s.slice(0, 1_000)
|
|
192
|
+
},
|
|
193
|
+
verification: {
|
|
194
|
+
"checked" => false,
|
|
195
|
+
"verified" => false,
|
|
196
|
+
"detail" => "The adapter raised before this attestation could be verified."
|
|
197
|
+
},
|
|
198
|
+
adapter_capabilities: safe_capabilities(adapter),
|
|
199
|
+
attempted_at: attempted_at,
|
|
200
|
+
created_at: Clickwrap.now
|
|
201
|
+
)
|
|
202
|
+
rescue StandardError => error
|
|
203
|
+
report_failure(error)
|
|
204
|
+
ensure
|
|
205
|
+
report_failure(adapter_error)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def safe_provider_name(adapter)
|
|
209
|
+
value = adapter.provider_name if adapter.respond_to?(:provider_name)
|
|
210
|
+
value.to_s.presence || adapter.class.name.presence || "unknown_integrity_adapter"
|
|
211
|
+
rescue StandardError
|
|
212
|
+
adapter.class.name.presence || "unknown_integrity_adapter"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def report_failure(error)
|
|
216
|
+
Clickwrap.report_after_commit_failure(error, event)
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Integrity
|
|
5
|
+
# Walks the optional event chains and reports what still links up.
|
|
6
|
+
#
|
|
7
|
+
# result = Clickwrap::Integrity::Chain.verify
|
|
8
|
+
# result.success? # => true
|
|
9
|
+
# result.counts # => {"checked" => 41_882, "verified" => 41_882, "breaks" => 0}
|
|
10
|
+
# result.first_break # => nil
|
|
11
|
+
#
|
|
12
|
+
# ============================================================================
|
|
13
|
+
# WHAT A CHAIN DETECTS, EXACTLY. Each event carries the digest of the one
|
|
14
|
+
# before it, so an event that is later rewritten or removed stops linking up
|
|
15
|
+
# with its successors, and this walk finds the first place that happens. That
|
|
16
|
+
# is a real and useful property: ordinary corruption, a well-meant
|
|
17
|
+
# `update_column`, a restored partial backup, and a row edited by hand all
|
|
18
|
+
# show up here.
|
|
19
|
+
#
|
|
20
|
+
# What it does NOT do is stop, or detect, a privileged actor who rewrites an
|
|
21
|
+
# event AND every digest that follows it. Whoever can write the events table
|
|
22
|
+
# can usually write this table too, and a chain whose head lives in the same
|
|
23
|
+
# database as the chain cannot say otherwise. That is precisely the gap the
|
|
24
|
+
# optional independent anchor adapter addresses, and even then the claim is
|
|
25
|
+
# only ever as strong as the anchor. The chain makes rewriting history
|
|
26
|
+
# detectable for as long as the head remains trustworthy — no more than that,
|
|
27
|
+
# and this class never says more than that.
|
|
28
|
+
# ============================================================================
|
|
29
|
+
class Chain
|
|
30
|
+
BATCH_SIZE = 1_000
|
|
31
|
+
|
|
32
|
+
# Every way a walk can stop lining up, as a stable symbol so a monitor can
|
|
33
|
+
# branch on it without matching English.
|
|
34
|
+
REASONS = %i[
|
|
35
|
+
digest_does_not_match
|
|
36
|
+
previous_digest_does_not_link
|
|
37
|
+
sequence_gap
|
|
38
|
+
earlier_events_missing
|
|
39
|
+
chain_head_missing
|
|
40
|
+
chain_tail_missing
|
|
41
|
+
chain_head_event_mismatch
|
|
42
|
+
chain_head_digest_mismatch
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
Break = Data.define(:chain_scope, :chain_sequence, :event_id, :reason, :detail) do
|
|
46
|
+
def to_h
|
|
47
|
+
{
|
|
48
|
+
"chain_scope" => chain_scope,
|
|
49
|
+
"chain_sequence" => chain_sequence,
|
|
50
|
+
"event_id" => event_id,
|
|
51
|
+
"reason" => reason.to_s,
|
|
52
|
+
"detail" => detail
|
|
53
|
+
}.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_s = "#{chain_scope}##{chain_sequence} #{event_id}: #{detail}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Result = Data.define(
|
|
60
|
+
:chaining_enabled,
|
|
61
|
+
:checked,
|
|
62
|
+
:verified,
|
|
63
|
+
:documented_dispositions,
|
|
64
|
+
:scopes,
|
|
65
|
+
:breaks,
|
|
66
|
+
:started_mid_chain
|
|
67
|
+
) do
|
|
68
|
+
def success? = breaks.empty?
|
|
69
|
+
def first_break = breaks.first
|
|
70
|
+
|
|
71
|
+
def counts
|
|
72
|
+
{
|
|
73
|
+
"checked" => checked,
|
|
74
|
+
"verified" => verified,
|
|
75
|
+
"documented_dispositions" => documented_dispositions,
|
|
76
|
+
"breaks" => breaks.length,
|
|
77
|
+
"scopes" => scopes.length
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_h
|
|
82
|
+
{
|
|
83
|
+
"chaining_enabled" => chaining_enabled,
|
|
84
|
+
"counts" => counts,
|
|
85
|
+
"scopes" => scopes,
|
|
86
|
+
"started_mid_chain" => started_mid_chain,
|
|
87
|
+
"first_break" => first_break&.to_h,
|
|
88
|
+
"breaks" => breaks.map(&:to_h),
|
|
89
|
+
"detects" => "An event rewritten or removed after it was written, for as long as the " \
|
|
90
|
+
"chain head remains trustworthy. Not a rewrite of the events and their " \
|
|
91
|
+
"digests together by a privileged actor."
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `from:` and `to:` accept either a chain sequence number or a time. A
|
|
97
|
+
# sequence is the natural way to re-check one span of a chain; a time is
|
|
98
|
+
# the natural way to run "everything since last night" from cron, and
|
|
99
|
+
# refusing one of them would just make an operator convert by hand.
|
|
100
|
+
def self.verify(scope: nil, from: nil, to: nil) = new(scope: scope, from: from, to: to).verify
|
|
101
|
+
|
|
102
|
+
def initialize(scope: nil, from: nil, to: nil)
|
|
103
|
+
@scope = scope&.to_s
|
|
104
|
+
@from = from
|
|
105
|
+
@to = to
|
|
106
|
+
@breaks = []
|
|
107
|
+
@checked = 0
|
|
108
|
+
@verified = 0
|
|
109
|
+
@documented_dispositions = 0
|
|
110
|
+
@started_mid_chain = []
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
attr_reader :scope, :from, :to
|
|
114
|
+
|
|
115
|
+
def verify
|
|
116
|
+
scopes = chain_scopes
|
|
117
|
+
scopes.each { |chain_scope| walk(chain_scope) }
|
|
118
|
+
|
|
119
|
+
Result.new(
|
|
120
|
+
chaining_enabled: !Clickwrap.config.chain_event_history_with.nil?,
|
|
121
|
+
checked: @checked,
|
|
122
|
+
verified: @verified,
|
|
123
|
+
documented_dispositions: @documented_dispositions,
|
|
124
|
+
scopes: scopes,
|
|
125
|
+
breaks: @breaks,
|
|
126
|
+
started_mid_chain: @started_mid_chain
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def chain_scopes
|
|
133
|
+
return [scope] if scope
|
|
134
|
+
|
|
135
|
+
event_scopes = Event.where.not(chain_scope: nil).distinct.pluck(:chain_scope)
|
|
136
|
+
head_scopes = ChainHead.distinct.pluck(:chain_scope)
|
|
137
|
+
(event_scopes + head_scopes).compact.uniq.sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def walk(chain_scope)
|
|
141
|
+
previous = nil
|
|
142
|
+
|
|
143
|
+
each_event(chain_scope) do |event|
|
|
144
|
+
@checked += 1
|
|
145
|
+
|
|
146
|
+
if previous.nil?
|
|
147
|
+
check_first(chain_scope, event)
|
|
148
|
+
else
|
|
149
|
+
check_link(chain_scope, event, previous)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
check_digest(chain_scope, event)
|
|
153
|
+
previous = event
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
check_durable_head(chain_scope, previous) if full_chain_walk?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def check_durable_head(chain_scope, last_event)
|
|
160
|
+
head = ChainHead.find_by(chain_scope: chain_scope)
|
|
161
|
+
|
|
162
|
+
unless head
|
|
163
|
+
add_head_break(
|
|
164
|
+
chain_scope,
|
|
165
|
+
last_event,
|
|
166
|
+
:chain_head_missing,
|
|
167
|
+
"Events name this chain scope, but its durable chain-head row is missing. The walk " \
|
|
168
|
+
"cannot establish whether the newest recorded events are still present."
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if last_event.nil?
|
|
174
|
+
return if head.chain_sequence.to_i.zero? && head.last_event_id.blank? && head.last_event_digest.blank?
|
|
175
|
+
|
|
176
|
+
add_head_break(
|
|
177
|
+
chain_scope,
|
|
178
|
+
nil,
|
|
179
|
+
:chain_tail_missing,
|
|
180
|
+
"The durable head records sequence #{head.chain_sequence} and event #{head.last_event_id}, " \
|
|
181
|
+
"but this scope has no event rows. Its recorded tail is missing."
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if last_event.chain_sequence != head.chain_sequence
|
|
187
|
+
add_head_break(
|
|
188
|
+
chain_scope,
|
|
189
|
+
last_event,
|
|
190
|
+
:chain_tail_missing,
|
|
191
|
+
"The newest event row is sequence #{last_event.chain_sequence}, while the durable " \
|
|
192
|
+
"head records sequence #{head.chain_sequence}. One or more newest events are missing."
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if last_event.id.to_s != head.last_event_id.to_s
|
|
197
|
+
add_head_break(
|
|
198
|
+
chain_scope,
|
|
199
|
+
last_event,
|
|
200
|
+
:chain_head_event_mismatch,
|
|
201
|
+
"The newest event row is #{last_event.id}, while the durable head names " \
|
|
202
|
+
"#{head.last_event_id}. The head and chain tail do not describe the same event."
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
return if Digest.secure_compare?(last_event.event_digest.to_s, head.last_event_digest.to_s)
|
|
207
|
+
|
|
208
|
+
add_head_break(
|
|
209
|
+
chain_scope,
|
|
210
|
+
last_event,
|
|
211
|
+
:chain_head_digest_mismatch,
|
|
212
|
+
"The newest event digest does not match the digest retained by the durable chain head."
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Keyset pagination on the (chain_scope, chain_sequence) index. `find_each`
|
|
217
|
+
# would order by primary key, and the primary key here is a ULID: close to
|
|
218
|
+
# chain order, but "close to" is not the property a chain walk can rely on.
|
|
219
|
+
def each_event(chain_scope, &block)
|
|
220
|
+
cursor = nil
|
|
221
|
+
|
|
222
|
+
loop do
|
|
223
|
+
relation = bounded(Event.where(chain_scope: chain_scope)).order(:chain_sequence).limit(BATCH_SIZE)
|
|
224
|
+
relation = relation.where(chain_sequence: (cursor + 1)..) if cursor
|
|
225
|
+
batch = relation.to_a
|
|
226
|
+
break if batch.empty?
|
|
227
|
+
|
|
228
|
+
batch.each(&block)
|
|
229
|
+
cursor = batch.last.chain_sequence
|
|
230
|
+
break if cursor.nil?
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def bounded(relation)
|
|
235
|
+
relation = apply_bound(relation, from, :from)
|
|
236
|
+
apply_bound(relation, to, :to)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def apply_bound(relation, value, side)
|
|
240
|
+
return relation if value.nil?
|
|
241
|
+
|
|
242
|
+
column = value.is_a?(Integer) ? :chain_sequence : :recorded_at_by_server
|
|
243
|
+
range = side == :from ? (value..) : (..value)
|
|
244
|
+
|
|
245
|
+
relation.where(column => range)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The first event of a walk is the one link that cannot be checked against
|
|
249
|
+
# a predecessor this walk has seen. Sequence 1 with no previous digest is a
|
|
250
|
+
# genuine chain start; anything else is either a deliberately bounded run
|
|
251
|
+
# (which is reported, not hidden) or a chain missing its beginning.
|
|
252
|
+
def check_first(chain_scope, event)
|
|
253
|
+
return if event.chain_sequence == 1 && event.previous_event_digest.blank?
|
|
254
|
+
|
|
255
|
+
if bounded_run?
|
|
256
|
+
@started_mid_chain << { "chain_scope" => chain_scope, "chain_sequence" => event.chain_sequence }
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
add_break(chain_scope, event, :earlier_events_missing,
|
|
261
|
+
"The walk starts at sequence #{event.chain_sequence}, so the events before it are " \
|
|
262
|
+
"no longer in the table and nothing can check the link into this one.")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def check_link(chain_scope, event, previous)
|
|
266
|
+
unless event.chain_sequence == previous.chain_sequence + 1
|
|
267
|
+
add_break(chain_scope, event, :sequence_gap,
|
|
268
|
+
"Sequence jumps from #{previous.chain_sequence} to #{event.chain_sequence}, so " \
|
|
269
|
+
"at least one event between them is gone.")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
return if Digest.secure_compare?(event.previous_event_digest.to_s, previous.event_digest.to_s)
|
|
273
|
+
|
|
274
|
+
add_break(chain_scope, event, :previous_digest_does_not_link,
|
|
275
|
+
"This event records a different predecessor digest than event #{previous.id} " \
|
|
276
|
+
"actually has, so the two no longer form a chain.")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def check_digest(chain_scope, event)
|
|
280
|
+
case event.digest_integrity_status
|
|
281
|
+
when :verified
|
|
282
|
+
@verified += 1
|
|
283
|
+
when :documented_core_disposition
|
|
284
|
+
@documented_dispositions += 1
|
|
285
|
+
else
|
|
286
|
+
add_break(chain_scope, event, :digest_does_not_match,
|
|
287
|
+
"Recomputing the canonical body of this event produces a different digest than " \
|
|
288
|
+
"the one stored with it, and no valid disposition event accounts for the missing " \
|
|
289
|
+
"payload. Its meaningful bytes changed after it was written or its disposition " \
|
|
290
|
+
"marker is unexplained. That does not, on its own, say who changed it or when.")
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def add_break(chain_scope, event, reason, detail)
|
|
295
|
+
@breaks << Break.new(chain_scope: chain_scope, chain_sequence: event.chain_sequence,
|
|
296
|
+
event_id: event.id, reason: reason, detail: detail)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def add_head_break(chain_scope, event, reason, detail)
|
|
300
|
+
@breaks << Break.new(
|
|
301
|
+
chain_scope: chain_scope,
|
|
302
|
+
chain_sequence: event&.chain_sequence,
|
|
303
|
+
event_id: event&.id,
|
|
304
|
+
reason: reason,
|
|
305
|
+
detail: detail
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def bounded_run? = !from.nil?
|
|
310
|
+
def full_chain_walk? = from.nil? && to.nil?
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|