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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The exact document version bound to one statement in one event, with the
|
|
5
|
+
# digests as they stood at capture.
|
|
6
|
+
#
|
|
7
|
+
# The digests are copied here rather than only referenced, so that verification
|
|
8
|
+
# can detect a document version row that was edited in place instead of
|
|
9
|
+
# trusting the current value of a column it is supposed to be checking.
|
|
10
|
+
class EventDocument < ApplicationRecord
|
|
11
|
+
self.table_name = "clickwrap_event_documents"
|
|
12
|
+
self.record_timestamps = false
|
|
13
|
+
|
|
14
|
+
belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :documents
|
|
15
|
+
belongs_to :document_version,
|
|
16
|
+
class_name: "Clickwrap::DocumentVersion",
|
|
17
|
+
optional: true,
|
|
18
|
+
inverse_of: :event_documents
|
|
19
|
+
|
|
20
|
+
validates :statement_key, :document_key, :version_label, :locale, :source_content_digest,
|
|
21
|
+
presence: true
|
|
22
|
+
|
|
23
|
+
before_update :refuse_change
|
|
24
|
+
before_destroy :refuse_destroy
|
|
25
|
+
|
|
26
|
+
# True when the stored document version still carries the digest this event
|
|
27
|
+
# recorded. A false says the document row changed after capture — which is
|
|
28
|
+
# a finding, not a crash, so verification reports it with a stable error
|
|
29
|
+
# rather than raising into an export.
|
|
30
|
+
def still_matches_stored_version?
|
|
31
|
+
return false if document_version.nil?
|
|
32
|
+
|
|
33
|
+
Digest.secure_compare?(document_version.content_digest, source_content_digest) &&
|
|
34
|
+
Digest.secure_compare?(
|
|
35
|
+
document_version.rendered_content_digest.presence || document_version.content_digest,
|
|
36
|
+
rendered_content_digest
|
|
37
|
+
) && document_version.media_type == source_media_type &&
|
|
38
|
+
(document_version.rendered_media_type.presence || document_version.media_type) == rendered_media_type &&
|
|
39
|
+
document_version.renderer_name.to_s == renderer_name.to_s &&
|
|
40
|
+
document_version.renderer_version.to_s == renderer_version.to_s &&
|
|
41
|
+
document_version.sanitizer_name.to_s == sanitizer_name.to_s &&
|
|
42
|
+
document_version.sanitizer_version.to_s == sanitizer_version.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def canonical_fragment
|
|
46
|
+
{
|
|
47
|
+
"statement" => statement_key,
|
|
48
|
+
"key" => document_key,
|
|
49
|
+
"version" => version_label,
|
|
50
|
+
"locale" => locale,
|
|
51
|
+
"source_media_type" => source_media_type,
|
|
52
|
+
"source_digest" => source_content_digest,
|
|
53
|
+
"rendered_media_type" => rendered_media_type,
|
|
54
|
+
"rendered_digest" => rendered_content_digest,
|
|
55
|
+
"renderer" => {
|
|
56
|
+
"name" => renderer_name,
|
|
57
|
+
"version" => renderer_version,
|
|
58
|
+
"sanitizer_name" => sanitizer_name,
|
|
59
|
+
"sanitizer_version" => sanitizer_version
|
|
60
|
+
}.compact.presence,
|
|
61
|
+
"ordinal" => ordinal
|
|
62
|
+
}.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_s = "#{document_key} #{version_label} (#{locale})"
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def refuse_change
|
|
70
|
+
raise EventWriteFailed,
|
|
71
|
+
"Document binding #{self} on event #{event_id} is an immutable snapshot."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def refuse_destroy
|
|
75
|
+
raise EventWriteFailed,
|
|
76
|
+
"Document binding #{self} on event #{event_id} cannot be destroyed."
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# One act inside one event: the assertion in the server-generated offer and
|
|
5
|
+
# what the submitted answer said about it.
|
|
6
|
+
#
|
|
7
|
+
# `assertion_text` is the resolved sentence, not an I18n key. Storing the key
|
|
8
|
+
# would be a false economy — its meaning can change in a later deploy, and
|
|
9
|
+
# then the receipt no longer says what assertion the application recorded.
|
|
10
|
+
class EventStatement < ApplicationRecord
|
|
11
|
+
self.table_name = "clickwrap_event_statements"
|
|
12
|
+
self.record_timestamps = false
|
|
13
|
+
|
|
14
|
+
belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :statements
|
|
15
|
+
|
|
16
|
+
has_many :event_documents,
|
|
17
|
+
->(statement) { where(statement_key: statement.statement_key) },
|
|
18
|
+
class_name: "Clickwrap::EventDocument",
|
|
19
|
+
foreign_key: :event_id,
|
|
20
|
+
primary_key: :event_id,
|
|
21
|
+
inverse_of: false,
|
|
22
|
+
dependent: nil
|
|
23
|
+
|
|
24
|
+
validates :statement_key, :assertion_text, :assertion_locale, presence: true
|
|
25
|
+
validates :kind, inclusion: { in: Vocabulary::KINDS }
|
|
26
|
+
validate :action_belongs_to_kind
|
|
27
|
+
|
|
28
|
+
before_update :refuse_change
|
|
29
|
+
before_destroy :refuse_destroy
|
|
30
|
+
|
|
31
|
+
scope :of_kind, ->(kind) { where(kind: kind.to_s) }
|
|
32
|
+
scope :answered, -> { where(answered: true) }
|
|
33
|
+
|
|
34
|
+
def expired?(at = Clickwrap.now) = expires_at.present? && expires_at <= at
|
|
35
|
+
|
|
36
|
+
# An optional control left unselected creates no grant. The receipt can show
|
|
37
|
+
# that the option was offered and not taken, but it does not call silence an
|
|
38
|
+
# affirmative refusal — those are different facts and only one of them
|
|
39
|
+
# actually happened.
|
|
40
|
+
def offered_and_not_taken? = optional? && !answered?
|
|
41
|
+
|
|
42
|
+
def canonical_fragment
|
|
43
|
+
{
|
|
44
|
+
"statement" => statement_key,
|
|
45
|
+
"kind" => kind,
|
|
46
|
+
"action" => action,
|
|
47
|
+
"assertion" => assertion_text,
|
|
48
|
+
"locale" => assertion_locale,
|
|
49
|
+
"label" => label_text,
|
|
50
|
+
"link_labels" => link_labels.presence,
|
|
51
|
+
"choices" => choices.presence,
|
|
52
|
+
"required" => required?,
|
|
53
|
+
"optional" => optional?,
|
|
54
|
+
"answered" => answered?,
|
|
55
|
+
"answer" => answer.presence,
|
|
56
|
+
"purpose" => purpose_key,
|
|
57
|
+
"withdrawal_path" => withdrawal_path,
|
|
58
|
+
"valid_from" => Receipt.format_time(valid_from),
|
|
59
|
+
"expires_at" => Receipt.format_time(expires_at),
|
|
60
|
+
"one_time" => one_time? || nil,
|
|
61
|
+
"requires" => Array(requires).presence,
|
|
62
|
+
"subject_fingerprint" => subject_fingerprint
|
|
63
|
+
}.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_s = "#{kind} #{statement_key} (#{action})"
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def action_belongs_to_kind
|
|
71
|
+
return if kind.blank? || action.blank?
|
|
72
|
+
return if Vocabulary::ACTIONS_FOR_KIND.fetch(kind, []).include?(action)
|
|
73
|
+
|
|
74
|
+
errors.add(
|
|
75
|
+
:action,
|
|
76
|
+
"#{action.inspect} is not something a #{kind} can record. A #{kind} can be: " \
|
|
77
|
+
"#{Vocabulary::ACTIONS_FOR_KIND.fetch(kind, []).join(", ")}."
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def refuse_change
|
|
82
|
+
raise EventWriteFailed,
|
|
83
|
+
"Statement #{statement_key} on event #{event_id} is an immutable snapshot of what " \
|
|
84
|
+
"was offered and answered. Record a correction, withdrawal, or supersession instead."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refuse_destroy
|
|
88
|
+
raise EventWriteFailed,
|
|
89
|
+
"Statement #{statement_key} on event #{event_id} cannot be destroyed."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The outbox row for an action Clickwrap cannot make atomic.
|
|
5
|
+
#
|
|
6
|
+
# Stripe, an identity provider, a timestamp authority: none of them can join
|
|
7
|
+
# your database transaction, and pretending otherwise is how a provider
|
|
8
|
+
# timeout becomes either a fictional success or a second debit. So the local
|
|
9
|
+
# transaction commits a pending authorization and an idempotency key, the
|
|
10
|
+
# provider is called outside it, and the outcome is appended back
|
|
11
|
+
# idempotently.
|
|
12
|
+
#
|
|
13
|
+
# `unknown` is a first-class state, not an error state. A timeout is not a
|
|
14
|
+
# failure — it is an absence of information — and the reconciliation task
|
|
15
|
+
# exists precisely to resolve those later rather than guessing now.
|
|
16
|
+
class ExternalAction < ApplicationRecord
|
|
17
|
+
self.table_name = "clickwrap_external_actions"
|
|
18
|
+
|
|
19
|
+
STATES = %w[pending succeeded failed unknown].freeze
|
|
20
|
+
|
|
21
|
+
belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :external_action
|
|
22
|
+
|
|
23
|
+
validates :policy_key, :idempotency_key, :requested_at, presence: true
|
|
24
|
+
validates :idempotency_key, uniqueness: true
|
|
25
|
+
validates :state, inclusion: { in: STATES }
|
|
26
|
+
|
|
27
|
+
before_update :refuse_ordinary_update
|
|
28
|
+
before_destroy :refuse_destroy, prepend: true
|
|
29
|
+
|
|
30
|
+
scope :pending, -> { where(state: "pending") }
|
|
31
|
+
scope :unresolved, -> { where(state: %w[pending unknown]) }
|
|
32
|
+
scope :needing_reconciliation, lambda { |older_than = 15.minutes.ago|
|
|
33
|
+
unresolved.where(requested_at: ...older_than)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def pending? = state == "pending"
|
|
37
|
+
def resolved? = %w[succeeded failed].include?(state)
|
|
38
|
+
|
|
39
|
+
# Each resolution is one idempotent local transaction. Calling it twice with
|
|
40
|
+
# the same outcome is a no-op; calling it with a different outcome after the
|
|
41
|
+
# action already resolved raises, because silently overwriting "succeeded"
|
|
42
|
+
# with "failed" would rewrite the record of what the provider told us.
|
|
43
|
+
def record_provider_success_and_consume!(provider_receipt = nil)
|
|
44
|
+
resolve!("succeeded", provider_receipt: provider_receipt) do
|
|
45
|
+
Lifecycle.consume_authorization!(event: event, because: "External action succeeded")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_provider_failure!(reason:, provider_receipt: nil)
|
|
50
|
+
resolve!("failed", provider_receipt: provider_receipt, failure_reason: reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# For the genuinely ambiguous case: the request may or may not have been
|
|
54
|
+
# carried out, and the honest record says so rather than picking one.
|
|
55
|
+
def record_provider_outcome_unknown!(reason:)
|
|
56
|
+
resolve!("unknown", failure_reason: reason, resolved: false)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_s = "external action #{idempotency_key} (#{state})"
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def resolve!(new_state, provider_receipt: nil, failure_reason: nil, resolved: true)
|
|
64
|
+
transaction do
|
|
65
|
+
reload.lock!
|
|
66
|
+
|
|
67
|
+
if state == new_state
|
|
68
|
+
return self if same_provider_resolution?(
|
|
69
|
+
provider_receipt: provider_receipt,
|
|
70
|
+
failure_reason: failure_reason
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Unknown is an observation, not a terminal result. A later
|
|
74
|
+
# reconciliation attempt may still be unknown for a different
|
|
75
|
+
# documented reason; append that attempt instead of erasing the first.
|
|
76
|
+
unless new_state == "unknown"
|
|
77
|
+
raise ExternalActionAlreadyResolved,
|
|
78
|
+
"External action #{idempotency_key} is already #{state}, but the repeated provider " \
|
|
79
|
+
"result carries different evidence. The original outcome is not silently overwritten."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if resolved? && new_state != state
|
|
84
|
+
raise ExternalActionAlreadyResolved,
|
|
85
|
+
"External action #{idempotency_key} already resolved as #{state}; it cannot " \
|
|
86
|
+
"become #{new_state}. Appending a new event is the way to record a later change."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
update_columns(
|
|
90
|
+
state: new_state,
|
|
91
|
+
provider_receipt: provider_receipt || self.provider_receipt,
|
|
92
|
+
failure_reason: failure_reason,
|
|
93
|
+
attempt_count: attempt_count + 1,
|
|
94
|
+
resolved_at: resolved ? Clickwrap.now : nil,
|
|
95
|
+
updated_at: Clickwrap.now
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
Lifecycle.append_lifecycle_event!(
|
|
99
|
+
event: event,
|
|
100
|
+
event_type: "provider_outcome",
|
|
101
|
+
reason: provider_outcome_reason(new_state, failure_reason),
|
|
102
|
+
extra: {
|
|
103
|
+
protected_outcome: {
|
|
104
|
+
"external_action" => {
|
|
105
|
+
"external_action_id" => id.to_s,
|
|
106
|
+
"idempotency_key" => idempotency_key,
|
|
107
|
+
"provider_name" => provider_name,
|
|
108
|
+
"state" => new_state,
|
|
109
|
+
"provider_receipt" => provider_receipt,
|
|
110
|
+
"failure_reason" => failure_reason
|
|
111
|
+
}.compact
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
yield if block_given?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def provider_outcome_reason(new_state, failure_reason)
|
|
123
|
+
["External action was recorded as #{new_state}.", failure_reason].compact.join(" ")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def same_provider_resolution?(provider_receipt:, failure_reason:)
|
|
127
|
+
receipt_matches = provider_receipt.nil? ||
|
|
128
|
+
canonical_value(provider_receipt) == canonical_value(self.provider_receipt)
|
|
129
|
+
reason_matches = failure_reason.to_s == self.failure_reason.to_s
|
|
130
|
+
|
|
131
|
+
receipt_matches && reason_matches
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def canonical_value(value)
|
|
135
|
+
CanonicalJson.generate(value)
|
|
136
|
+
rescue CanonicalJson::SerializationError
|
|
137
|
+
value
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def refuse_ordinary_update
|
|
141
|
+
raise ImmutableEvidenceError,
|
|
142
|
+
"External action outcomes are changed only through the named provider-result methods."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def refuse_destroy
|
|
146
|
+
raise ImmutableEvidenceError,
|
|
147
|
+
"External actions are durable outbox records and cannot be destroyed."
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# One immutable result from an optional external integrity adapter. Merely
|
|
5
|
+
# configuring an adapter is not evidence; this row records what was actually
|
|
6
|
+
# submitted, what the provider returned, and whether its verifier checked the
|
|
7
|
+
# exact event digest.
|
|
8
|
+
class IntegrityAttestation < ApplicationRecord
|
|
9
|
+
self.table_name = "clickwrap_integrity_attestations"
|
|
10
|
+
self.record_timestamps = false
|
|
11
|
+
|
|
12
|
+
KINDS = %w[event_anchor third_party_timestamp].freeze
|
|
13
|
+
STATES = %w[verified issued_unverified unavailable failed].freeze
|
|
14
|
+
|
|
15
|
+
belongs_to :event, class_name: "Clickwrap::Event", inverse_of: :integrity_attestations
|
|
16
|
+
|
|
17
|
+
validates :kind, inclusion: { in: KINDS }
|
|
18
|
+
validates :state, inclusion: { in: STATES }
|
|
19
|
+
validates :event_id, :provider_name, :subject_digest, :attempted_at, :attestation_digest,
|
|
20
|
+
presence: true
|
|
21
|
+
|
|
22
|
+
before_validation :assign_attestation_digest, on: :create
|
|
23
|
+
before_update :refuse_update
|
|
24
|
+
before_destroy :refuse_destroy, prepend: true
|
|
25
|
+
|
|
26
|
+
scope :verified, -> { where(state: "verified") }
|
|
27
|
+
|
|
28
|
+
def verified?
|
|
29
|
+
state == "verified" && verification.to_h["checked"] == true &&
|
|
30
|
+
verification.to_h["verified"] == true && digest_verified?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def verified_for?(source_event)
|
|
34
|
+
verified? && event_id.to_s == source_event.id.to_s &&
|
|
35
|
+
subject_digest.to_s == source_event.event_digest.to_s &&
|
|
36
|
+
(kind != "event_anchor" ||
|
|
37
|
+
(chain_scope.to_s == source_event.chain_scope.to_s && chain_sequence == source_event.chain_sequence))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def digest_verified?
|
|
41
|
+
Digest.secure_compare?(attestation_digest.to_s, compute_attestation_digest)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def canonical_fragment
|
|
45
|
+
canonical_body.merge(
|
|
46
|
+
"attestation_digest" => attestation_digest
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def canonical_body
|
|
51
|
+
{
|
|
52
|
+
"event_id" => event_id,
|
|
53
|
+
"kind" => kind,
|
|
54
|
+
"state" => state,
|
|
55
|
+
"provider_name" => provider_name,
|
|
56
|
+
"subject_digest" => subject_digest,
|
|
57
|
+
"chain_scope" => chain_scope,
|
|
58
|
+
"chain_sequence" => chain_sequence,
|
|
59
|
+
"provider_reference" => provider_reference,
|
|
60
|
+
"provider_result" => provider_result.presence,
|
|
61
|
+
"verification" => verification.presence,
|
|
62
|
+
"adapter_capabilities" => adapter_capabilities.presence,
|
|
63
|
+
"attempted_at" => Receipt.format_time(attempted_at),
|
|
64
|
+
"provider_reported_at" => Receipt.format_time(provider_reported_at),
|
|
65
|
+
"created_at" => Receipt.format_time(created_at)
|
|
66
|
+
}.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def assign_attestation_digest
|
|
72
|
+
self.attestation_digest ||= compute_attestation_digest
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compute_attestation_digest
|
|
76
|
+
Digest.digest_canonical(canonical_body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def refuse_update
|
|
80
|
+
raise ImmutableEvidenceError,
|
|
81
|
+
"Integrity attestations cannot be updated through Clickwrap. " \
|
|
82
|
+
"Re-check a provider by recording a new attestation."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def refuse_destroy
|
|
86
|
+
raise ImmutableEvidenceError,
|
|
87
|
+
"Integrity attestations cannot be destroyed through Clickwrap."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A legal hold pauses scheduled disposition for an event, an actor, or a
|
|
5
|
+
# policy.
|
|
6
|
+
#
|
|
7
|
+
# It requires a reason, an owner, and a review date. That is not bureaucracy:
|
|
8
|
+
# an indefinite hold with no owner is exactly how "we'll delete it later"
|
|
9
|
+
# becomes "we kept everything forever", and a retention policy that can be
|
|
10
|
+
# suspended invisibly is not a retention policy. Placement and release append
|
|
11
|
+
# linked lifecycle events; this row is the current operational projection and
|
|
12
|
+
# changes only through the named `release!` transition.
|
|
13
|
+
class LegalHold < ApplicationRecord
|
|
14
|
+
self.table_name = "clickwrap_legal_holds"
|
|
15
|
+
self.record_timestamps = false
|
|
16
|
+
|
|
17
|
+
SCOPES = %w[event actor policy].freeze
|
|
18
|
+
|
|
19
|
+
belongs_to :event, class_name: "Clickwrap::Event", optional: true, inverse_of: :legal_holds
|
|
20
|
+
|
|
21
|
+
validates :reason, :placed_by_reference, :placed_at, :review_at, presence: true
|
|
22
|
+
validates :hold_scope, inclusion: { in: SCOPES }
|
|
23
|
+
validate :scope_has_its_target
|
|
24
|
+
validate :review_follows_placement
|
|
25
|
+
before_update :refuse_ordinary_update
|
|
26
|
+
before_destroy :refuse_destroy, prepend: true
|
|
27
|
+
|
|
28
|
+
scope :in_effect, -> { where(released_at: nil) }
|
|
29
|
+
scope :due_for_review, ->(at = Clickwrap.now) { in_effect.where(review_at: ..at) }
|
|
30
|
+
scope :for_event, ->(id) { where(event_id: id) }
|
|
31
|
+
scope :for_actor, ->(reference) { where(actor_reference: reference) }
|
|
32
|
+
|
|
33
|
+
def released? = released_at.present?
|
|
34
|
+
def in_effect? = !released?
|
|
35
|
+
|
|
36
|
+
def release!(because:, released_by:)
|
|
37
|
+
raise LegalHoldInEffect, "Releasing a legal hold needs a `because:` explaining why." if because.to_s.strip.empty?
|
|
38
|
+
raise LegalHoldInEffect, "Legal hold #{id} was already released at #{released_at}." if released?
|
|
39
|
+
|
|
40
|
+
update_columns(
|
|
41
|
+
released_at: Clickwrap.now,
|
|
42
|
+
released_by_reference: Reference.actor(released_by),
|
|
43
|
+
release_reason: because
|
|
44
|
+
)
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_s = "legal hold on #{hold_scope} (#{reason})"
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def refuse_ordinary_update
|
|
53
|
+
raise ImmutableEvidenceError,
|
|
54
|
+
"Legal holds refuse ordinary updates. Release one through " \
|
|
55
|
+
"`release!(because:, released_by:)`, which records the named transition."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def refuse_destroy
|
|
59
|
+
raise ImmutableEvidenceError,
|
|
60
|
+
"Legal holds cannot be destroyed; release one through the named transition so its history remains."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def scope_has_its_target
|
|
64
|
+
target = case hold_scope
|
|
65
|
+
when "event" then event_id
|
|
66
|
+
when "actor" then actor_reference
|
|
67
|
+
when "policy" then policy_key
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
return if target.present?
|
|
71
|
+
|
|
72
|
+
errors.add(:base, "A #{hold_scope}-scoped legal hold must name the #{hold_scope} it holds.")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def review_follows_placement
|
|
76
|
+
return if placed_at.nil? || review_at.nil? || review_at > placed_at
|
|
77
|
+
|
|
78
|
+
errors.add(:review_at, "must be after the hold was placed")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A frozen snapshot of one compiled policy.
|
|
5
|
+
#
|
|
6
|
+
# Policies are written in Ruby because that is what a reviewer can read in a
|
|
7
|
+
# pull request. But an export has to stay intelligible after the source has
|
|
8
|
+
# moved on, so the first time a revision is offered or captured its compiled
|
|
9
|
+
# form is written here and never changed. An event points at the revision it
|
|
10
|
+
# was captured under, so a receipt explains itself without a checkout of the
|
|
11
|
+
# application at the right commit.
|
|
12
|
+
#
|
|
13
|
+
# The revision digest covers declared structure and copy. Host callables —
|
|
14
|
+
# subject fingerprints, protected-outcome recorders — are recorded as present
|
|
15
|
+
# rather than serialized, because a lambda's body cannot be canonicalized.
|
|
16
|
+
# That boundary is stated in the receipt rather than papered over.
|
|
17
|
+
class PolicyRevision < ApplicationRecord
|
|
18
|
+
self.table_name = "clickwrap_policy_revisions"
|
|
19
|
+
|
|
20
|
+
has_many :events,
|
|
21
|
+
class_name: "Clickwrap::Event",
|
|
22
|
+
foreign_key: :policy_revision_id,
|
|
23
|
+
inverse_of: :policy_revision,
|
|
24
|
+
dependent: :restrict_with_error
|
|
25
|
+
|
|
26
|
+
has_many :presentations,
|
|
27
|
+
class_name: "Clickwrap::Presentation",
|
|
28
|
+
foreign_key: :policy_revision_id,
|
|
29
|
+
inverse_of: :policy_revision,
|
|
30
|
+
dependent: :restrict_with_error
|
|
31
|
+
|
|
32
|
+
validates :policy_key, :revision_digest, :canonical_schema_version, :gem_version, presence: true
|
|
33
|
+
validates :revision_digest, uniqueness: { scope: :policy_key }
|
|
34
|
+
|
|
35
|
+
before_update :refuse_change
|
|
36
|
+
|
|
37
|
+
scope :for_policy, ->(key) { where(policy_key: key.to_s) }
|
|
38
|
+
|
|
39
|
+
# Finds or freezes the revision for a compiled policy. Called on the first
|
|
40
|
+
# presentation and again at capture; both are races that the unique index
|
|
41
|
+
# settles, so a lost race just reads the row the winner wrote.
|
|
42
|
+
def self.freeze_for(policy)
|
|
43
|
+
existing = find_by(policy_key: policy.key, revision_digest: policy.revision)
|
|
44
|
+
return existing if existing
|
|
45
|
+
|
|
46
|
+
create!(
|
|
47
|
+
policy_key: policy.key,
|
|
48
|
+
revision_digest: policy.revision,
|
|
49
|
+
compiled_snapshot: policy.snapshot,
|
|
50
|
+
retention_class_key: policy.retention_class_key,
|
|
51
|
+
canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
52
|
+
gem_version: Clickwrap::VERSION,
|
|
53
|
+
compiled_at: Clickwrap.now,
|
|
54
|
+
created_at: Clickwrap.now
|
|
55
|
+
)
|
|
56
|
+
rescue ActiveRecord::RecordNotUnique
|
|
57
|
+
find_by!(policy_key: policy.key, revision_digest: policy.revision)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Freezes the semantics of a legacy mapping without pretending the old act
|
|
61
|
+
# used the policy revision loaded today. The snapshot names only what the
|
|
62
|
+
# importer actually knows: source, mapped statements, known facts, and
|
|
63
|
+
# unknown facts. It intentionally cannot match `policy.revision`, so
|
|
64
|
+
# `require_current_revision: true` always asks for a modern presentation.
|
|
65
|
+
def self.freeze_legacy_import_for(policy, source:, statements:, known:, unknown:)
|
|
66
|
+
snapshot = {
|
|
67
|
+
"schema" => Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
68
|
+
"policy" => policy.key,
|
|
69
|
+
"revision_kind" => "legacy_import_mapping",
|
|
70
|
+
"source" => source.presence,
|
|
71
|
+
"statements" => statements.map { |statement| { "key" => statement.key, "kind" => statement.kind } },
|
|
72
|
+
"known" => known,
|
|
73
|
+
"unknown" => unknown,
|
|
74
|
+
"retention_class" => policy.retention_class_key
|
|
75
|
+
}.compact
|
|
76
|
+
digest = Digest.digest_canonical(snapshot)
|
|
77
|
+
existing = find_by(policy_key: policy.key, revision_digest: digest)
|
|
78
|
+
return existing if existing
|
|
79
|
+
|
|
80
|
+
create!(
|
|
81
|
+
policy_key: policy.key,
|
|
82
|
+
revision_digest: digest,
|
|
83
|
+
compiled_snapshot: snapshot,
|
|
84
|
+
retention_class_key: policy.retention_class_key,
|
|
85
|
+
canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
86
|
+
gem_version: Clickwrap::VERSION,
|
|
87
|
+
compiled_at: Clickwrap.now,
|
|
88
|
+
created_at: Clickwrap.now
|
|
89
|
+
)
|
|
90
|
+
rescue ActiveRecord::RecordNotUnique
|
|
91
|
+
find_by!(policy_key: policy.key, revision_digest: digest)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# True when the currently loaded policy compiles to this same revision. A
|
|
95
|
+
# false is not an error — it means the policy has been edited since, which
|
|
96
|
+
# is exactly why the snapshot is here.
|
|
97
|
+
def matches_loaded_policy?
|
|
98
|
+
Clickwrap.policies[policy_key]&.revision == revision_digest
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def statement_snapshot(statement_key)
|
|
102
|
+
Array(compiled_snapshot["statements"]).find { |s| s["key"] == statement_key.to_s }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_s = "#{policy_key}@#{revision_digest}"
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def refuse_change
|
|
110
|
+
raise EventWriteFailed,
|
|
111
|
+
"Policy revision #{self} is frozen. Editing the policy in Ruby produces a new " \
|
|
112
|
+
"revision; it does not change what earlier events were captured under."
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A record of one presentation manifest.
|
|
5
|
+
#
|
|
6
|
+
# Most presentations never become rows. The default path signs the manifest
|
|
7
|
+
# into a short-lived token and writes nothing on GET, because a render is not
|
|
8
|
+
# evidence and a row per page view is both slower and more personal data than
|
|
9
|
+
# the job needs.
|
|
10
|
+
#
|
|
11
|
+
# Rows appear in two cases. A policy can explicitly retain pre-submit
|
|
12
|
+
# presentations for a documented reason, which some high-assurance flows want
|
|
13
|
+
# so that display attempts are visible. And every successful capture persists
|
|
14
|
+
# the manifest it accepted, because that one is part of the evidence.
|
|
15
|
+
#
|
|
16
|
+
# The `state` vocabulary is deliberately careful. `presented_by_server` means
|
|
17
|
+
# the server generated and offered this manifest — not that anyone saw it, not
|
|
18
|
+
# that anyone read it, and certainly not that anyone accepted it.
|
|
19
|
+
class Presentation < ApplicationRecord
|
|
20
|
+
self.table_name = "clickwrap_presentations"
|
|
21
|
+
|
|
22
|
+
STATES = %w[presented_by_server accepted rejected expired].freeze
|
|
23
|
+
|
|
24
|
+
belongs_to :policy_revision, class_name: "Clickwrap::PolicyRevision", inverse_of: :presentations
|
|
25
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
26
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
27
|
+
belongs_to :represented_party, polymorphic: true, optional: true
|
|
28
|
+
|
|
29
|
+
has_many :events,
|
|
30
|
+
class_name: "Clickwrap::Event",
|
|
31
|
+
foreign_key: :presentation_id,
|
|
32
|
+
inverse_of: :presentation,
|
|
33
|
+
dependent: :restrict_with_error
|
|
34
|
+
|
|
35
|
+
validates :policy_key, :nonce, :manifest_digest, :issued_at, :expires_at, presence: true
|
|
36
|
+
validates :nonce, uniqueness: true
|
|
37
|
+
validates :state, inclusion: { in: STATES }
|
|
38
|
+
validates :capture_channel, inclusion: { in: Vocabulary::CAPTURE_CHANNELS }
|
|
39
|
+
|
|
40
|
+
scope :pending, -> { where(state: "presented_by_server") }
|
|
41
|
+
scope :expired_at, ->(moment = Clickwrap.now) { where(expires_at: ..moment) }
|
|
42
|
+
scope :due_for_disposition, lambda { |at = Clickwrap.now|
|
|
43
|
+
where.not(retain_until: nil).where(retain_until: ..at)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def expired?(at = Clickwrap.now) = expires_at <= at
|
|
47
|
+
def accepted? = state == "accepted"
|
|
48
|
+
|
|
49
|
+
def mark_accepted!(at: Clickwrap.now)
|
|
50
|
+
update!(state: "accepted", submitted_at: at)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mark_rejected!(at: Clickwrap.now)
|
|
54
|
+
update!(state: "rejected", submitted_at: at)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_s = "presentation #{nonce} for #{policy_key}"
|
|
58
|
+
end
|
|
59
|
+
end
|