clickwrap 0.0.0 → 0.1.1
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 +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -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 +154 -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 +187 -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 +729 -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,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The abstract base class every model the gem ships inherits from. It plays
|
|
5
|
+
# the exact role an engine's `app/models/<engine>/application_record.rb`
|
|
6
|
+
# normally plays.
|
|
7
|
+
#
|
|
8
|
+
# It inherits from the HOST's `::ActiveRecord::Base`, NOT from the host's
|
|
9
|
+
# `::ApplicationRecord`. That matters here more than in most engines: evidence
|
|
10
|
+
# tables must behave identically in every host, and a default scope, a
|
|
11
|
+
# multitenancy filter, or a `before_save` bolted onto the app's base class
|
|
12
|
+
# could quietly change what gets recorded — or hide rows from an export that
|
|
13
|
+
# is supposed to be complete.
|
|
14
|
+
#
|
|
15
|
+
# The reference to `::ActiveRecord::Base` is fully qualified so Ruby's
|
|
16
|
+
# constant lookup can never re-bind it to a `Clickwrap::ActiveRecord`.
|
|
17
|
+
class ApplicationRecord < ::ActiveRecord::Base
|
|
18
|
+
self.abstract_class = true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# The head of one tamper-evident event chain.
|
|
5
|
+
#
|
|
6
|
+
# Chaining is off unless configured. The scope is tenant-and-policy: a
|
|
7
|
+
# tenanted installation gets one chain per (tenant, policy). An UNTENANTED
|
|
8
|
+
# installation's scope is the literal "global/<policy_key>" — one chain per
|
|
9
|
+
# policy across all actors, which means every capture of that policy
|
|
10
|
+
# serializes behind every other. Enable chaining on hot policies knowing
|
|
11
|
+
# that queue exists; per-actor scoping is future work, not current behavior.
|
|
12
|
+
#
|
|
13
|
+
# What a chain detects: an event rewritten or removed after the fact, as long
|
|
14
|
+
# as the head remains trustworthy. What it does not do: stop a party with full
|
|
15
|
+
# control of the application and database from rewriting both the events and
|
|
16
|
+
# the head. That is what the optional independent anchor adapter is for, and
|
|
17
|
+
# even then the claim is only as strong as the anchor.
|
|
18
|
+
class ChainHead < ApplicationRecord
|
|
19
|
+
self.table_name = "clickwrap_chain_heads"
|
|
20
|
+
|
|
21
|
+
validates :chain_scope, presence: true, uniqueness: true
|
|
22
|
+
|
|
23
|
+
# Appending is two phases, and it has to be.
|
|
24
|
+
#
|
|
25
|
+
# An event's digest is computed from its own body, which the event does not
|
|
26
|
+
# have until it is built — but its `previous_event_digest` has to be set
|
|
27
|
+
# before it is saved. So `reserve!` hands out the predecessor's digest and
|
|
28
|
+
# the next sequence number, the event is written with those, and `record!`
|
|
29
|
+
# then stores the digest the event actually ended up with.
|
|
30
|
+
#
|
|
31
|
+
# Doing it in one call is how a chain quietly ends up with a head full of
|
|
32
|
+
# nils: every link would point at a digest that had not been computed yet.
|
|
33
|
+
|
|
34
|
+
# Takes the next position in the chain. The row lock is what stops two
|
|
35
|
+
# concurrent captures in the same scope from reading the same predecessor
|
|
36
|
+
# and forking the chain.
|
|
37
|
+
def self.reserve!(chain_scope:)
|
|
38
|
+
# The first probe must be an ordinary read. On MySQL/InnoDB, two
|
|
39
|
+
# `SELECT ... FOR UPDATE` calls for the same absent unique key both take
|
|
40
|
+
# gap locks; when both then INSERT, InnoDB has to deadlock one of them.
|
|
41
|
+
# Let the unique INSERT choose the first writer, absorb the loser's
|
|
42
|
+
# duplicate in a savepoint, and only then take the row lock.
|
|
43
|
+
head = find_by(chain_scope: chain_scope)
|
|
44
|
+
|
|
45
|
+
unless head
|
|
46
|
+
begin
|
|
47
|
+
transaction(requires_new: true) { create!(chain_scope: chain_scope, chain_sequence: 0) }
|
|
48
|
+
rescue ActiveRecord::RecordNotUnique
|
|
49
|
+
# The savepoint absorbs PostgreSQL's aborted-statement state before
|
|
50
|
+
# the winning row is read and locked in the caller's transaction.
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Always re-read under the lock. A plain read above is only an existence
|
|
55
|
+
# probe and may carry a stale sequence; this is the value from which the
|
|
56
|
+
# next link is actually reserved.
|
|
57
|
+
head = lock.find_by!(chain_scope: chain_scope)
|
|
58
|
+
|
|
59
|
+
next_sequence = head.chain_sequence + 1
|
|
60
|
+
previous_digest = head.last_event_digest
|
|
61
|
+
|
|
62
|
+
head.update!(chain_sequence: next_sequence)
|
|
63
|
+
|
|
64
|
+
[previous_digest, next_sequence]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Records the digest the event was actually written with, so the next event
|
|
68
|
+
# in this scope links to something real.
|
|
69
|
+
def self.record!(chain_scope:, event_id:, event_digest:)
|
|
70
|
+
head = lock.find_by(chain_scope: chain_scope)
|
|
71
|
+
return nil unless head
|
|
72
|
+
|
|
73
|
+
head.update!(last_event_id: event_id, last_event_digest: event_digest)
|
|
74
|
+
head
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_s = "chain #{chain_scope} at #{chain_sequence}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# What `has_clickwraps` mixes into the actor model.
|
|
5
|
+
#
|
|
6
|
+
# It adds the evidence proxy and the associations, and nothing else. Notably
|
|
7
|
+
# absent: any `dependent: :destroy`. Deleting an account must not silently
|
|
8
|
+
# erase the record of what that person agreed to — that is a retention
|
|
9
|
+
# decision, and it belongs to the host and its counsel, not to a foreign key.
|
|
10
|
+
#
|
|
11
|
+
# So the associations nullify the actor link on destroy and leave the stable
|
|
12
|
+
# pseudonymous `actor_reference` behind. The evidence remains queryable and
|
|
13
|
+
# verifiable; what disappears is the pointer to a row that no longer exists.
|
|
14
|
+
# A host that genuinely wants the evidence gone runs disposition through
|
|
15
|
+
# `Clickwrap::Privacy`, which records that it did.
|
|
16
|
+
module HasClickwraps
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
has_many :clickwrap_events,
|
|
21
|
+
class_name: "Clickwrap::Event",
|
|
22
|
+
as: :actor,
|
|
23
|
+
inverse_of: :actor,
|
|
24
|
+
dependent: :nullify
|
|
25
|
+
|
|
26
|
+
has_many :clickwrap_statement_states,
|
|
27
|
+
class_name: "Clickwrap::StatementState",
|
|
28
|
+
as: :actor,
|
|
29
|
+
inverse_of: :actor,
|
|
30
|
+
dependent: :nullify
|
|
31
|
+
|
|
32
|
+
has_many :clickwrap_presentations,
|
|
33
|
+
class_name: "Clickwrap::Presentation",
|
|
34
|
+
as: :actor,
|
|
35
|
+
inverse_of: :actor,
|
|
36
|
+
dependent: :nullify
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The everyday API: `user.clickwraps.agreed_to?(:terms)`.
|
|
40
|
+
def clickwraps
|
|
41
|
+
@clickwraps ||= Clickwrap::ActorProxy.new(self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# How this record is referenced in evidence.
|
|
45
|
+
#
|
|
46
|
+
# Uses GlobalID when the host loads it and a stable class/id string in a
|
|
47
|
+
# minimal Rails host. Either string remains in the evidence after the row is
|
|
48
|
+
# gone — which is the situation this reference exists for. Override this
|
|
49
|
+
# method when the host has its own stable pseudonymous identifier scheme;
|
|
50
|
+
# `identify_actor_with` asks for it first, so no initializer change is needed.
|
|
51
|
+
def clickwrap_actor_reference
|
|
52
|
+
Clickwrap::Reference.record(self)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A reviewable plan to delete something.
|
|
5
|
+
#
|
|
6
|
+
# Deletion here is always two steps: plan, then apply. The plan is immutable,
|
|
7
|
+
# scoped, and expiring, and it is rechecked at apply time. A newly placed
|
|
8
|
+
# legal hold, a changed policy, a changed eligibility, or a stale plan stops
|
|
9
|
+
# the run rather than deleting a broader set than the person who reviewed it
|
|
10
|
+
# agreed to.
|
|
11
|
+
#
|
|
12
|
+
# The plan itself never decides whether an erasure request overrides a
|
|
13
|
+
# retention duty, a legal claim, or a hold. It shows what would happen.
|
|
14
|
+
class DispositionPlan < ApplicationRecord
|
|
15
|
+
self.table_name = "clickwrap_disposition_plans"
|
|
16
|
+
self.primary_key = "id"
|
|
17
|
+
|
|
18
|
+
KINDS = %w[retention actor_privacy].freeze
|
|
19
|
+
STATES = %w[open applying applied applied_with_errors superseded].freeze
|
|
20
|
+
|
|
21
|
+
DEFAULT_LIFETIME = 24.hours
|
|
22
|
+
|
|
23
|
+
validates :kind, inclusion: { in: KINDS }
|
|
24
|
+
validates :state, inclusion: { in: STATES }
|
|
25
|
+
validates :expires_at, presence: true
|
|
26
|
+
validates :plan_digest, presence: true
|
|
27
|
+
|
|
28
|
+
before_validation :assign_identifier, on: :create
|
|
29
|
+
before_validation :assign_plan_digest, on: :create
|
|
30
|
+
before_update :refuse_ordinary_update
|
|
31
|
+
before_destroy :refuse_destroy, prepend: true
|
|
32
|
+
|
|
33
|
+
scope :open_plans, -> { where(state: "open") }
|
|
34
|
+
scope :usable, ->(at = Clickwrap.now) { open_plans.where("expires_at > ?", at) }
|
|
35
|
+
|
|
36
|
+
def expired?(at = Clickwrap.now) = expires_at <= at
|
|
37
|
+
def applied? = state == "applied"
|
|
38
|
+
|
|
39
|
+
def usable?(at = Clickwrap.now)
|
|
40
|
+
state == "open" && !expired?(at)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raises with the specific reason this plan can no longer be applied, so an
|
|
44
|
+
# operator sees "the plan expired" or "this was already applied" rather than
|
|
45
|
+
# a generic refusal.
|
|
46
|
+
def ensure_usable!(at = Clickwrap.now)
|
|
47
|
+
raise DispositionPlanInvalid, "Disposition plan #{id} failed its immutable plan digest." unless digest_verified?
|
|
48
|
+
return true if usable?(at)
|
|
49
|
+
|
|
50
|
+
raise DispositionPlanInvalid,
|
|
51
|
+
case state
|
|
52
|
+
when "applied", "applied_with_errors" then "Disposition plan #{id} was already applied at #{applied_at}."
|
|
53
|
+
when "applying" then "Disposition plan #{id} is already being applied."
|
|
54
|
+
when "superseded" then "Disposition plan #{id} was superseded by a newer plan."
|
|
55
|
+
else "Disposition plan #{id} expired at #{expires_at}. Run the plan again and " \
|
|
56
|
+
"review the current set before applying it."
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def claim_for_application!(by_reference:, recover_if_stale_after: nil,
|
|
61
|
+
because_recovery_is_needed: nil)
|
|
62
|
+
if by_reference.to_s.strip.empty?
|
|
63
|
+
raise DispositionPlanInvalid,
|
|
64
|
+
"Applying a disposition plan needs the stable reference of the operator doing it."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
with_lock do
|
|
68
|
+
if state == "applying"
|
|
69
|
+
reclaim_stale_application!(
|
|
70
|
+
by_reference: by_reference,
|
|
71
|
+
stale_after: recover_if_stale_after,
|
|
72
|
+
because: because_recovery_is_needed
|
|
73
|
+
)
|
|
74
|
+
else
|
|
75
|
+
ensure_usable!
|
|
76
|
+
start_application_attempt!(by_reference)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def finish_application!(outcome_summary:, had_errors: false)
|
|
83
|
+
with_lock do
|
|
84
|
+
unless state == "applying"
|
|
85
|
+
raise DispositionPlanInvalid, "Disposition plan #{id} is not currently being applied."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
update_columns(
|
|
89
|
+
state: had_errors ? "applied_with_errors" : "applied",
|
|
90
|
+
applied_at: Clickwrap.now,
|
|
91
|
+
application_outcome: outcome_summary
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def supersede!(because:, by:)
|
|
98
|
+
raise DispositionPlanInvalid, "Superseding a plan needs a `because:`." if because.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
with_lock do
|
|
101
|
+
ensure_usable!
|
|
102
|
+
update_columns(
|
|
103
|
+
state: "superseded",
|
|
104
|
+
superseded_at: Clickwrap.now,
|
|
105
|
+
superseded_by_reference: Reference.actor(by),
|
|
106
|
+
superseded_reason: because
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def digest_verified?
|
|
113
|
+
Digest.secure_compare?(plan_digest.to_s, compute_plan_digest)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def to_s = "#{kind} disposition plan #{id} (#{item_count} items)"
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def assign_identifier
|
|
121
|
+
self.id ||= Identifier.generate
|
|
122
|
+
self.expires_at ||= Clickwrap.now + DEFAULT_LIFETIME
|
|
123
|
+
self.created_at ||= Clickwrap.now
|
|
124
|
+
self.updated_at ||= created_at
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def assign_plan_digest
|
|
128
|
+
self.plan_digest ||= compute_plan_digest
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def compute_plan_digest
|
|
132
|
+
Digest.digest_canonical({
|
|
133
|
+
"id" => id,
|
|
134
|
+
"kind" => kind,
|
|
135
|
+
"scope" => disposition_scope,
|
|
136
|
+
"summary" => summary,
|
|
137
|
+
"item_count" => item_count,
|
|
138
|
+
"created_by_reference" => created_by_reference,
|
|
139
|
+
"reason" => reason,
|
|
140
|
+
"expires_at" => Receipt.format_time(expires_at),
|
|
141
|
+
"created_at" => Receipt.format_time(created_at)
|
|
142
|
+
})
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def start_application_attempt!(by_reference)
|
|
146
|
+
started_at = Clickwrap.now
|
|
147
|
+
update_columns(
|
|
148
|
+
state: "applying",
|
|
149
|
+
application_started_at: started_at,
|
|
150
|
+
applied_by_reference: by_reference.to_s,
|
|
151
|
+
application_attempt_count: application_attempt_count.to_i + 1,
|
|
152
|
+
updated_at: started_at
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def reclaim_stale_application!(by_reference:, stale_after:, because:)
|
|
157
|
+
raise DispositionPlanInvalid, "Disposition plan #{id} expired at #{expires_at}." if expired?
|
|
158
|
+
|
|
159
|
+
unless stale_after.respond_to?(:positive?) && stale_after.positive?
|
|
160
|
+
raise DispositionPlanInvalid,
|
|
161
|
+
"Disposition plan #{id} is already being applied. Recovering it requires " \
|
|
162
|
+
"recover_application_if_stale_for with a positive duration."
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
cutoff = Clickwrap.now - stale_after
|
|
166
|
+
if application_started_at.present? && application_started_at > cutoff
|
|
167
|
+
raise DispositionPlanInvalid,
|
|
168
|
+
"Disposition plan #{id} has only been applying since #{application_started_at}; " \
|
|
169
|
+
"it is not older than the #{stale_after.inspect} recovery threshold."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if because.to_s.strip.empty?
|
|
173
|
+
raise DispositionPlanInvalid,
|
|
174
|
+
"Recovering a stale disposition application needs " \
|
|
175
|
+
"because_recovery_is_needed in plain English."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
recovered_at = Clickwrap.now
|
|
179
|
+
recoveries = Array(application_recoveries).dup
|
|
180
|
+
recoveries << {
|
|
181
|
+
"previous_application_started_at" => Receipt.format_time(application_started_at),
|
|
182
|
+
"previous_applied_by_reference" => applied_by_reference,
|
|
183
|
+
"recovered_at" => Receipt.format_time(recovered_at),
|
|
184
|
+
"recovered_by_reference" => by_reference.to_s,
|
|
185
|
+
"reason" => because.to_s.strip
|
|
186
|
+
}.compact
|
|
187
|
+
|
|
188
|
+
update_columns(
|
|
189
|
+
application_started_at: recovered_at,
|
|
190
|
+
applied_by_reference: by_reference.to_s,
|
|
191
|
+
application_attempt_count: application_attempt_count.to_i + 1,
|
|
192
|
+
application_recoveries: recoveries,
|
|
193
|
+
updated_at: recovered_at
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def refuse_ordinary_update
|
|
198
|
+
raise ImmutableEvidenceError,
|
|
199
|
+
"Disposition plans refuse ordinary updates. Use claim_for_application!, " \
|
|
200
|
+
"finish_application!, or supersede!(because:, by:) for a named transition."
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def refuse_destroy
|
|
204
|
+
raise ImmutableEvidenceError,
|
|
205
|
+
"Disposition plans cannot be destroyed; their reviewed scope and named transitions must remain."
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# A logical document — `:terms`, `:privacy_notice` — separate from the
|
|
5
|
+
# immutable versions that carry its actual bytes.
|
|
6
|
+
class Document < ApplicationRecord
|
|
7
|
+
self.table_name = "clickwrap_documents"
|
|
8
|
+
|
|
9
|
+
has_many :versions,
|
|
10
|
+
class_name: "Clickwrap::DocumentVersion",
|
|
11
|
+
foreign_key: :document_id,
|
|
12
|
+
inverse_of: :document,
|
|
13
|
+
dependent: :restrict_with_error
|
|
14
|
+
|
|
15
|
+
validates :document_key, presence: true, uniqueness: { scope: :tenant_key }
|
|
16
|
+
|
|
17
|
+
scope :for_tenant, ->(tenant_key) { where(tenant_key: tenant_key.presence) }
|
|
18
|
+
|
|
19
|
+
# The version a policy should present right now for a locale: published,
|
|
20
|
+
# already effective, and not retired. A version scheduled for the future is
|
|
21
|
+
# deliberately not presentable yet — that is what `effective_at` is for.
|
|
22
|
+
# The version to present right now: published, already effective, and not
|
|
23
|
+
# retired. A version scheduled for the future is deliberately not
|
|
24
|
+
# presentable yet — that is what `effective_at` is for.
|
|
25
|
+
#
|
|
26
|
+
# The ordering is by effective time, then by publication time as a
|
|
27
|
+
# tie-breaker for two versions scheduled for the same instant. Publishing
|
|
28
|
+
# always writes an `effective_at`, so no NULL reaches this comparison and
|
|
29
|
+
# PostgreSQL and SQLite agree about which document the server offered.
|
|
30
|
+
def current_version(locale: I18n.locale, at: Clickwrap.now)
|
|
31
|
+
versions
|
|
32
|
+
.published
|
|
33
|
+
.effective_at_or_before(at)
|
|
34
|
+
.not_retired_at(at)
|
|
35
|
+
.for_locale(locale)
|
|
36
|
+
.order(effective_at: :desc, published_at: :desc, created_at: :desc)
|
|
37
|
+
.first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def version(label, locale: I18n.locale)
|
|
41
|
+
versions.for_locale(locale).find_by(version_label: label)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s = document_key
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# An immutable published version of a document.
|
|
5
|
+
#
|
|
6
|
+
# Once `published_at` is set, the bytes and their digest are frozen. There is
|
|
7
|
+
# no supported way to edit them: a change is a new version, and the old row
|
|
8
|
+
# stays exactly as it was because a receipt from last year points at it.
|
|
9
|
+
#
|
|
10
|
+
# `content_digest` covers the original source bytes. `rendered_content_digest`
|
|
11
|
+
# covers the representation actually offered when a source format was
|
|
12
|
+
# transformed for display. Keeping both is what lets a receipt distinguish
|
|
13
|
+
# "this Markdown file existed" from "the server offered this rendered HTML"
|
|
14
|
+
# instead of letting one claim borrow the other's credibility.
|
|
15
|
+
class DocumentVersion < ApplicationRecord
|
|
16
|
+
self.table_name = "clickwrap_document_versions"
|
|
17
|
+
|
|
18
|
+
STORAGE_BACKENDS = %w[database active_storage resolver].freeze
|
|
19
|
+
|
|
20
|
+
belongs_to :document, class_name: "Clickwrap::Document", inverse_of: :versions
|
|
21
|
+
|
|
22
|
+
has_many :event_documents,
|
|
23
|
+
class_name: "Clickwrap::EventDocument",
|
|
24
|
+
foreign_key: :document_version_id,
|
|
25
|
+
inverse_of: :document_version,
|
|
26
|
+
dependent: :restrict_with_error
|
|
27
|
+
|
|
28
|
+
validates :version_label, :locale, :media_type, :content_digest, presence: true
|
|
29
|
+
validates :version_label, uniqueness: { scope: %i[document_id locale] }
|
|
30
|
+
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
|
|
31
|
+
|
|
32
|
+
scope :published, -> { where.not(published_at: nil) }
|
|
33
|
+
scope :for_locale, ->(locale) { where(locale: locale.to_s) }
|
|
34
|
+
scope :effective_at_or_before, ->(moment) { where(effective_at: ..moment).or(where(effective_at: nil)) }
|
|
35
|
+
scope :not_retired_at, ->(moment) { where(retired_at: nil).or(where(retired_at: moment...)) }
|
|
36
|
+
|
|
37
|
+
# Publishing freezes content. Editing a published version is refused here
|
|
38
|
+
# rather than in a code review, because the whole promise of this table is
|
|
39
|
+
# that its rows do not change.
|
|
40
|
+
before_update :refuse_to_change_published_version
|
|
41
|
+
before_destroy :refuse_to_destroy_published_version, prepend: true
|
|
42
|
+
|
|
43
|
+
def published? = published_at.present?
|
|
44
|
+
def retired? = retired_at.present?
|
|
45
|
+
|
|
46
|
+
def presentable_at?(moment = Clickwrap.now)
|
|
47
|
+
published? &&
|
|
48
|
+
(effective_at.nil? || effective_at <= moment) &&
|
|
49
|
+
(retired_at.nil? || retired_at > moment)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reads the bytes for this version, from wherever the storage adapter put
|
|
53
|
+
# them, and verifies them against the recorded digest before returning.
|
|
54
|
+
# Verification is not optional: silently returning bytes that no longer
|
|
55
|
+
# match would turn this method into a way to launder edited content into an
|
|
56
|
+
# export.
|
|
57
|
+
def content_bytes
|
|
58
|
+
bytes = read_bytes
|
|
59
|
+
|
|
60
|
+
unless Digest.matches?(bytes, "#{content_digest_algorithm}:#{bare_digest(content_digest)}")
|
|
61
|
+
raise DocumentDigestMismatchError,
|
|
62
|
+
"The stored bytes for document version #{self} no longer match the digest " \
|
|
63
|
+
"recorded when it was published. The evidence that references this version " \
|
|
64
|
+
"cannot be reproduced until that is explained. Recorded: #{content_digest}."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
bytes
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def verify_content_digest
|
|
71
|
+
content_bytes
|
|
72
|
+
true
|
|
73
|
+
rescue DocumentDigestMismatchError, DocumentNotPublishedError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rendered_bytes
|
|
78
|
+
return content_bytes if rendered_content.nil?
|
|
79
|
+
|
|
80
|
+
unless rendered_content_digest.present? && Digest.matches?(rendered_content, rendered_content_digest)
|
|
81
|
+
raise DocumentDigestMismatchError,
|
|
82
|
+
"The rendered bytes for document version #{self} no longer match the digest " \
|
|
83
|
+
"recorded when they were published."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
rendered_content
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verify_rendered_content_digest
|
|
90
|
+
rendered_bytes
|
|
91
|
+
true
|
|
92
|
+
rescue DocumentDigestMismatchError, DocumentNotPublishedError
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def retire!(because:, at: Clickwrap.now)
|
|
97
|
+
raise DocumentVersionConflictError, "Retiring a document version needs a `because:`." if because.to_s.strip.empty?
|
|
98
|
+
raise DocumentVersionConflictError, "Document version #{self} is already retired." if retired?
|
|
99
|
+
|
|
100
|
+
update_columns(retired_at: at, retired_reason: because)
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def prefixed_content_digest = content_digest
|
|
105
|
+
|
|
106
|
+
def to_s = "#{document&.document_key} #{version_label} (#{locale})"
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def read_bytes
|
|
111
|
+
case storage_backend
|
|
112
|
+
when "database" then content.to_s
|
|
113
|
+
when "resolver" then read_from_resolver
|
|
114
|
+
when "active_storage" then read_from_active_storage
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_from_resolver
|
|
119
|
+
resolver = Clickwrap.config.document_resolver
|
|
120
|
+
|
|
121
|
+
unless resolver
|
|
122
|
+
raise ConfigurationError,
|
|
123
|
+
"Document version #{self} is stored through a resolver, but no " \
|
|
124
|
+
"`document_resolver` is configured."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
resolver.call(self).to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def read_from_active_storage
|
|
131
|
+
unless defined?(::ActiveStorage)
|
|
132
|
+
raise ConfigurationError,
|
|
133
|
+
"Document version #{self} is stored in Active Storage, but Active Storage is " \
|
|
134
|
+
"not loaded in this application."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
::ActiveStorage::Blob.find_signed!(storage_locator).download
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def bare_digest(value)
|
|
141
|
+
value.to_s.split(":").last
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def refuse_to_change_published_version
|
|
145
|
+
return unless published_at_was.present?
|
|
146
|
+
|
|
147
|
+
changed_frozen = changed - %w[retired_at retired_reason]
|
|
148
|
+
return if changed_frozen.empty? && !will_save_change_to_retired_at? && !will_save_change_to_retired_reason?
|
|
149
|
+
|
|
150
|
+
raise DocumentVersionConflictError,
|
|
151
|
+
"Document version #{self} has frozen published content. Use `retire!(because:)` to " \
|
|
152
|
+
"record the named retirement metadata and stop future presentation, or publish a new " \
|
|
153
|
+
"version; ordinary updates are refused."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def refuse_to_destroy_published_version
|
|
157
|
+
return unless published?
|
|
158
|
+
|
|
159
|
+
raise DocumentVersionConflictError,
|
|
160
|
+
"Published document version #{self} cannot be destroyed because receipts may cite it."
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|