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,534 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# Everything that happens to evidence after it is captured.
|
|
5
|
+
#
|
|
6
|
+
# The rule that governs all of it: nothing here rewrites history. Withdrawing
|
|
7
|
+
# consent, correcting a declaration, consuming an authorization, expiring an
|
|
8
|
+
# acknowledgment, superseding an agreement — each appends a new event linked
|
|
9
|
+
# to the one it acts on, and updates the current-state projection. The original
|
|
10
|
+
# event stays exactly as it was written, because a receipt has to be able to
|
|
11
|
+
# show both what is true now and what was true then.
|
|
12
|
+
#
|
|
13
|
+
# The lifecycle each kind gets is the one it actually needs. Consent is
|
|
14
|
+
# withdrawable because someone must be able to change their mind as easily as
|
|
15
|
+
# they agreed. An agreement is not: withdrawing future consent to marketing
|
|
16
|
+
# does not retroactively unmake a contract, and a gem that let it would be
|
|
17
|
+
# recording something false.
|
|
18
|
+
module Lifecycle
|
|
19
|
+
class << self
|
|
20
|
+
# Withdraws a consent purpose. First-class, because consent that cannot be
|
|
21
|
+
# withdrawn as easily as it was given is not what this gem will record as
|
|
22
|
+
# consent — the policy compiler already refused to accept one without a
|
|
23
|
+
# withdrawal route.
|
|
24
|
+
def withdraw!(purpose_key, actor:, because:, tenant: nil, subject: nil,
|
|
25
|
+
acting_for: nil, http_request: nil)
|
|
26
|
+
require_reason!(because, "Withdrawing consent")
|
|
27
|
+
|
|
28
|
+
candidates = StatementState
|
|
29
|
+
.for_actor(reference_for(actor))
|
|
30
|
+
.for_purpose(purpose_key)
|
|
31
|
+
.where(kind: "consent",
|
|
32
|
+
subject_key: Reference.subject(subject),
|
|
33
|
+
represented_party_reference: Reference.represented_party(acting_for))
|
|
34
|
+
.to_a
|
|
35
|
+
|
|
36
|
+
# Tenant is matched under EACH state's own policy semantics, exactly as
|
|
37
|
+
# the grant was recorded: a consent captured under
|
|
38
|
+
# `tenant_is :not_applicable` lives at a nil tenant key, and the
|
|
39
|
+
# ambient organization in the withdrawing session must not hide it —
|
|
40
|
+
# that would make consent granted personally unwithdrawable the moment
|
|
41
|
+
# the person joins an organization.
|
|
42
|
+
for_purpose = candidates.select do |state|
|
|
43
|
+
state.tenant_key == expected_tenant_key_for(state, tenant)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
states = for_purpose.select { |state| state.state == "active" }
|
|
47
|
+
|
|
48
|
+
if states.empty?
|
|
49
|
+
# Two different situations, told apart, because a person pressing
|
|
50
|
+
# "withdraw" twice is not the same as an application withdrawing
|
|
51
|
+
# something that was never granted — and a controller showing the
|
|
52
|
+
# first person an error would be both wrong and alarming.
|
|
53
|
+
if for_purpose.any? { |state| state.state == "withdrawn" }
|
|
54
|
+
raise AlreadyWithdrawnError,
|
|
55
|
+
"Consent for #{purpose_key.inspect} was already withdrawn. Nothing further " \
|
|
56
|
+
"was recorded; withdrawing twice is not an error worth showing a person."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raise NotWithdrawableError,
|
|
60
|
+
"There is no consent for #{purpose_key.inspect} to withdraw. It was never " \
|
|
61
|
+
"granted — and leaving an optional control unselected creates no grant, so " \
|
|
62
|
+
"there may be nothing here to find."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One transaction for every matching state: a person withdrawing a
|
|
66
|
+
# purpose granted under several statements must never end up half
|
|
67
|
+
# withdrawn with an error implying nothing happened.
|
|
68
|
+
events = StatementState.transaction do
|
|
69
|
+
states.map do |state|
|
|
70
|
+
transition!(state, to: "withdrawn", event_type: "withdrawal", action: "withdrawn",
|
|
71
|
+
because: because, http_request: http_request, actor: actor)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
events.length == 1 ? events.first : events
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The tenant key this state's grant was recorded under, given what the
|
|
79
|
+
# withdrawing caller can see. The state's own policy translates the
|
|
80
|
+
# caller's ambient tenant (`:not_applicable` policies always record nil);
|
|
81
|
+
# a state whose policy is no longer declared falls back to the raw value,
|
|
82
|
+
# which is the only reading its records can support.
|
|
83
|
+
def expected_tenant_key_for(state, tenant)
|
|
84
|
+
policy = begin
|
|
85
|
+
Clickwrap.policy!(state.policy_key)
|
|
86
|
+
rescue UnknownPolicyError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
return Reference.tenant(tenant) if policy.nil?
|
|
90
|
+
|
|
91
|
+
Reference.tenant(policy.tenant_from_controller(tenant))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Records a corrected factual statement. A correction does not imply the
|
|
95
|
+
# original was false when it was made — people's circumstances change, and
|
|
96
|
+
# conflating "this changed" with "this was a lie" would be both wrong and
|
|
97
|
+
# unfair to the person who declared it.
|
|
98
|
+
def correct!(statement_key, actor:, subject: nil, tenant: nil, replaces: nil,
|
|
99
|
+
acting_for: nil, because: nil, http_request: nil, submission: nil, answers: nil)
|
|
100
|
+
require_reason!(because, "Correcting a declaration or attestation")
|
|
101
|
+
state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
|
|
102
|
+
acting_for: acting_for)
|
|
103
|
+
|
|
104
|
+
unless Vocabulary.correctable?(state.kind)
|
|
105
|
+
raise LifecycleError,
|
|
106
|
+
"#{statement_key} is a #{state.kind}, which is not corrected. An agreement is " \
|
|
107
|
+
"superseded by a new version; consent is withdrawn and granted again."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
origin = lifecycle_origin!(state, replaces)
|
|
111
|
+
policy = Clickwrap.policy!(state.policy_key)
|
|
112
|
+
|
|
113
|
+
Capture.new(
|
|
114
|
+
policy: policy,
|
|
115
|
+
actor: actor,
|
|
116
|
+
subject: subject,
|
|
117
|
+
tenant: tenant,
|
|
118
|
+
acting_for: acting_for,
|
|
119
|
+
http_request: http_request,
|
|
120
|
+
submission: submission,
|
|
121
|
+
answers: answers,
|
|
122
|
+
reason: because,
|
|
123
|
+
event_type: "correction",
|
|
124
|
+
root_event_id: state.root_event_id,
|
|
125
|
+
predecessor_event_id: origin.id,
|
|
126
|
+
statement_action_overrides: { statement_key.to_s => "corrected" }
|
|
127
|
+
).capture!
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A renewal always starts a new validity period rather than extending the
|
|
131
|
+
# old one, so a stale expiry can never quietly survive a renewal.
|
|
132
|
+
def renew!(statement_key, actor:, subject: nil, tenant: nil, because: nil,
|
|
133
|
+
acting_for: nil, http_request: nil, submission: nil, answers: nil)
|
|
134
|
+
require_reason!(because, "Renewing a statement")
|
|
135
|
+
state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
|
|
136
|
+
acting_for: acting_for)
|
|
137
|
+
policy = Clickwrap.policy!(state.policy_key)
|
|
138
|
+
statement = policy.statement!(statement_key)
|
|
139
|
+
|
|
140
|
+
unless statement.expirable? && statement.valid_for.present?
|
|
141
|
+
raise LifecycleError,
|
|
142
|
+
"#{statement_key} is a #{statement.kind} with no validity period, so it cannot be renewed."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
action = statement.kind == "consent" ? "renewed" : statement.initial_action
|
|
146
|
+
Capture.new(
|
|
147
|
+
policy: policy,
|
|
148
|
+
actor: actor,
|
|
149
|
+
subject: subject,
|
|
150
|
+
tenant: tenant,
|
|
151
|
+
acting_for: acting_for,
|
|
152
|
+
http_request: http_request,
|
|
153
|
+
submission: submission,
|
|
154
|
+
answers: answers,
|
|
155
|
+
reason: because,
|
|
156
|
+
event_type: "renewal",
|
|
157
|
+
root_event_id: state.root_event_id,
|
|
158
|
+
predecessor_event_id: state.current_event_id,
|
|
159
|
+
statement_action_overrides: { statement_key.to_s => action }
|
|
160
|
+
).capture!
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def change_consent_scope!(statement_key, actor:, because:, subject: nil, tenant: nil,
|
|
164
|
+
acting_for: nil, http_request: nil, submission: nil, answers: nil)
|
|
165
|
+
require_reason!(because, "Changing consent scope")
|
|
166
|
+
state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
|
|
167
|
+
acting_for: acting_for)
|
|
168
|
+
|
|
169
|
+
unless state.kind == "consent"
|
|
170
|
+
raise LifecycleError, "Only consent has a changeable scope; #{statement_key} is #{state.kind}."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Capture.new(
|
|
174
|
+
policy: Clickwrap.policy!(state.policy_key), actor: actor, subject: subject, tenant: tenant,
|
|
175
|
+
http_request: http_request, submission: submission, answers: answers, reason: because,
|
|
176
|
+
acting_for: acting_for,
|
|
177
|
+
event_type: "scope_change", root_event_id: state.root_event_id,
|
|
178
|
+
predecessor_event_id: state.current_event_id,
|
|
179
|
+
statement_action_overrides: { statement_key.to_s => "scope_changed" }
|
|
180
|
+
).capture!
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def revoke!(statement_key, actor:, because:, subject: nil, tenant: nil,
|
|
184
|
+
acting_for: nil, http_request: nil)
|
|
185
|
+
require_reason!(because, "Revoking an authorization")
|
|
186
|
+
|
|
187
|
+
state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
|
|
188
|
+
acting_for: acting_for)
|
|
189
|
+
|
|
190
|
+
transition!(state, to: "revoked", event_type: "revocation", action: "revoked",
|
|
191
|
+
because: because, http_request: http_request, actor: actor)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def supersede!(statement_key, actor:, subject: nil, tenant: nil, acting_for: nil,
|
|
195
|
+
because: nil, http_request: nil)
|
|
196
|
+
state = find_state!(statement_key, actor: actor, subject: subject, tenant: tenant,
|
|
197
|
+
acting_for: acting_for)
|
|
198
|
+
|
|
199
|
+
transition!(state, to: "superseded", event_type: "supersession", action: "superseded",
|
|
200
|
+
because: because, http_request: http_request, actor: actor)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Consumes a one-time authorization. Called inside the transaction that
|
|
204
|
+
# performs the protected action, after the row lock the capture took, so
|
|
205
|
+
# two concurrent attempts cannot both spend the same authorization.
|
|
206
|
+
def consume_authorization!(event:, because: nil)
|
|
207
|
+
source_event = Event.includes(:statements).find(event.id)
|
|
208
|
+
authorizations = source_event.statements.select(&:one_time?)
|
|
209
|
+
|
|
210
|
+
authorizations.map do |authorization|
|
|
211
|
+
::ActiveRecord::Base.transaction do
|
|
212
|
+
identity = StatementState.identity_for(
|
|
213
|
+
policy_key: source_event.policy_key,
|
|
214
|
+
statement_key: authorization.statement_key,
|
|
215
|
+
actor_reference: source_event.actor_reference,
|
|
216
|
+
tenant_key: source_event.tenant_key,
|
|
217
|
+
subject_key: source_event.subject_key,
|
|
218
|
+
represented_party_reference: source_event.represented_party_reference
|
|
219
|
+
)
|
|
220
|
+
StatementIdentityLock.acquire_for_actor!(source_event.actor_reference)
|
|
221
|
+
StatementIdentityLock.acquire!(identity.fetch(:identity_digest))
|
|
222
|
+
state = StatementState.lock.find_by(
|
|
223
|
+
identity
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if authorization_was_consumed?(source_event, authorization.statement_key)
|
|
227
|
+
raise AlreadyConsumedError,
|
|
228
|
+
"Authorization #{authorization.statement_key} from event #{source_event.id} " \
|
|
229
|
+
"was already consumed. A one-time authorization cannot be spent twice."
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
consumed_at = Clickwrap.now
|
|
233
|
+
consumption = append_lifecycle_event!(
|
|
234
|
+
event: source_event,
|
|
235
|
+
event_type: "consumption",
|
|
236
|
+
reason: because.presence || "The one-time authorization was consumed",
|
|
237
|
+
actor: nil
|
|
238
|
+
) do |appended|
|
|
239
|
+
appended.statements.create!(
|
|
240
|
+
ordinal: 0,
|
|
241
|
+
statement_key: authorization.statement_key,
|
|
242
|
+
kind: authorization.kind,
|
|
243
|
+
action: "consumed",
|
|
244
|
+
assertion_text: "The authorization #{authorization.statement_key} was consumed.",
|
|
245
|
+
assertion_locale: "en",
|
|
246
|
+
required: false,
|
|
247
|
+
optional: false,
|
|
248
|
+
answered: false,
|
|
249
|
+
purpose_key: authorization.purpose_key,
|
|
250
|
+
valid_from: consumed_at,
|
|
251
|
+
created_at: consumed_at
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A later authorization for the same actor/subject may already be
|
|
256
|
+
# current while an earlier provider result is being reconciled.
|
|
257
|
+
# Record consumption of the earlier authorization without spending
|
|
258
|
+
# or deactivating the later one.
|
|
259
|
+
if state && state.root_event_id.to_s == source_event.id.to_s && state.state == "active"
|
|
260
|
+
CurrentState.transition!(state, to: "consumed", event: consumption, at: consumed_at)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
consumption
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Expires everything past its validity. Reporting and tidiness only:
|
|
269
|
+
# verification evaluates expiry live against the clock, so evidence never
|
|
270
|
+
# becomes wrongly valid because a job did not run — which is also why one
|
|
271
|
+
# contended row (a person withdrawing mid-sweep) skips instead of
|
|
272
|
+
# aborting the whole batch and leaving every later state untouched.
|
|
273
|
+
def expire_due!(at: Clickwrap.now)
|
|
274
|
+
expired = []
|
|
275
|
+
StatementState.due_for_expiry(at).find_each do |state|
|
|
276
|
+
expired << transition!(state, to: "expired", event_type: "expiry", action: "expired",
|
|
277
|
+
because: "The validity period recorded at capture ended",
|
|
278
|
+
actor: nil, at: at)
|
|
279
|
+
rescue LifecycleError, ::ActiveRecord::ActiveRecordError
|
|
280
|
+
# This row moved under the sweep (withdrawn, consumed, or locked by a
|
|
281
|
+
# live transition). The next sweep — or live verification — owns it.
|
|
282
|
+
next
|
|
283
|
+
end
|
|
284
|
+
expired
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# An explicitly recorded system exemption.
|
|
288
|
+
#
|
|
289
|
+
# Seeds, imports, invitations, admin actions, and service accounts must
|
|
290
|
+
# never "accept" by omitting a browser parameter or by fabricating a human
|
|
291
|
+
# click. An exemption says plainly that no human action occurred, records
|
|
292
|
+
# who created it and why, and never satisfies `agreed_to?` — it answers
|
|
293
|
+
# the separate `exempted_from?` question. There is no "missing checkbox
|
|
294
|
+
# means system account" inference anywhere in this gem.
|
|
295
|
+
def exempt!(policy_key, actor:, because:, subject: nil, tenant: nil)
|
|
296
|
+
require_reason!(because, "Recording an exemption")
|
|
297
|
+
|
|
298
|
+
policy = Clickwrap.policy!(policy_key.to_s)
|
|
299
|
+
|
|
300
|
+
unless policy.permits_exemptions?
|
|
301
|
+
raise LifecycleError,
|
|
302
|
+
"Policy #{policy.key} does not permit exemptions. If system-created records " \
|
|
303
|
+
"legitimately bypass it, say so in the policy with `permit_exemptions`, so the " \
|
|
304
|
+
"decision is visible in review rather than implied by a call site."
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
now = Clickwrap.now
|
|
308
|
+
revision = PolicyRevision.freeze_for(policy)
|
|
309
|
+
|
|
310
|
+
::ActiveRecord::Base.transaction do
|
|
311
|
+
# Actor lock BEFORE the event insert: creating the event reserves the
|
|
312
|
+
# chain head, and every writer must take these two locks in the same
|
|
313
|
+
# order (actor first, chain head second — the order capture uses) or
|
|
314
|
+
# two concurrent paths deadlock against each other.
|
|
315
|
+
StatementIdentityLock.acquire_for_actor!(reference_for(actor))
|
|
316
|
+
|
|
317
|
+
event = Event.create!(
|
|
318
|
+
event_type: "exemption",
|
|
319
|
+
policy_key: policy.key,
|
|
320
|
+
policy_revision: revision,
|
|
321
|
+
actor: actor.is_a?(::ActiveRecord::Base) ? actor : nil,
|
|
322
|
+
actor_reference: reference_for(actor),
|
|
323
|
+
# Reference.tenant, never to_s: an Active Record tenant's to_s is a
|
|
324
|
+
# per-process memory address, which would make the exemption
|
|
325
|
+
# permanently unfindable (and no two exemptions equal).
|
|
326
|
+
tenant_key: Reference.tenant(tenant),
|
|
327
|
+
subject: subject.is_a?(::ActiveRecord::Base) ? subject : nil,
|
|
328
|
+
subject_key: StatementState.subject_key_for(subject),
|
|
329
|
+
capture_channel: "system",
|
|
330
|
+
attribution_method: "system_process",
|
|
331
|
+
recorded_at_by_server: now,
|
|
332
|
+
reason: because,
|
|
333
|
+
retention_class_key: policy.retention_class_key,
|
|
334
|
+
canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
335
|
+
gem_version: Clickwrap::VERSION,
|
|
336
|
+
created_at: now
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
policy.statements.each_with_index do |statement, index|
|
|
340
|
+
event.statements.create!(
|
|
341
|
+
ordinal: index,
|
|
342
|
+
statement_key: statement.key,
|
|
343
|
+
kind: statement.kind,
|
|
344
|
+
action: statement.initial_action,
|
|
345
|
+
assertion_text: "Exempted: no human action was recorded for this statement.",
|
|
346
|
+
assertion_locale: "en",
|
|
347
|
+
required: statement.required?,
|
|
348
|
+
optional: statement.optional?,
|
|
349
|
+
answered: false,
|
|
350
|
+
purpose_key: statement.purpose_key,
|
|
351
|
+
valid_from: now,
|
|
352
|
+
created_at: now
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
event.finalize_integrity!
|
|
357
|
+
CurrentState.apply!(event.reload)
|
|
358
|
+
event
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Appends a linked lifecycle event without touching the projection. Used
|
|
363
|
+
# by holds and dispositions, which record that something happened to the
|
|
364
|
+
# evidence rather than changing what the evidence says.
|
|
365
|
+
def append_lifecycle_event!(event:, event_type:, reason:, actor: nil, extra: {}, &block)
|
|
366
|
+
now = Clickwrap.now
|
|
367
|
+
lifecycle_actor = actor || SystemActor.new("clickwrap_lifecycle")
|
|
368
|
+
human_operator = actor.present? && !actor.is_a?(SystemActor)
|
|
369
|
+
|
|
370
|
+
Event.transaction do
|
|
371
|
+
# Actor lock BEFORE the event insert (which reserves the chain head):
|
|
372
|
+
# every evidence writer takes these two locks actor-first, the order
|
|
373
|
+
# capture uses, so no two paths can deadlock against each other.
|
|
374
|
+
StatementIdentityLock.acquire_for_actor!(event.actor_reference)
|
|
375
|
+
|
|
376
|
+
appended = Event.create!(
|
|
377
|
+
{
|
|
378
|
+
event_type: event_type,
|
|
379
|
+
policy_key: event.policy_key,
|
|
380
|
+
policy_revision_id: event.policy_revision_id,
|
|
381
|
+
root_event_id: event.root_event_id || event.id,
|
|
382
|
+
predecessor_event_id: event.id,
|
|
383
|
+
actor: lifecycle_actor.is_a?(::ActiveRecord::Base) ? lifecycle_actor : nil,
|
|
384
|
+
actor_reference: reference_for(lifecycle_actor),
|
|
385
|
+
tenant_key: event.tenant_key,
|
|
386
|
+
subject_key: event.subject_key,
|
|
387
|
+
capture_channel: "system",
|
|
388
|
+
attribution_method: human_operator ? "operator_session" : "system_process",
|
|
389
|
+
recorded_at_by_server: now,
|
|
390
|
+
reason: reason,
|
|
391
|
+
retention_class_key: event.retention_class_key,
|
|
392
|
+
canonical_schema_version: Clickwrap::CANONICAL_SCHEMA_VERSION,
|
|
393
|
+
gem_version: Clickwrap::VERSION,
|
|
394
|
+
created_at: now
|
|
395
|
+
}.merge(extra)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
block&.call(appended)
|
|
399
|
+
appended.finalize_integrity!
|
|
400
|
+
appended
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
private
|
|
405
|
+
|
|
406
|
+
def transition!(state, to:, event_type:, action:, because:, actor:, http_request: nil,
|
|
407
|
+
predecessor: nil, at: nil)
|
|
408
|
+
at ||= Clickwrap.now
|
|
409
|
+
original_current_event_id = state.current_event_id
|
|
410
|
+
|
|
411
|
+
::ActiveRecord::Base.transaction do
|
|
412
|
+
StatementIdentityLock.acquire_for_actor!(state.actor_reference)
|
|
413
|
+
StatementIdentityLock.acquire!(state.identity_digest)
|
|
414
|
+
state = StatementState.lock.find(state.id)
|
|
415
|
+
|
|
416
|
+
if state.current_event_id.to_s != original_current_event_id.to_s
|
|
417
|
+
if event_type == "withdrawal" && state.state == "withdrawn"
|
|
418
|
+
raise AlreadyWithdrawnError,
|
|
419
|
+
"Consent for #{state.purpose_key.inspect} was already withdrawn. Nothing " \
|
|
420
|
+
"further was recorded."
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
raise LifecycleError,
|
|
424
|
+
"#{state.statement_key} changed while this lifecycle action was waiting. " \
|
|
425
|
+
"Reload its current evidence and try the intended transition again."
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
origin = Event.find_by(id: predecessor_id(predecessor) || state.current_event_id)
|
|
429
|
+
event = append_lifecycle_event!(
|
|
430
|
+
event: origin,
|
|
431
|
+
event_type: event_type,
|
|
432
|
+
reason: because,
|
|
433
|
+
actor: actor,
|
|
434
|
+
extra: {
|
|
435
|
+
http_request_id: http_request.respond_to?(:request_id) ? http_request.request_id : nil
|
|
436
|
+
}.compact
|
|
437
|
+
) do |appended|
|
|
438
|
+
appended.statements.create!(
|
|
439
|
+
ordinal: 0,
|
|
440
|
+
statement_key: state.statement_key,
|
|
441
|
+
kind: state.kind,
|
|
442
|
+
action: action,
|
|
443
|
+
assertion_text: lifecycle_assertion(action, state),
|
|
444
|
+
assertion_locale: "en",
|
|
445
|
+
required: false,
|
|
446
|
+
optional: false,
|
|
447
|
+
answered: false,
|
|
448
|
+
purpose_key: state.purpose_key,
|
|
449
|
+
valid_from: at,
|
|
450
|
+
created_at: at
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
CurrentState.transition!(state, to: to, event: event, at: at)
|
|
455
|
+
event
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def lifecycle_assertion(action, state)
|
|
460
|
+
case action
|
|
461
|
+
when "withdrawn" then "Consent for #{state.purpose_key} was withdrawn."
|
|
462
|
+
when "corrected" then "The declaration #{state.statement_key} was corrected."
|
|
463
|
+
when "revoked" then "The authorization #{state.statement_key} was revoked."
|
|
464
|
+
when "consumed" then "The authorization #{state.statement_key} was consumed."
|
|
465
|
+
when "expired" then "The validity period for #{state.statement_key} ended."
|
|
466
|
+
when "superseded" then "#{state.statement_key} was superseded."
|
|
467
|
+
else "#{state.statement_key} became #{action}."
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def authorization_was_consumed?(event, statement_key)
|
|
472
|
+
Event.where(root_event_id: event.id, event_type: "consumption")
|
|
473
|
+
.joins(:statements)
|
|
474
|
+
.where(clickwrap_event_statements: { statement_key: statement_key, action: "consumed" })
|
|
475
|
+
.exists?
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def predecessor_id(replaces)
|
|
479
|
+
return nil if replaces.nil?
|
|
480
|
+
return replaces if replaces.is_a?(String)
|
|
481
|
+
return replaces.event_id if replaces.respond_to?(:event_id)
|
|
482
|
+
|
|
483
|
+
replaces.id
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def lifecycle_origin!(state, replaces)
|
|
487
|
+
id = predecessor_id(replaces) || state.current_event_id
|
|
488
|
+
origin = Event.find_by(id: id)
|
|
489
|
+
|
|
490
|
+
unless origin && origin.policy_key == state.policy_key &&
|
|
491
|
+
origin.actor_reference == state.actor_reference &&
|
|
492
|
+
origin.tenant_key.to_s == state.tenant_key.to_s &&
|
|
493
|
+
origin.subject_key.to_s == state.subject_key.to_s &&
|
|
494
|
+
origin.represented_party_reference.to_s == state.represented_party_reference.to_s &&
|
|
495
|
+
origin.statement(state.statement_key)
|
|
496
|
+
raise LifecycleError,
|
|
497
|
+
"The event named by `replaces:` is not the current statement for this actor, tenant, and subject."
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
origin
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def find_state!(statement_key, actor:, subject:, tenant:, acting_for:)
|
|
504
|
+
state = StatementState
|
|
505
|
+
.for_actor(reference_for(actor))
|
|
506
|
+
.for_statement(statement_key)
|
|
507
|
+
.where(subject_key: Reference.subject(subject),
|
|
508
|
+
tenant_key: Reference.tenant(tenant),
|
|
509
|
+
represented_party_reference: Reference.represented_party(acting_for))
|
|
510
|
+
.first
|
|
511
|
+
|
|
512
|
+
return state if state
|
|
513
|
+
|
|
514
|
+
raise UnknownStatementError,
|
|
515
|
+
"There is no recorded #{statement_key.inspect} for this actor and subject to act on."
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def reference_for(actor)
|
|
519
|
+
return nil if actor.nil?
|
|
520
|
+
return actor if actor.is_a?(String)
|
|
521
|
+
|
|
522
|
+
Reference.actor(actor)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def require_reason!(because, what)
|
|
526
|
+
return unless because.to_s.strip.empty?
|
|
527
|
+
|
|
528
|
+
raise LifecycleError,
|
|
529
|
+
"#{what} needs a `because:` in plain English. It is stored on the event, and it is " \
|
|
530
|
+
"the only thing that will explain this to someone reading the record later."
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|