Class: Clickwrap::Event
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Clickwrap::Event
- Defined in:
- lib/clickwrap/models/event.rb
Overview
One digest-bound evidence event with fixed, named post-write transitions.
A capture, a withdrawal, a correction, an expiry, a consumption, a
disposition, a hold — each is a row here, linked to what it acts on. The
ordinary update and destroy calls are refused. Finalization, pointer
nullification, request-evidence linking, legal-hold state, and reviewed core
disposition use explicit fixed write sets; lifecycle and disposition facts
append linked events explaining what happened and why. Optional PostgreSQL
hardening enforces those same sets below the model layer.
What an event proves is bounded and stated in the receipt: the server generated and accepted a particular presentation, an explicit action was submitted against it, and it committed together with whatever domain action it protected. It does not prove that a person read anything, understood anything, saw particular pixels, or that a party controlling both the application and the database could not have written the row.
Constant Summary collapse
- FINALIZATION_COLUMNS =
Named write sets used by the opt-in PostgreSQL hardening migration. They document every post-INSERT path the gem itself needs, so the migration can refuse to install if its trigger policy ever drifts from runtime code.
%w[ chain_scope chain_sequence previous_event_digest event_digest digest_algorithm canonical_schema_version ].freeze
- POINTER_NULLIFICATION_COLUMNS =
%w[ actor_type actor_id represented_party_type represented_party_id subject_type subject_id ].freeze
- DISPOSITION_COLUMNS =
%w[ actor_type actor_id actor_reference actor_snapshot represented_party_type represented_party_id represented_party_reference authority_source authority_role authority_verified_at authority_details tenant_key subject_type subject_id subject_key subject_fingerprint authentication_method authentication_context idempotency_key http_request_id http_route_name presentation_id presentation_manifest presentation_manifest_digest protected_outcome provider_receipt provider_verification reason core_event_disposed_at core_event_disposition_event_id ].freeze
- MODEL_CALLBACK_MUTABLE_COLUMNS =
%w[ core_event_disposed_at core_event_disposition_event_id on_legal_hold request_evidence_id ].freeze
- MUTABLE_COLUMNS =
MODEL_CALLBACK_MUTABLE_COLUMNS- DATABASE_HARDENING_WRITE_SETS =
{ "finalization" => FINALIZATION_COLUMNS, "pointer_nullification" => POINTER_NULLIFICATION_COLUMNS, "disposition" => DISPOSITION_COLUMNS, "legal_hold" => %w[on_legal_hold].freeze, "request_evidence_link" => %w[request_evidence_id].freeze }.transform_values { |columns| columns.map(&:to_s).sort.freeze }.freeze
Instance Method Summary collapse
-
#attach_request_evidence!(record) ⇒ Object
Only the foreign key.
- #authorization_was_consumed? ⇒ Boolean
-
#canonical_body ⇒ Object
The canonical body this event's digest covers.
- #capture? ⇒ Boolean
- #compiled_policy_snapshot ⇒ Object
- #compute_digest ⇒ Object
- #consent_was_granted? ⇒ Boolean
-
#consent_was_withdrawn? ⇒ Boolean
Questions an
after_event_is_committedhook actually asks. - #declaration_was_corrected? ⇒ Boolean
- #digest_integrity_accounted_for? ⇒ Boolean
-
#digest_integrity_status ⇒ Object
A disposed core payload cannot be recomputed: the point of disposition is that those bytes are gone.
-
#digest_verified? ⇒ Boolean
Recomputes the digest and compares it with the one recorded at write time.
-
#dispose_core_payload!(disposition_event:, at: Clickwrap.now) ⇒ Object
Marks the core event as disposed of under a retention rule.
- #disposed? ⇒ Boolean
- #documented_core_disposition? ⇒ Boolean
- #evidence_integrity_verified? ⇒ Boolean
- #evidence_was_disposed? ⇒ Boolean
- #exemption? ⇒ Boolean
- #external_action ⇒ Object
-
#finalize_durable_commit! ⇒ Object
Called only once durable commit is no longer capable of being rolled back.
-
#finalize_integrity! ⇒ Object
Finalization is deliberately explicit and happens only after every child statement/document, protected outcome, registration binding, retention decision, and chain position exists.
- #held? ⇒ Boolean
- #human_action? ⇒ Boolean
- #imported? ⇒ Boolean
- #integrity_attestations ⇒ Object
- #invalidate_pending_receipts_after_rollback! ⇒ Object
- #legal_holds ⇒ Object
- #policy ⇒ Object
-
#presentation ⇒ Object
--- Associations whose tables are optional -------------------------------.
-
#purpose_keys ⇒ Object
The purposes this event affected, for a hook that needs to know which processing to stop.
- #receipt ⇒ Object
- #request_evidence ⇒ Object
-
#request_evidence_binding_status ⇒ Object
The optional annex has its own keyed binding.
- #set_legal_hold!(held) ⇒ Object
- #statement(statement_key) ⇒ Object
- #to_s ⇒ Object
- #track_pending_receipt(pending_receipt) ⇒ Object
Instance Method Details
#attach_request_evidence!(record) ⇒ Object
Only the foreign key. The binding digest was written when the event was
created, because it is part of the canonical body the event digest covers;
setting it afterwards would leave every event with request evidence
failing its own verification. request_evidence_id is safe to set here
precisely because it is NOT in the canonical body — it is a pointer, not
a fact about what was recorded.
527 528 529 |
# File 'lib/clickwrap/models/event.rb', line 527 def attach_request_evidence!(record) update_columns(request_evidence_id: record.id) end |
#authorization_was_consumed? ⇒ Boolean
241 |
# File 'lib/clickwrap/models/event.rb', line 241 def = event_type == "consumption" |
#canonical_body ⇒ Object
The canonical body this event's digest covers. It deliberately excludes the mutable columns: whether a legal hold is currently in effect, and whether the optional annex has since been disposed of, are facts about today, not about what was recorded. Including them would make an ordinary retention run look like tampering.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/clickwrap/models/event.rb', line 270 def canonical_body { "schema" => canonical_schema_version, "event_id" => id, "event_type" => event_type, "policy" => { "key" => policy_key, "revision" => policy_revision&.revision_digest }.compact, "actor" => canonical_actor, "tenant" => tenant_key, "subject" => canonical_subject, "capture_channel" => capture_channel, "authentication_method" => authentication_method, "authentication_context" => authentication_context.presence, "recorded_at_by_server" => Receipt.format_time(recorded_at_by_server), "occurred_at" => Receipt.format_time(occurred_at), "idempotency_key" => idempotency_key, "http_request_id" => http_request_id, "http_route_name" => http_route_name, "acts" => statements.map(&:canonical_fragment), "documents" => documents.map(&:canonical_fragment), "presentation" => canonical_presentation, "protected_outcome" => protected_outcome.presence, "provider" => canonical_provider, "request_evidence" => canonical_request_evidence_binding, "predecessor_event_id" => predecessor_event_id, "root_event_id" => root_event_id, "reason" => reason, "retention" => { "class" => retention_class_key, "retain_core_event_until" => Receipt.format_time(retain_core_event_until), "rule" => retention_rule_name }.compact.presence, "chain" => { "scope" => chain_scope, "sequence" => chain_sequence, "previous_event_digest" => previous_event_digest }.compact.presence, "gem_version" => gem_version, "application_version" => application_version, "template_version" => template_version, "created_at" => Receipt.format_time(created_at) }.compact end |
#capture? ⇒ Boolean
248 |
# File 'lib/clickwrap/models/event.rb', line 248 def capture? = event_type == "capture" |
#compiled_policy_snapshot ⇒ Object
261 |
# File 'lib/clickwrap/models/event.rb', line 261 def compiled_policy_snapshot = policy_revision&.compiled_snapshot |
#compute_digest ⇒ Object
313 314 315 |
# File 'lib/clickwrap/models/event.rb', line 313 def compute_digest Digest.digest_canonical(canonical_body, algorithm: digest_algorithm || "sha256") end |
#consent_was_granted? ⇒ Boolean
239 |
# File 'lib/clickwrap/models/event.rb', line 239 def = capture? && statements.any? { |s| s.kind == "consent" && s.answered? } |
#consent_was_withdrawn? ⇒ Boolean
Questions an after_event_is_committed hook actually asks. A host wiring
up "stop processing when someone withdraws" should not have to know that
the answer is a string comparison against an event-type vocabulary.
238 |
# File 'lib/clickwrap/models/event.rb', line 238 def = event_type == "withdrawal" |
#declaration_was_corrected? ⇒ Boolean
240 |
# File 'lib/clickwrap/models/event.rb', line 240 def declaration_was_corrected? = event_type == "correction" |
#digest_integrity_accounted_for? ⇒ Boolean
342 343 344 |
# File 'lib/clickwrap/models/event.rb', line 342 def digest_integrity_accounted_for? digest_integrity_status != :unaccounted_mismatch end |
#digest_integrity_status ⇒ Object
A disposed core payload cannot be recomputed: the point of disposition is that those bytes are gone. Keep that state separate from a verifying digest. A valid, digest-bound disposition event can account for the mismatch, while an unexplained marker or altered row remains a failure.
330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/clickwrap/models/event.rb', line 330 def digest_integrity_status if disposed? return :documented_core_disposition if documented_core_disposition? return :unaccounted_mismatch end return :verified if digest_verified? :unaccounted_mismatch end |
#digest_verified? ⇒ Boolean
Recomputes the digest and compares it with the one recorded at write time. A false here means the row's meaningful bytes changed since it was written; it does not, on its own, say who changed them or when.
320 321 322 323 324 |
# File 'lib/clickwrap/models/event.rb', line 320 def digest_verified? return false if event_digest.blank? Digest.secure_compare?(compute_digest, event_digest) end |
#dispose_core_payload!(disposition_event:, at: Clickwrap.now) ⇒ Object
Marks the core event as disposed of under a retention rule. The row stays: what disappears is the payload, and the disposition is itself recorded as a linked event, so an auditor sees a documented deletion rather than a gap.
451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 |
# File 'lib/clickwrap/models/event.rb', line 451 def dispose_core_payload!(disposition_event:, at: Clickwrap.now) transaction do # Mark and link the documented disposition first. The PostgreSQL # hardening tier permits child DELETEs only while their parent carries # this marker. If any later deletion fails, this transaction rolls the # marker back as well, so callers can never observe a half-disposed row. update_columns( actor_type: nil, actor_id: nil, actor_reference: "", actor_snapshot: {}, represented_party_type: nil, represented_party_id: nil, represented_party_reference: "", authority_source: nil, authority_role: nil, authority_verified_at: nil, authority_details: {}, tenant_key: "", subject_type: nil, subject_id: nil, subject_key: "", subject_fingerprint: nil, authentication_method: nil, authentication_context: {}, idempotency_key: nil, http_request_id: nil, http_route_name: nil, presentation_id: nil, presentation_manifest: nil, presentation_manifest_digest: nil, protected_outcome: nil, provider_receipt: nil, provider_verification: nil, reason: nil, core_event_disposed_at: at, core_event_disposition_event_id: disposition_event.id ) # Calling `delete_all` through a `has_many` association asks Active # Record to null the foreign key. These evidence children have an # intentionally non-null foreign key, so issue real, scoped DELETEs. EventStatement.where(event_id: id).delete_all EventDocument.where(event_id: id).delete_all # A root and its later lifecycle events are independently retained # evidence payloads. Disposing the root must not erase a projection that # now points at a still-retained correction, renewal, withdrawal, or # other successor. Remove only projections for which this exact event is # current; a later successor removes its own projection when its own # schedule becomes due. StatementState.where(current_event_id: id).delete_all end self end |
#disposed? ⇒ Boolean
252 |
# File 'lib/clickwrap/models/event.rb', line 252 def disposed? = core_event_disposed_at.present? |
#documented_core_disposition? ⇒ Boolean
506 507 508 509 510 511 512 513 514 515 |
# File 'lib/clickwrap/models/event.rb', line 506 def documented_core_disposition? return false unless disposed? && core_event_disposition_event_id.present? disposition = Event.find_by(id: core_event_disposition_event_id) facts = disposition&.protected_outcome.to_h["core_event_disposition"].to_h disposition_event_links_to_self?(disposition) && disposition.digest_verified? && facts["event_id"] == id && facts["original_event_digest"] == event_digest && facts["disposed_at"] == Receipt.format_time(core_event_disposed_at) end |
#evidence_integrity_verified? ⇒ Boolean
388 389 390 391 392 |
# File 'lib/clickwrap/models/event.rb', line 388 def evidence_integrity_verified? digest_verified? && %i[ not_recorded verified disposed_with_documented_events ].include?(request_evidence_binding_status) end |
#evidence_was_disposed? ⇒ Boolean
242 |
# File 'lib/clickwrap/models/event.rb', line 242 def evidence_was_disposed? = event_type == "disposition" |
#exemption? ⇒ Boolean
250 |
# File 'lib/clickwrap/models/event.rb', line 250 def exemption? = event_type == "exemption" |
#external_action ⇒ Object
159 160 161 |
# File 'lib/clickwrap/models/event.rb', line 159 def external_action super if SchemaRequirements.available?(:external_actions) end |
#finalize_durable_commit! ⇒ Object
Called only once durable commit is no longer capable of being rolled back. Public because DurableCommitCallback invokes it through Active Record's transaction-record protocol; it is not host application API.
434 435 436 437 438 439 |
# File 'lib/clickwrap/models/event.rb', line 434 def finalize_durable_commit! commit_pending_receipts run_after_commit_hook run_integrity_attestors self end |
#finalize_integrity! ⇒ Object
Finalization is deliberately explicit and happens only after every child
statement/document, protected outcome, registration binding, retention
decision, and chain position exists. Computing this in before_create
produced digests for a half-built event and made normal successful
captures fail their own integrity check.
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 |
# File 'lib/clickwrap/models/event.rb', line 399 def finalize_integrity! raise EventWriteFailed, "Event #{id} has not been persisted, so it cannot be finalized." unless persisted? if event_digest.present? raise EventWriteFailed, "Event #{id} was already finalized and cannot be finalized twice." end # Normally assigned by the before-create callback so chain contention is # the first database lock this event takes. Keep this idempotent call as a # defensive invariant for a host that deliberately bypassed callbacks # while constructing an internal event. assign_chain_position! self.event_digest = compute_digest update_columns( chain_scope: chain_scope, chain_sequence: chain_sequence, previous_event_digest: previous_event_digest, event_digest: event_digest, digest_algorithm: digest_algorithm, canonical_schema_version: canonical_schema_version ) ChainHead.record!(chain_scope: chain_scope, event_id: id, event_digest: event_digest) if chain_scope.present? self end |
#held? ⇒ Boolean
253 |
# File 'lib/clickwrap/models/event.rb', line 253 def held? = on_legal_hold? |
#human_action? ⇒ Boolean
251 |
# File 'lib/clickwrap/models/event.rb', line 251 def human_action? = Vocabulary.human_action_event_type?(event_type) |
#imported? ⇒ Boolean
249 |
# File 'lib/clickwrap/models/event.rb', line 249 def imported? = %w[imported_legacy external_receipt].include?(event_type) |
#integrity_attestations ⇒ Object
147 148 149 150 151 |
# File 'lib/clickwrap/models/event.rb', line 147 def integrity_attestations return IntegrityAttestation.none unless SchemaRequirements.available?(:integrity) super end |
#invalidate_pending_receipts_after_rollback! ⇒ Object
441 442 443 444 |
# File 'lib/clickwrap/models/event.rb', line 441 def invalidate_pending_receipts_after_rollback! Array(@pending_receipts).each(&:mark_rolled_back!) self end |
#legal_holds ⇒ Object
153 154 155 156 157 |
# File 'lib/clickwrap/models/event.rb', line 153 def legal_holds return LegalHold.none unless SchemaRequirements.available?(:retention_ops) super end |
#policy ⇒ Object
259 |
# File 'lib/clickwrap/models/event.rb', line 259 def policy = Clickwrap.policies[policy_key] |
#presentation ⇒ Object
--- Associations whose tables are optional -------------------------------
clickwrap:install emits only the tables an installation can put a row
in; the rest arrive with their own flag. Reading one of these associations
on an installation that never created the table would read the schema of
a table that deliberately does not exist — so each reader answers "there
is nothing here", which is not a fallback but the exact truth: without the
table, no row was ever written.
Guarded here rather than at each call site, because the call sites are everywhere and the answer is a property of this record.
139 140 141 |
# File 'lib/clickwrap/models/event.rb', line 139 def presentation super if SchemaRequirements.available?(:persisted_presentations) end |
#purpose_keys ⇒ Object
The purposes this event affected, for a hook that needs to know which processing to stop.
246 |
# File 'lib/clickwrap/models/event.rb', line 246 def purpose_keys = statements.filter_map(&:purpose_key).uniq |
#receipt ⇒ Object
263 |
# File 'lib/clickwrap/models/event.rb', line 263 def receipt = Receipt.new(self) |
#request_evidence ⇒ Object
143 144 145 |
# File 'lib/clickwrap/models/event.rb', line 143 def request_evidence super if SchemaRequirements.available?(:request_evidence) end |
#request_evidence_binding_status ⇒ Object
The optional annex has its own keyed binding. A permitted category disposition makes the original HMAC no longer recomputable; that state is accepted only when every deletion timestamp has a valid linked disposition event naming the category.
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/clickwrap/models/event.rb', line 350 def request_evidence_binding_status annex = request_evidence digests = request_evidence_category_binding_digests.to_h has_binding = digests.present? || request_evidence_key_id.present? || request_evidence_digest_algorithm.present? return :not_recorded if annex.nil? && !has_binding return :missing_annex_or_binding if annex.nil? || !has_binding return :missing_annex_or_binding unless digests.keys.sort == RequestEvidence::CATEGORIES.map(&:to_s).sort return :binding_key_unavailable unless annex.binding_key_available?(request_evidence_key_id) disposed = false RequestEvidence::CATEGORIES.each do |category| if annex.deleted_for?(category) disposed = true return :undocumented_disposition unless request_evidence_disposition_documented?(annex, category) next end verified = annex.category_binding_digest_verified?( category: category, digest: digests[category.to_s], algorithm: request_evidence_digest_algorithm, key_id: request_evidence_key_id ) return :digest_mismatch unless verified end disposed ? :disposed_with_documented_events : :verified rescue ::ActiveRecord::Encryption::Errors::Base # Losing or rotating away an encryption key is different from a digest # mismatch. The annex cannot currently be read, so verification reports # that limited fact instead of crashing or calling the bytes modified. :annex_unreadable end |
#set_legal_hold!(held) ⇒ Object
517 518 519 |
# File 'lib/clickwrap/models/event.rb', line 517 def set_legal_hold!(held) update_columns(on_legal_hold: held) end |
#statement(statement_key) ⇒ Object
255 256 257 |
# File 'lib/clickwrap/models/event.rb', line 255 def statement(statement_key) statements.find { |candidate| candidate.statement_key == statement_key.to_s } end |
#to_s ⇒ Object
531 |
# File 'lib/clickwrap/models/event.rb', line 531 def to_s = "#{event_type} #{policy_key} #{id}" |
#track_pending_receipt(pending_receipt) ⇒ Object
426 427 428 429 |
# File 'lib/clickwrap/models/event.rb', line 426 def track_pending_receipt(pending_receipt) (@pending_receipts ||= []) << pending_receipt pending_receipt end |